Sem Göksu
Sem Göksu
Yazılım · Yolculuk · Fenerbahçe
C#

C# Ustası — Bölüm 2: Span<T>, Memory<T> ve Sıfır Allocation Performans

Span ve Memory tipleriyle sifir allocation parsing, stackalloc, ArrayPool, SearchValues, P/Invoke interop ve BenchmarkDotNet olcumleri. ref struct kurallari, async sinirinda Memory'ye gecis ve dikkat edilmesi gereken tuzaklar — performans kritik C# kodunun omurgasi.

03 Aralık 2025 9 dk okuma 5 0

Merhaba arkadaşlar, bu makalemizde C# Ustası serisinin ikinci bölümündeyiz ve çok sevdiğim bir konuya giriyoruz — Span<T>, Memory<T> ve sıfır allocation performans dünyası. Bu konuyu iyi anlayan bir geliştirici, aynı işi yapan iki kodu yazdığında birincisi saniyede 50 bin isteğe cevap verirken ikincisinin 2 milyon isteğe cevap vermesini sağlayabilir. Abartı değil, ben kendi API projelerimde gördüm. Başlamadan bir kahve alın, çünkü önce hafıza modeline döneceğiz.

Stack ve Heap — Hızlıca Geri Dönelim
Bu konunun omurgası, ne olduğunu bilmediğimizde Span'i anlamanın imkansız olması. Kısa bir tekrar:

- Stack: Her thread'in kendi yığını. Push ve pop çok hızlı — sadece stack pointer oynatılır. Boyut derleme zamanı biliniyorsa buraya konabilir. Lokal değişkenler, value type'lar burada yaşar.
- Heap: Tüm thread'lerin paylaştığı, GC tarafından yönetilen bölge. new ile oluşturduğumuz her reference type burada doğar. GC çöp topladığında uygulamanız kısa süreli duraklar ("GC pause").
- Allocation maliyeti: Tek bir obje tahsisi 10-30 nanosaniye. Ama saniyede milyonlarca allocation yaparsanız GC sürekli çalışır, "throughput"ın yarısı çöpü topluyor olur.

İşte hot path'te (isteklerin hızlı çalışması gereken kısımlarda) allocation'ı azaltmak, uygulamanızın gözle görülür şekilde hızlanmasını sağlar. Ve bu noktada Span devreye girer.

Span<T> Nedir?
En temel tanım: Span, bir belleğe yapılan pencere. Kendisi veri saklamaz, sadece "şu adreste şu kadar T elemanı var" der. Ama bu pencere array, stackalloc, unmanaged memory, string — fark etmez her türlü belleği işaret edebilir.

int[] dizi = { 1, 2, 3, 4, 5 };
Span<int> pencere = dizi.AsSpan(1, 3); // dizi[1], dizi[2], dizi[3]
pencere[0] = 99; // dizi[1] = 99

Span<int> stackBuffer = stackalloc int[10]; // stack'te 10 elemanlık int
for (int i = 0; i < 10; i++) stackBuffer[i] = i * i;

string metin = "Merhaba Dunya";
ReadOnlySpan<char> kelime = metin.AsSpan(0, 7); // "Merhaba"

Span'in bu üç kullanımı da sıfır allocation. Dizi zaten vardı, stackalloc stack'te, string Span ise aynı string'in karakterlerini gösteriyor. Yeni hiçbir heap objesi oluşmadı.

ref struct — Span Neden Özel Bir Tiptir?
Span'in tip tanımına baktığımızda ilginç bir detay görürüz:

public readonly ref struct Span<T> { ... }

ref struct, C#'ın en çok kısıtlanmış tipidir. Kuralları:

- Asla heap'e gitmez. Field olamaz (class içinde), boxing yapılmaz, static tutulamaz.
- async method'larda local olamaz — async state machine heap'e alındığı için.
- lambda kapatamaz. Closure heap'te, ref struct orada olamaz.
- iterator'da (yield return ile) olamaz.
- Jenerik parametre olamaz — C# 13'e kadar. (Yeni allows ref struct kısıtı ile artık olabilir.)

Bu kısıtlamalar zorluk gibi görünse de güvenlik garantisi: Span, işaret ettiği bellek serbest bırakıldıktan sonra yaşamaya devam edemez. Stack'te kalır, stack çerçevesi sonlandığında kaybolur.

Memory<T> — Span'in Async Kardeşi
Span async'te kullanılamıyor dedik. Peki dosyadan veri okumak, network'ten parse etmek gibi işlerde ne yapacağız? İşte Memory<T> burada devreye girer:

public readonly struct Memory<T> { ... } // normal struct, heap'e gidebilir

Memory de bir belleğe pencere açar ama ref struct değil, normal struct. Dolayısıyla async state machine'in field'ı olabilir. İhtiyaç duyduğunda memory.Span ile senkron bir pencereye çevrilir:

public async Task<int> ReadAsync(Stream s, Memory<byte> buffer)
{
    int read = 0;
    while (read < buffer.Length)
    {
        int n = await s.ReadAsync(buffer.Slice(read));
        if (n == 0) break;
        read += n;
    }
    return read;
}

Önemli kural: Memory async method'a git, iç döngüde Span'e dönüştür, span ile işle. Span sıcak loop'ta, Memory cross-await boundary'de.

Gerçek Örnek: CSV Satırı Parse Etmek — Sıfır Allocation
Klasik yol:

string satir = "123,Sem,sem@example.com,2024-01-01";
string[] parcalar = satir.Split(',');
int id = int.Parse(parcalar[0]);
string ad = parcalar[1];
string email = parcalar[2];
DateTime tarih = DateTime.Parse(parcalar[3]);

Bu tek satır kaç allocation yapıyor? Split sonucu yeni bir string array, her bir parça ayrı string, toplamda 5 heap allocation. Saniyede 1 milyon satır işlerseniz 5 milyon allocation — GC zora girer. Şimdi aynı işi sıfır allocation ile:

public static (int Id, string Ad, string Email, DateTime Tarih) Parse(ReadOnlySpan<char> satir)
{
    // Birinci virgül
    int v1 = satir.IndexOf(',');
    var idSpan = satir[..v1];
    satir = satir[(v1 + 1)..];

    int v2 = satir.IndexOf(',');
    var adSpan = satir[..v2];
    satir = satir[(v2 + 1)..];

    int v3 = satir.IndexOf(',');
    var emailSpan = satir[..v3];
    var tarihSpan = satir[(v3 + 1)..];

    int id = int.Parse(idSpan);                      // Span overload — allocation YOK
    DateTime tarih = DateTime.Parse(tarihSpan);       // Span overload — allocation YOK

    // ad ve email string olmak zorunda — çünkü pointer saklayacağız
    return (id, adSpan.ToString(), emailSpan.ToString(), tarih);
}

Burada int.Parse ve DateTime.Parse'ın Span overload'larını kullandık — bunlar allocation yapmaz. ad ve email string'e dönüştürdük çünkü dışarıya döndüreceğimiz değer, span ile dönemezdik. Ama 4 allocation'dan 2'ye indik.

Eğer parse edilen değerleri sadece kontrol edip atacaksak (örneğin validation) hiç string'e çevirmeden sadece span'leri karşılaştırmak mümkün:

if (emailSpan.Contains('@') && emailSpan.EndsWith(".com"))
{
    // geçerli — hiç string oluşturmadık!
}

MemoryExtensions ve SearchValues<T> — Modern Arama
.NET 8 ile gelen SearchValues<T>, çoklu karakter arama için optimize edilmiş bir yapı. SIMD kullanarak birden fazla değer ararken CPU'yu zorlar:

private static readonly SearchValues<char> Ayiraclar =
    SearchValues.Create(",;|\t");

public static int OnekiIndex(ReadOnlySpan<char> satir)
{
    return satir.IndexOfAny(Ayiraclar); // SIMD hızında
}

Span extension'ı olan MemoryExtensions.Split (enumerator tabanlı, allocation yok):

foreach (Range r in "a,b;c|d".AsSpan().Split(new[] { ',', ';', '|' }))
{
    ReadOnlySpan<char> parca = "a,b;c|d".AsSpan()[r];
    // parça üzerinde işlem
}

Geleneksel string.Split'in aksine burada hiç string array alloke etmiyoruz — sadece aralık (Range) enumerasyonu yapıyoruz.

ArrayPool — Geçici Bufferlar İçin
Büyük diziler için stackalloc riskli (stack overflow). Bunun yerine ArrayPool:

var pool = ArrayPool<byte>.Shared;
byte[] buf = pool.Rent(4096);
try
{
    Span<byte> span = buf.AsSpan(0, 4096);
    int n = await stream.ReadAsync(buf.AsMemory(0, 4096));
    // span üzerinde işlem
}
finally
{
    pool.Return(buf, clearArray: true);
}

Pool'dan kiraladığımız array GC tarafından toplanmaz — geri iade ederiz. Yüksek trafikli API'lerde bu pattern allocation grafiğini düzeltir.

P/Invoke ile Interop
Native kod ile çalışırken Span allocation'sız marshalling sağlar:

[LibraryImport("native")]
private static partial int NativeParse(ReadOnlySpan<byte> input, Span<byte> output);

public static int Parse(ReadOnlySpan<byte> input)
{
    Span<byte> output = stackalloc byte[256];
    return NativeParse(input, output);
}

Hiç array kopyalamadan C/C++ kütüphanesine veri gönderiyoruz.

Performans Ölçümü — BenchmarkDotNet
Bu konuda iddialı konuşmak için ölçmek lazım. BenchmarkDotNet ile:

Visual Studio tarafında bu paketi yüklemek için Solution Explorer'da projeye sağ tık > "Manage NuGet Packages..." deyip arama kutusuna "BenchmarkDotNet" yazıp kurabilirsiniz. Daha hızlı yolu: View > Other Windows > Package Manager Console'u açıp Install-Package BenchmarkDotNet komutunu çalıştırmak. Çalıştırmadan önce projenizi mutlaka Release konfigürasyonuna geçirin — BenchmarkDotNet debug build'de direkt exception fırlatır. Build Configuration Manager'ı Build menüsünden açıp "Active solution configuration: Release" dediğinizde hazırsınız.

[MemoryDiagnoser]
public class ParseBench
{
    private const string Satir = "123,Sem,sem@example.com,2024-01-01";

    [Benchmark(Baseline = true)]
    public (int, string, string, DateTime) Klasik()
    {
        var p = Satir.Split(',');
        return (int.Parse(p[0]), p[1], p[2], DateTime.Parse(p[3]));
    }

    [Benchmark]
    public (int, string, string, DateTime) Span()
    {
        return Parse(Satir.AsSpan());
    }
}

Tipik sonuç (makineye göre değişir):

|   Method |    Mean | Allocated |
|--------- |--------:|----------:|
|  Klasik  | 312 ns  | 240 B     |
|    Span  | 142 ns  |  64 B     |

Yaklaşık 2x hız, 4x azaltılmış allocation.

BenchmarkDotNet sonuçları — Span vs string allocation
BenchmarkDotNet raporu — Span tabanlı parse sıfır/az allocation, Split tabanlı klasik yol 2-3x daha yavaş ve 240B/iter tahsisat

Benchmark çalıştıktan sonra proje klasöründe BenchmarkDotNet.Artifacts/ oluşur; orada markdown, CSV ve HTML raporlar yan yana durur. Özellikle markdown raporu VS içinde açıp tablo görünümünde incelemek ayrı bir keyif — takım sunumuna doğrudan yapıştırabilirsiniz.

Visual Studio tarafında gerçek production kodunu optimize ederken iki araç vazgeçilmez. Debug > Windows > Diagnostic Tools penceresi uygulama çalışırken GC event'lerini, memory kullanımını ve CPU'yu canlı gösterir — Span'e geçtikten sonra Gen0 grafiğinin ne kadar düzleştiğini gözünüzle görmek motive edicidir. Bir diğeri: Analyze menüsü > Performance Profiler (Alt+F2). ".NET Object Allocation Tracking" seçeneğini işaretleyip profile alırsanız raporda hangi call-site'ın kaç byte allocate ettiği satır satır çıkar. İlk optimize ettiğim projede bu profiler sayesinde beklenmedik bir string.Concat bulmuştum — gizlice + operatörü kullanılmıştı.

Tuzaklar ve Anti-Pattern'ler
Span güçlü ama bazı ayak kaymaları var:

- ToArray() gereksiz kopyası: span.ToArray() yeni allocation — sadece gerçekten array lazımsa kullanın. Aksi halde span'le devam edin.
- Span'i field olarak tutmak: Class içinde Span<T> field olamaz. Compile error alırsınız.
- async içinde Span tutmak: Aynı nedenle local bile olsa await'ten sonra span yaşayamaz. Memory kullanın.
- stackalloc'u büyük tutmak: 10 KB üzerinde stack overflow olabilir. Üst sınır koyun, ArrayPool'a düşün.
- Boxing ile kaybetmek: object o = span derleme hatası değil ama interface çağrısı gibi gizli boxing olabilir. Profile edin.
- Escape eden ref: ref return ederken, işaret ettiğiniz bellek siz geri dönmeden ölmemeli. Derleyici bazen fark etmez, memory corruption olur.

Ne Zaman Span Kullanmamalıyız?
Her yere Span saçmak da hata. Aşağıdaki durumlarda klasik yollar daha iyi:

- Public API'lerde: Tüketicileri zorluyor. Onlara string, IEnumerable verin, içeride span'lı optimize edin.
- Hot path değilse: Saniyede 100 kez çalışan bir kodda allocation farkı görünmez. Okunabilirliği feda etmeyin.
- Test edilebilirlik kaybı: Span'lı kod mock etmek zor. Bazen bir array parametresi daha esnek.
- Kod karmaşıklığı: Junior ekibinizde Span deneyimi azsa, hemen saçma yerine önce eğitimi tamamlayın.

Özet
Span ve Memory, modern C#'ın performans silahları. Stack tabanlı pencereler, ref struct kuralları, stackalloc ve ArrayPool — hepsi aynı orkestranın parçası. Doğru yerde kullanıldığında allocation'ı ve GC pause'u görünür şekilde azaltırlar. Ama her zaman ölçerek ilerleyin — optimizasyon önce kanıt ister.

Bir sonraki bölümde async/await'in iç mekaniğine bakacağız. Derleyici bu basit gibi duran iki kelimeyi nasıl bir state machine'e çeviriyor, ValueTask ne zaman kazandırıyor ne zaman zarar veriyor, ConfigureAwait kütüphane kodunda hâlâ neden önemli — hepsini göreceğiz. Görüşürüz!

Paylaş:

Yorumlar (0)

Henüz yorum yok. İlk yorumu sen yap!

Yorum bırak

* Yorumlar moderasyon sonrası yayınlanır. E-posta gizli tutulur.