Yazılarımız

Veri Akademi

C# PERFORMANS: SPAN<T>, MEMORY<T> VE ALLOCATİON AZALTMA TEKNİKLERİ

Performans sorunlarının büyük kısmı “yavaş algoritma”dan değil, görünmez küçük maliyetlerin birikmesinden çıkar: gereksiz kopyalama, kısa ömürlü nesneler ve sık çalışan garbage collector. Özellikle yüksek trafikli servislerde veya gerçek zamanlı işleme yapan uygulamalarda, bu maliyetler CPU’yu olduğu kadar gecikmeyi de büyütür.

Modern C# ile bu tabloyu tersine çevirmek mümkün. Span<T> ve Memory<T>, veriyi kopyalamadan “pencereleyerek” işleyebilmenizi sağlar; allocation azaltma teknikleri ise GC baskısını düşürür. Doğru senaryoda, yalnızca daha hızlı değil, daha tutarlı ve öngörülebilir gecikme değerleri elde edersiniz.

Bu yazıda, Span ve Memory’nin ne zaman kullanılacağını, hangi kısıtları olduğunu ve pratik tekniklerle allocation’ı nasıl minimize edeceğinizi; ölçüm ve tasarım kararlarıyla birlikte ele alacağız.

Sunucu tarafı kodda tampon yönetimini temsil eden, CPU grafikleri ve bellek akışını anlatan düzen

Primary Keyword Odaklı Çerçeve: C# Span<T> performansı neden kritik?

C# Span<T> performansı denince aslında iki ana hedef var: kopyalamayı azaltmak ve kısa ömürlü tahsisleri (allocation) düşürmek. Özellikle “hot path” olarak adlandırılan, çok sık çalışan kod yollarında küçük bir allocation bile milyonlarca çağrıda hissedilir.

Span<T>, mevcut bir diziye, stack üstündeki bir tampon alanına ya da belirli bellek bloklarına “view” sağlar. Bu, veriyi yeni bir diziye kopyalamadan dilimleyebilmeniz (slicing) anlamına gelir. Bu yaklaşım, string ayrıştırma (parsing), protokol çözümleme, dosya/akış okuma ve yüksek hacimli serileştirme senaryolarında ciddi fark yaratır.

Span<T> ne sağlar, ne sağlamaz?

  • Kopyasız dilimleme: Aynı veriye farklı aralıklardan erişim.
  • Allocation azaltma: Geçici diziler ve substring benzeri kopyaların önüne geçme.
  • Güvenli sınır kontrolleri: Düz pointer işlemlerine göre daha güvenli bir model.
  • Kısıt: Span<T> bir ref struct olduğu için heap üzerinde tutulamaz, async/iterator sınırlarını geçemez.

Span<T> temelleri: Dilimleme ve kopyalamadan kaçınma

En yaygın “gizli” performans problemi, bir verinin tekrar tekrar dönüştürülmesi veya bölünmesidir. Örneğin bir metni parçalarken substring kullanmak çoğu zaman yeni string üretir ve bu da allocation demektir. Span temelli yaklaşımda aynı bellek üzerinde, farklı aralıkları işlersiniz.

String ayrıştırmada Span yaklaşımı

Bir header satırından anahtar-değer çıkarmayı düşünün. Klasik yaklaşımda Split veya Substring ile her parça için yeni string üretirsiniz. Span ile hedefiniz, yalnızca gerçekten ihtiyacınız olan yerde string üretmek; mümkünse parsing işlemini tamamen span üzerinde bitirmektir.

using System;

public static class HeaderParser
{
    public static bool TryParseHeader(ReadOnlySpan<char> line, out ReadOnlySpan<char> key, out ReadOnlySpan<char> value)
    {
        int idx = line.IndexOf(':');
        if (idx <= 0)
        {
            key = default;
            value = default;
            return false;
        }

        key = line.Slice(0, idx).Trim();
        value = line.Slice(idx + 1).Trim();
        return true;
    }
}

Bu örnekte, key ve value doğrudan aynı satırın içinden “görünüm” olarak gelir. Bu sayede Split/Substring gibi kopyalayan işlemlerden kaçınırsınız. Eğer sonunda bir sözlüğe koymak istiyorsanız, o noktada kontrollü biçimde string’e dönüştürüp allocation’ı “tek bir yerde” toplarsınız.

Slicing stratejisi: Küçük pencereler, büyük tasarruf

Slicing, veriyi parça parça işlemek için ideal bir tekniktir. Ancak aşırı dilimleme, gereksiz sınır kontrolüne ve okunabilirlik kaybına yol açabilir. İyi bir denge için şu yaklaşım işe yarar: önce tek bir “parse pass” ile indeksleri belirleyin, sonra minimum sayıda Slice çağrısıyla işleyin.

Memory<T> ve ReadOnlyMemory<T>: Async uyumlu tampon yönetimi

Span<T>’in en büyük kısıtı, heap üzerinde saklanamaması ve async sınırlarını geçememesidir. Burada devreye Memory<T> ve ReadOnlyMemory<T> girer. Memory, heap üzerinde taşınabilen bir “view” sağlar; gerektiğinde .Span ile Span’a dönüşebilir.

Ne zaman Memory kullanmalı?

Bir tamponu methodlar arasında taşımanız, bir nesnenin alanında saklamanız veya async operasyonlarda “iş bitene kadar” elde tutmanız gerekiyorsa Memory kullanın. Örneğin bir ağ okumasında buffer’ı pooled olarak alıp, okuma tamamlanana kadar saklamak tipik bir Memory senaryosudur.

Span ve Memory birlikte çalışma modeli

Pratikte sık görülen desen şudur: dış katmanlarda Memory ile yaşam döngüsünü yönet, iç katmanlarda Span ile hızlı işle. Böylece hem async uyumu yakalarsınız hem de hot path’te Span optimizasyonlarından faydalanırsınız.

Allocation azaltma teknikleri: GC baskısını düşürmenin yolları

Allocation azaltma, yalnızca “daha az nesne üret” demek değildir. Amaç, “gereksiz kısa ömürlü nesne” üretimini azaltmak ve GC baskısını kontrol altına almaktır. Bu bölümde Span/Memory ile birlikte kullanılan etkili teknikleri ele alalım.

stackalloc ile küçük tamponları stack üzerinde tutma

Kısa ömürlü ve küçük boyutlu tamponlar için stackalloc, heap allocation’ı tamamen ortadan kaldırabilir. Ancak stack alanı sınırlıdır; boyut kontrolü ve güvenli kullanım önemlidir. Bu teknik, özellikle formatlama, küçük parsing ve geçici byte/char tamponları için etkilidir.

using System;

public static class FastFormatter
{
    public static string ToHex(int value)
    {
        Span<char> buffer = stackalloc char[8];
        for (int i = 7; i >= 0; i--)
        {
            int nibble = value & 0xF;
            buffer[i] = (char)(nibble < 10 ? '0' + nibble : 'A' + (nibble - 10));
            value >>= 4;
        }
        return new string(buffer);
    }
}

Burada yalnızca finalde string üretilir; ara tampon heap’e çıkmaz. Bu, özellikle sık çağrılan formatlama kodlarında stabil bir kazanım sağlar.

ArrayPool kullanımı: Büyük dizileri tekrar kullanma

Büyük diziler, hem allocation maliyeti hem de GC süresi açısından pahalıdır. ArrayPool ile dizileri kiralayıp iade ederek allocation’ı azaltabilirsiniz. Buradaki kritik nokta, buffer’ın yaşam döngüsünü net tutmak ve iade etmeyi garanti altına almaktır.

Bir örnek olarak, dosya okuma veya büyük JSON parçaları üzerinde çalışan bir işlemci düşünün. Her çağrıda yeni byte[] üretmek yerine pool’dan kiralayarak GC baskısını düşürürsünüz.

Gerçekçi senaryo: Kopyasız parsing ile gecikme iyileştirme

“Kopyasız parsing” yaklaşımı, genelde string tarafında fark edilir. Çünkü string.Split, substring ve benzeri işlemler hem CPU hem bellek üretiminde maliyetlidir. Burada hedef, veriyi span üzerinde parçalamak, minimum string üretmek ve gerekiyorsa yalnızca final çıktıyı üretmektir.

Split yerine span tabanlı ayrıştırma deseni

Aşağıdaki örnek, virgülle ayrılmış değerleri işlerken Split kullanmak yerine ReadOnlySpan<char> üzerinde dolaşır. Bu yaklaşım, özellikle log işleme, CSV benzeri formatlar ve protokol parsing işlerinde etkilidir.

using System;
using System.Collections.Generic;

public static class CsvLike
{
    public static List<int> ParseInts(ReadOnlySpan<char> line)
    {
        var result = new List<int>();
        int start = 0;

        while (start < line.Length)
        {
            int comma = line.Slice(start).IndexOf(',');
            ReadOnlySpan<char> token;

            if (comma < 0)
            {
                token = line.Slice(start).Trim();
                start = line.Length;
            }
            else
            {
                token = line.Slice(start, comma).Trim();
                start += comma + 1;
            }

            if (!token.IsEmpty && int.TryParse(token, out int value))
                result.Add(value);
        }

        return result;
    }
}

Bu kodda token’lar string’e çevrilmeden parse edilir. Eğer “sonuçların” listesi bile pahalıysa, burada da pooling veya önceden tahsis (preallocation) stratejileri düşünülür. Önemli olan, parsing sırasında gereksiz ara nesneler üretmemektir.

Metin ayrıştırmada indeks tabanlı ilerleme ve kopyasız dilimleme yaklaşımını anlatan akış şeması

Ölçüm ve doğrulama: Benchmark ile iyileştirmeyi kanıtlamak

Performans iyileştirmesinde en güvenilir rehber ölçümdür. “Daha hızlı olmalı” hissi çoğu zaman yanılabilir; hatta bazı mikro-optimizasyonlar, gerçek iş yükünde daha kötü sonuç verebilir. Bu yüzden benchmark yaklaşımını sistematik hale getirmek gerekir.

Hot path tespiti ve allocation profili

Önce nerede zaman harcadığınızı bulun. Profiler çıktılarında CPU ağırlıklı noktalar genellikle belirgindir; fakat allocation tarafı daha sinsi olabilir. Kısa ömürlü nesne üretimi, GC’nin daha sık tetiklenmesine ve “düzensiz” gecikme sıçramalarına neden olur. Bu nedenle yalnızca ortalama süreye değil, dağılıma ve p95/p99 gibi metriklere bakmak anlamlıdır.

Yanlış optimizasyon tuzağı: Okunabilirliği feda etmeden ilerlemek

Span/Memory ile kodu hızlandırmak mümkün; ancak okunabilirlik her zaman korunmalı. Kural basit: önce açık bir versiyon yazın, sonra sadece gerçekten hot path olan bölümleri optimize edin. Ayrıca, span temelli kodlarda yaşam döngüsü hataları “yanlış veri” üretimi gibi sessiz sonuçlar doğurabilir; test kapsamı burada daha da önem kazanır.

Tasarım kararları: API yüzeyinde Span/Memory nasıl sunulur?

Kütüphane veya ortak bir modül yazıyorsanız, API tasarımı kritik hale gelir. Doğrudan Span<T> almak, çağıran taraf için daha hızlı olabilir; fakat async veya saklama gereksinimleri varsa Memory<T> daha uygundur. Genelde iki yaklaşım öne çıkar: overload’lar ve “çekirdek method” deseni.

Overload yaklaşımı: Kullanıcıya seçenek sunma

Bir methodu hem string hem ReadOnlySpan<char> ile sunmak, yaygın bir pratiktir. string overload’u geleneksel kullanıcıları desteklerken, span overload’u performans kritik çağrıları hedefler. Bu sayede “hız isteyen hızını alır”, diğerleri basit kullanımı korur.

Çekirdek method deseni: Tek yerde optimize etme

Bir “core” methodu ReadOnlySpan<T> ile yazıp, diğer overload’ları ona yönlendirmek bakımı kolaylaştırır. Böylece optimizasyonu tek yerde yapar, davranış farklarını minimize edersiniz. Bu yaklaşım, özellikle parsing ve formatlama yardımcılarında çok etkilidir.

Yaygın hatalar ve güvenli kullanım kontrol listesi

Span/Memory kullanımında performans kazanımı kadar, güvenli kullanım da önemlidir. Aşağıdaki kontrol listesi, gerçek projelerde sık karşılaşılan hataları önlemeye yardımcı olur.

Ref struct sınırları ve async bariyerleri

  • Span<T>’i alan olarak saklamayın; heap’e çıkamaz.
  • Span<T> referansını async/await sınırının ötesine taşımayın.
  • Gerekirse Memory<T> kullanın ve içte .Span ile işleyin.

Pooling hataları: Kiralanan diziyi iade etmeyi unutmak

ArrayPool ile en büyük risk, iade etmeyi unutmak veya yanlış zamanda iade etmektir. “Try/finally” deseni neredeyse zorunludur. Ayrıca pooled diziyi iade ettikten sonra kullanmak, hatalı davranışlara neden olur. Bu noktada, yaşam döngüsünü basit tutan bir yardımcı sınıf veya pattern kullanmak çoğu ekipte daha güvenli sonuç verir.

Dizi havuzu ile kiralama-iade yaşam döngüsünü ve GC yükünün azalmasını anlatan kavramsal diyagram

Uygulama planı: Hangi sırayla iyileştirmeli?

Bu teknikleri “her yere” uygulamak yerine, adım adım ilerlemek daha doğru sonuç verir. Önce ölçün, sonra en pahalı akışları seçin. Genelde aşağıdaki sıralama iyi çalışır:

  1. Hot path’i belirleyin ve mevcut durumda allocation kaynaklarını çıkarın.
  2. Split/Substring gibi kopyalayan işlemleri span tabanlı ayrıştırma ile değiştirin.
  3. Küçük geçici tamponlarda stackalloc, büyük tamponlarda pooling uygulayın.
  4. API yüzeyinde Span ve Memory stratejisini belirleyin; overload veya core method deseni seçin.
  5. Benchmark ve regresyon testleri ile kazanımı doğrulayın; okunabilirliği koruyun.

Eğitim ile hızlanmak: Uygulamalı pratik

Span/Memory konuları, “kısa bir refactor” gibi görünse de gerçek fayda, doğru senaryoyu seçmekte ve yaşam döngüsü/ölçüm disiplininde ortaya çıkar. Konuyu daha sistematik ele almak isterseniz C# eğitimi sayfasındaki performans ve modern dil özellikleri modülleri iyi bir başlangıç noktası olabilir.

Kapanış: Tutarlı performans için doğru araç seti

Span<T> ve Memory<T>, modern C#’ın performans odaklı en değerli yapı taşları arasında. Doğru kullanıldığında, yalnızca hız değil, daha düşük bellek dalgalanması ve daha stabil gecikme değerleri sağlar. Önemli olan, ölçüm ile ilerlemek, allocation kaynaklarını görünür kılmak ve optimizasyonu “hedefli” uygulamaktır.

 VERİ AKADEMİ