C#'ta Span<T> ve Memory<T>: Allocation'sız Parse, stackalloc ve ref struct Kuralları
Span<T> ile kopyasız dilimleme ve allocation'sız parse, ölçülmüş allocation farkı, stackalloc ve güvenli kullanım pattern'i, ref struct kısıtları ve async kod için Memory<T>.
Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.
Önce Tahmin Et
Elimizde dört kitabın stok sayısı var. İlk iki element'i iki farklı yolla alıyoruz: biri array üzerinde range, diğeri AsSpan() üzerinde range. Sonra ikisinde de birer element'i değiştiriyoruz. İki satırda ne yazar?
int[] stock = { 5, 3, 8, 1 };
int[] copy = stock[..2];
copy[0] = 42;
Span<int> view = stock.AsSpan()[..2];
view[1] = 99;
Console.WriteLine(string.Join(", ", stock));
Console.WriteLine(string.Join(", ", copy));
Span<T>: Belleğe Açılan Bir Pencere
Çıktı:
5, 99, 8, 1
42, 3
İki range aynı görünüyor ama bambaşka işler yapıyor. Array üzerindeki stock[..2] yeni bir array oluşturup iki element'i ona kopyalar; copy[0] = 42 orijinale dokunmaz. stock.AsSpan()[..2] ise hiçbir şey kopyalamaz. Aynı belleğin ilk iki element'ine bakan bir pencere verir; view[1] = 99 doğrudan stock'un ikinci element'ini değiştirdi.

Span<T> tam olarak bu: bellekte art arda duran bir bölgeye bir referans ve bir uzunluk. Pencerenin arkasında managed bir array, stackalloc ile stack'te ayrılmış bir alan ya da native bellek olabilir; span için fark etmez, hepsine aynı API'yle erişirsin. Indexer, Slice, range, foreach, CopyTo, Fill, IndexOf hepsi kopya üretmeden çalışır. Salt okunur versiyonu ReadOnlySpan<T>, ve string doğrudan ReadOnlySpan<char>'a dönüşebilir.
Bir analojiyle: array bir kitap rafı, Substring ya da array range'i raftaki kitapların fotokopisini çekip yeni bir rafa dizmek, span ise rafın bir bölümünü işaret eden bir cetvel. Cetveli istediğin kadar kaydırırsın, fotokopi makinesi hiç çalışmaz.
String'i Kopyalamadan Parçalamak
Span'in en sık kullanıldığı yer parse etmek. Dosyadan ya da network'ten gelen bir satırı field'larına ayırmak istiyorsun. Klasik yol Split ya da Substring; ikisi de her parça için yeni bir string üretir. Aynı işi ReadOnlySpan<char> ile yapalım:
using System.Globalization;
const string line = "978-0441013593;Dune;9.99;12";
ReadOnlySpan<char> rest = line;
int sep = rest.IndexOf(';');
ReadOnlySpan<char> isbn = rest[..sep];
rest = rest[(sep + 1)..];
sep = rest.IndexOf(';');
ReadOnlySpan<char> title = rest[..sep];
rest = rest[(sep + 1)..];
sep = rest.IndexOf(';');
decimal price = decimal.Parse(rest[..sep], NumberStyles.Number, CultureInfo.InvariantCulture);
int stock = int.Parse(rest[(sep + 1)..]);
Console.WriteLine($"isbn={isbn.ToString()} title={title.ToString()} price={price.ToString(CultureInfo.InvariantCulture)} stock={stock}");
Console.WriteLine(isbn.StartsWith("978"));
isbn=978-0441013593 title=Dune price=9.99 stock=12
True
rest satırın geri kalanına bakan bir pencere. Her adımda IndexOf ile ayıracı bulup pencereyi kaydırıyoruz; isbn ve title da yeni string değil, orijinal satırın parçalarına bakan span'ler. .NET 6'da int.Parse, decimal.Parse, Guid.Parse, DateTime.Parse gibi method'ların ReadOnlySpan<char> alan overload'ları var; sayıyı ara bir string oluşturmadan doğrudan pencereden okuyorlar. StartsWith, Trim, Contains gibi yardımcılar da MemoryExtensions üzerinden span'lere extension method olarak geliyor.
ToString() çağrısına dikkat: span'i string'e çeviren tek şey o ve her çağrıda yeni bir string ayırır. Burada sadece ekrana yazdırmak için kullandık. Gerçek kodda amaç, string'e ihtiyacın olan son ana kadar span'le kalmak.
Ne Kadar Allocation Kazandık?
"Kopyalamıyor" demek kolay; ölçelim. Aynı satırı yüz bin kez iki yolla parse edip thread'in ayırdığı byte'ları sayıyoruz:
using System.Globalization;
const string line = "978-0441013593;Dune;9.99;12";
const int iterations = 100_000;
decimal total = 0;
ParseWithSplit(line);
ParseWithSpan(line);
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < iterations; i++)
total += ParseWithSplit(line);
long splitBytes = GC.GetAllocatedBytesForCurrentThread() - before;
before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < iterations; i++)
total += ParseWithSpan(line);
long spanBytes = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"Split: {splitBytes / iterations} bytes per line");
Console.WriteLine($"Span: {spanBytes / iterations} bytes per line");
Console.WriteLine(total.ToString(CultureInfo.InvariantCulture));
static decimal ParseWithSplit(string text)
{
string[] parts = text.Split(';');
decimal price = decimal.Parse(parts[2], CultureInfo.InvariantCulture);
int stock = int.Parse(parts[3]);
return price * stock;
}
static decimal ParseWithSpan(string text)
{
ReadOnlySpan<char> rest = text.AsSpan();
for (int skip = 0; skip < 2; skip++)
rest = rest[(rest.IndexOf(';') + 1)..];
int sep = rest.IndexOf(';');
decimal price = decimal.Parse(rest[..sep], provider: CultureInfo.InvariantCulture);
int stock = int.Parse(rest[(sep + 1)..]);
return price * stock;
}
Split: 208 bytes per line
Span: 0 bytes per line
23976000.00
Split her satır için 208 byte ayırdı: dört element'li bir string array'i ve dört yeni string. Span versiyonu sıfır. Bu sayılar .NET 10 SDK ile derlenip DOTNET_ROLL_FORWARD=Major ile .NET 8.0.25 runtime'ında koşan programdan geldi (bu makinede .NET 6 runtime yok); runtime değiştikçe byte sayısı biraz oynayabilir, sıfır sıfır kalır. Döngüden önceki iki çağrı ısınma için: ilk çağrıda JIT'in ve kültür verisinin yaptığı tek seferlik ayırmalar ölçüme karışmasın.
208 byte küçük görünür. Saniyede on binlerce satır işleyen bir import job'ında ya da her request'te header parse eden bir middleware'de ise bu, garbage collector'ın sürekli çalışması demek. Allocation azaldıkça GC daha seyrek devreye girer, gecikme dalgalanmaları azalır. Süre ölçümü işin ayrı bir boyutu; onu bu serinin performans yazısında BenchmarkDotNet ile yapacağız.
stackalloc: Heap'e Hiç Uğramamak
Bazen küçük, geçici bir buffer'a ihtiyacın olur: bir sayıyı biçimlendirmek, bir checksum hesaplamak. stackalloc bu buffer'ı heap yerine stack'te ayırır; method bitince kendiliğinden yok olur, GC'nin haberi bile olmaz. C# 7.2'den beri sonucu doğrudan Span<T>'e atayabiliyorsun ve unsafe gerekmiyor:
using System.Globalization;
decimal[] prices = { 9.99m, 124.5m, 1250m };
Span<char> buffer = stackalloc char[32];
foreach (decimal price in prices)
{
if (price.TryFormat(buffer, out int written, "F2", CultureInfo.InvariantCulture))
{
ReadOnlySpan<char> text = buffer[..written];
Console.WriteLine($"{text.ToString()} ({written} chars)");
}
}
Console.WriteLine(Checksum(10));
Console.WriteLine(Checksum(10_000));
static int Checksum(int length)
{
const int StackLimit = 256;
Span<byte> data = length <= StackLimit
? stackalloc byte[StackLimit]
: new byte[length];
data = data[..length];
for (int i = 0; i < data.Length; i++)
data[i] = (byte)(i % 7);
int sum = 0;
foreach (byte b in data)
sum += b;
return sum;
}
9.99 (4 chars)
124.50 (6 chars)
1250.00 (7 chars)
24
29994
TryFormat, string üretmeden sayıyı verilen buffer'a yazar ve kaç karakter yazdığını out ile döner. Çoğu primitive tipte (int, decimal, DateTime, Guid) bu method var; buffer yetmezse exception fırlatmak yerine false döner.
Checksum'daki pattern önemli. Stack sınırlı bir alandır (genelde birkaç megabyte; işletim sistemine ve thread'e göre değişir) ve taşarsa yakalanamayan bir StackOverflowException ile process çöker. Bu yüzden boyut runtime'da belli oluyorsa üst sınır koyarsın: küçükse stack, büyükse heap. İki branch de Span<byte> verdiği için kodun geri kalanı farkı bilmez. Bu pattern .NET'in kendi kaynak kodunda da sık geçer; sık tekrarlanan büyük buffer'lar için ArrayPool<T>.Shared ile kiralanmış bir array'i span olarak kullanmak da yaygın bir alternatif.
ref struct Olmanın Bedeli
Span<T> bir ref struct. Yani sadece stack'te yaşayabilir; heap'e taşınabileceği hiçbir yere konamaz. Stack'teki bir buffer'a bakan span'in, method bittikten sonra hâlâ bir class field'ında durduğunu düşün: artık geçersiz bir belleği gösterirdi. Compiler bunu baştan yasaklar:
var parser = new LineParser();
Console.WriteLine(parser);
public sealed class LineParser
{
private ReadOnlySpan<char> _current;
}
Program.cs(6,13): error CS8345: Field or auto-implemented property cannot be of type 'ReadOnlySpan<char>' unless it is an instance member of a ref struct.
Aynı sebeple span'i bir lambda'da yakalayamazsın (closure, yakaladığı değişkenleri heap'teki bir class'a taşır), object'e çeviremezsin (boxing heap demek) ve C# 10'da async bir method'da span local'i tanımlayamazsın (async method'un local'leri de state machine'le heap'e gidebilir):
var reader = new IsbnReader();
await reader.ReadAsync();
public sealed class IsbnReader
{
public async Task ReadAsync()
{
Span<char> local = stackalloc char[16];
await Task.Delay(10);
Console.WriteLine(local.Length);
}
public Func<int> Capture()
{
Span<char> local = stackalloc char[16];
return () => local.Length;
}
public object Box()
{
Span<char> local = stackalloc char[16];
return local;
}
}
Program.cs(8,9): error CS8936: Feature 'ref and unsafe in async and iterator methods' is not available in C# 10.0. Please use language version 13.0 or greater.
Program.cs(16,22): error CS8175: Cannot use ref local 'local' inside an anonymous method, lambda expression, or query expression
Program.cs(22,16): error CS0029: Cannot implicitly convert type 'System.Span<char>' to 'object'
Üçüncü hata ilginç: compiler, bu kısıtın C# 13'te gevşediğini söylüyor. C# 13 ile async method'da span local'i tanımlanabiliyor, ama await'in öbür tarafına taşınamıyor. Aynı kodu C# 13 ile derlediğimde hata CS4007: Instance of type 'System.Span<char>' cannot be preserved across 'await' or 'yield' boundary. oldu. Bu ayrıntıyı C# Sürümleri serisinde göreceğiz; C# 10'da kural basit: async kodda span yok.
Memory<T>: Async Dünyası İçin Span
Peki bir buffer'ı async bir method'a vermek ya da bir parçasını bir class'ta saklamak istersen? Memory<T> ve ReadOnlyMemory<T> bunun için var. Span'in yaptığını yapar (bir bölgeye referans ve uzunluk, kopyasız dilimleme) ama ref struct değil, sıradan bir struct; field'da durabilir, async method'a parametre olabilir, await'ten sağ çıkar:
using System.Text;
var catalog = new MemoryStream(Encoding.UTF8.GetBytes("978-0441013593\n978-0141439518\n"));
var cache = new TitleCache();
byte[] buffer = new byte[64];
int read = await ReadChunkAsync(catalog, buffer);
Console.WriteLine($"read {read} bytes");
ReadOnlyMemory<byte> data = buffer.AsMemory(0, read);
cache.Remember(data[..14]);
Console.WriteLine(Encoding.UTF8.GetString(cache.First.Span));
static async Task<int> ReadChunkAsync(Stream stream, Memory<byte> destination)
{
int total = 0;
while (total < destination.Length)
{
int n = await stream.ReadAsync(destination[total..]);
if (n == 0)
break;
total += n;
}
return total;
}
public sealed class TitleCache
{
public ReadOnlyMemory<byte> First { get; private set; }
public void Remember(ReadOnlyMemory<byte> isbn) => First = isbn;
}
read 30 bytes
978-0441013593
Stream.ReadAsync(Memory<byte>) overload'u buffer'ı span yerine memory olarak alıyor, çünkü okuma bir await boyunca sürüyor. TitleCache ise bir ReadOnlyMemory<byte>'ı property'de tutuyor; span'le bunu yazamazdık. Asıl işi yapmak gerektiğinde .Span property'siyle senkron bir bölgede span'e geçiyorsun. Memory biraz daha ağır (içinde bir object referansı, başlangıç ve uzunluk var) ve indexer'ı yok; element'lere erişmek için hep .Span'e inersin.
Hangisi Ne Zaman
Kısa bir kural seti, hem kendi kodun hem yazdığın API'ler için:
Senkron bir method bir bölgeyi okuyacaksa parametre tipi ReadOnlySpan<T>, yazacaksa Span<T>. Böylece çağıran taraf array, string, stackalloc ya da başka bir span'in dilimini aynı method'a verebilir. string alan bir method sadece string kabul eder; ReadOnlySpan<char> alan method hepsini.
Buffer bir await'i aşacaksa ya da bir field'da saklanacaksa Memory<T> / ReadOnlyMemory<T>. İçeride iş yapacağın an .Span'e geç.
Uygulama kodunun her yerine span serpiştirme. Okunaklı bir Split, saniyede birkaç kez çalışan bir yerde tamamen yeterli. Span'in değeri sıcak yollarda (hot path) çıkar: parser'lar, serializer'lar, her request'te çalışan kodlar. Önce ölç, sonra optimize et.
C# 11 ile gelen UTF-8 literal'leri ("abc"u8), list pattern'lerin span'lerle kullanımı ve C# 14'ün span'leri dilin birinci sınıf tipleri gibi ele alan örtük dönüşümleri bu tabloyu daha da rahatlatıyor; onları C# Sürümleri serisinde göreceğiz.
Sık Düşülen Tuzaklar
Döngü içinde stackalloc. Stack'te ayrılan alan method bitene kadar geri verilmez, döngünün her turunda değil. Yüz bin turluk bir döngüde 32 karakterlik buffer bile stack'i doldurabilir. Analyzer bunu yakalar:
using System.Globalization;
decimal[] prices = { 9.99m, 124.5m, 1250m };
foreach (decimal price in prices)
{
Span<char> buffer = stackalloc char[32];
if (price.TryFormat(buffer, out int written, "F2", CultureInfo.InvariantCulture))
Console.WriteLine(buffer[..written].ToString());
}
9.99
124.50
1250.00
Çözüm, yukarıdaki örnekteki gibi buffer'ı döngünün dışında bir kez ayırıp her turda yeniden kullanmak.
Saklanan bir pencerenin arkasındaki belleği değiştirmek. Memory ve span kopya değil, görüntü. Arkadaki buffer'ı yeniden kullanırsan saklanan dilim de değişir:
using System.Text;
byte[] buffer = Encoding.UTF8.GetBytes("978-0441013593");
ReadOnlyMemory<byte> remembered = buffer.AsMemory(0, 3);
Console.WriteLine(Encoding.UTF8.GetString(remembered.Span));
Encoding.UTF8.GetBytes("979").CopyTo(buffer, 0);
Console.WriteLine(Encoding.UTF8.GetString(remembered.Span));
string a = "Dune";
string b = string.Concat("Du", "ne");
Console.WriteLine(a.AsSpan() == b.AsSpan());
Console.WriteLine(a.AsSpan().SequenceEqual(b));
Console.WriteLine(a.AsSpan()[..2] == a.AsSpan()[..2]);
978
979
False
True
True
İlk iki satır, remembered'ın buffer'daki değişikliği "gördüğünü" gösteriyor. ArrayPool'dan kiralanmış bir buffer'ın dilimini saklayıp buffer'ı iade edersen aynı şey, başka bir isteğin verisiyle olur. Saklayacaksan kopyala (ToArray()).
Son üç satır ikinci tuzak: span'lerde == içeriği değil, aynı belleği ve aynı uzunluğu gösterip göstermediklerini karşılaştırır. a ve b içerik olarak aynı ama farklı string'ler, bu yüzden False. İçerik karşılaştırması için SequenceEqual (ya da StringComparison alan Equals) kullan.
ToString()'i farkında olmadan çağırmak. Span'le parse edip sonra her field'ı ToString() ile bir DTO'ya koyuyorsan, Split'in ayırdığına yakın bir miktarı yine ayırıyorsun demektir. Kazanç, string'e hiç ihtiyaç duyulmayan field'larda (sayılar, tarihler, karşılaştırmalar) gelir.
Küçük Bir Deneme
★☆☆ ParseWithSpan'e ISBN'in "978" ile başlayıp başlamadığını kontrol eden bir adım ekle. Ölçümü tekrarla; satır başına byte sayısı değişiyor mu?
★★☆ Virgülle ayrılmış bir fiyat listesini ("9.99,12.50,7.25") hiç string ayırmadan toplayan static decimal SumPrices(ReadOnlySpan<char> text) yaz. GC.GetAllocatedBytesForCurrentThread() ile sıfır allocation olduğunu kanıtla.
★★★ Bir Stream'den satırları okuyan ve her satırın ilk field'ını (ISBN) ReadOnlyMemory<char> olarak bir listede biriktiren bir method yaz. Sonra tuzaklar bölümündeki örneği hatırla: buffer'ı yeniden kullandığında listede ne görüyorsun? Doğru çözüm nerede kopyalamayı gerektiriyor?
Span ile bir bölgenin parçalarına kopyasız erişmeyi gördük. Bunun altında yatan dil özellikleri, yani bir değeri kopyalamak yerine ona referans döndürmek ve taşımak, bir sonraki yazının konusu: ref return, ref local, in parametreleri, readonly ref ve ref struct'ın kendisi.