.NET Core

.NET Performance Optimizasyonu: Profiling ve Best Practices

15 dk okuma9 Şubat 2026Güncellendi 9 Mar 2026
.NET performanceC# optimizationBenchmarkDotNet.NET profilingMemory management C#Span T C#Async performanceGC optimization

Performans optimizasyonu tahminle değil, ölçümle başlar. Önce profil çıkar, darboğazı tespit et, sonra müdahale et. Yüksek trafikli bir serviste optimizasyon yaparken ilk varsayım veritabanı çağrılarının yavaş olduğuydu -- profiling sonucu logging middleware'indeki gereksiz string allocations'ların Gen0 collection'ların %40'ını tetiklediğini gördük. Asıl sorunu bulduktan sonra çözüm 20 dakika sürdü.

Ölçüm Araçları

Koda dokunmadan önce doğru araçları tanımak gerekir.

dotnet-counters

Uygulamayı yeniden başlatmadan anlık runtime metriklerini izler:

bash
dotnet-counters monitor --process-id <PID> --counters System.Runtime

GC sayıları, allocation hızı, thread pool kuyruk uzunlukları ve exception sayılarını canlı olarak gösterir. Bir servis üretimde sorun çıkardığında ilk başvurduğum araç budur.

dotnet-trace

Detaylı execution trace'leri yakalar; PerfView veya Visual Studio ile analiz edebilirsiniz:

bash
dotnet-trace collect --process-id <PID> --providers Microsoft-DotNETCore-SampleProfiler

BenchmarkDotNet

Mikrobenchmark için altın standart. Performans karşılaştırmalarını asla Stopwatch ile yapmayın -- BenchmarkDotNet warmup, istatistiksel analiz ve GC ölçümlerini otomatik olarak halleder.

csharp
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
[RankColumn]
public class StringConcatBenchmark
{
    private readonly string[] _items = Enumerable.Range(0, 1000)
        .Select(i => i.ToString())
        .ToArray();

    [Benchmark(Baseline = true)]
    public string ArtiBirlesim()
    {
        var result = "";
        foreach (var item in _items)
            result += item;
        return result;
    }

    [Benchmark]
    public string StringBuilderBirlesim()
    {
        var sb = new StringBuilder(4096);
        foreach (var item in _items)
            sb.Append(item);
        return sb.ToString();
    }

    [Benchmark]
    public string StringJoinBirlesim()
    {
        return string.Join("", _items);
    }
}

// Program.cs'de:
BenchmarkRunner.Run<StringConcatBenchmark>();

APM Araçları

Application Insights, Datadog veya OpenTelemetry gibi araçlar üretim ortamındaki gecikmeleri ve hata trendlerini gösterir. Mikrobenchmark neyin mümkün olduğunu söyler; APM gerçek yük altında neler olduğunu gösterir.

Profiling İş Akışı

Disiplinli bir yaklaşım, yanlış yeri optimize etmenizi engeller.

Adım 1: Hedef Belirle

Kod yazmadan önce somut hedefler koy. "Daha hızlı yap" bir hedef değildir. "/api/orders endpoint'i 500 RPS altında P99 gecikme 200ms'nin altında olmalı" bir hedeftir.

Adım 2: Mevcut Durumu Ölç

Gerçekçi yük altında mevcut performansı ölç. k6 veya NBomber gibi yük testi araçlarını kullan:

csharp
using NBomber.CSharp;
using NBomber.Http.CSharp;

var scenario = Scenario.Create("orders_api", async context =>
{
    var request = Http.CreateRequest("GET", "https://localhost:5001/api/orders");
    var response = await Http.Send(request);
    return response;
})
.WithLoadSimulations(
    Simulation.Inject(rate: 500, interval: TimeSpan.FromSeconds(1),
                      during: TimeSpan.FromMinutes(2))
);

NBomberRunner.RegisterScenarios(scenario).Run();

Adım 3: Profil Çıkar ve Tespit Et

Yük testi sırasında dotnet-trace çalıştır veya Visual Studio Profiler'ı bağla. CPU zamanı veya allocation sayısına göre sırala. İlk 3 hotspot'a odaklan -- genellikle sorunun %80'ini oluştururlar.

Adım 4: Tek Seferde Tek Değişiklik

Birden fazla optimizasyonu asla aynı anda yapma. Her değişiklik tek başına ölçülebilmeli.

Adım 5: Tekrar Ölç ve Doğrula

Aynı yük testini aynı parametrelerle tekrar çalıştır. Ölçülebilir bir iyileşme yoksa değişikliği geri al. Faydası kanıtlanmamış karmaşıklık, teknik borçtur.

GC Optimizasyonu

Garbage Collector'ı anlamak, yüksek performanslı .NET kodu için şarttır.

Nesil Tabanlı Toplama

.NET GC üç nesil kullanır:

  • Gen0: Kısa ömürlü nesneler. Sık ve ucuza toplanır. Çoğu nesne burada ölmelidir.
  • Gen1: Kısa ve uzun ömürlü nesneler arasındaki tampon. Gen0'dan kurtulan nesneler buraya düşer.
  • Gen2: Uzun ömürlü nesneler. Tam toplama pahalıdır ve fark edilir duraksamalara yol açabilir.

GC Baskısını Azaltma

En iyi çöp toplama, hiç gerçekleşmeyen çöp toplamadır. Önlediğiniz her allocation, engellediğiniz bir collection demektir.

csharp
// KÖTÜ: Her çağrıda yeni bir byte dizisi allocate eder
public byte[] ProcessRequest(Stream input)
{
    var buffer = new byte[4096];
    int bytesRead = input.Read(buffer, 0, buffer.Length);
    // işle...
    return buffer;
}

// İYİ: Paylaşılan havuzdan kirala
public void ProcessRequest(Stream input)
{
    var buffer = ArrayPool<byte>.Shared.Rent(4096);
    try
    {
        int bytesRead = input.Read(buffer, 0, buffer.Length);
        // işle...
    }
    finally
    {
        ArrayPool<byte>.Shared.Return(buffer);
    }
}

Span\<T\> ile Sıfır-Allocation Ayrıştırma

Span<T>, yeni dizi veya string oluşturmadan belleği dilimlemenizi sağlar:

csharp
// KÖTÜ: Substring her seferinde yeni bir string allocate eder
public (string Key, string Value) ParseHeader(string header)
{
    int colonIndex = header.IndexOf(':');
    string key = header.Substring(0, colonIndex);
    string value = header.Substring(colonIndex + 1).Trim();
    return (key, value);
}

// İYİ: ReadOnlySpan ile sıfır allocation
public (ReadOnlySpan<char> Key, ReadOnlySpan<char> Value) ParseHeader(ReadOnlySpan<char> header)
{
    int colonIndex = header.IndexOf(':');
    var key = header[..colonIndex];
    var value = header[(colonIndex + 1)..].Trim();
    return (key, value);
}

Optimize ettiğim yüksek trafikli bir serviste, HTTP header parsing'i string.Split'ten Span<T> tabanlı dilimlemeye geçirmek saniyede yaklaşık 12 MB'lık Gen0 allocation'ı ortadan kaldırdı. Herhangi bir mantık değişikliği olmadan iş hacmi %35 arttı.

Nesne Havuzlama (Object Pooling)

Sık oluşturulan ve yok edilen pahalı nesneler için ObjectPool<T> kullanın:

csharp
using Microsoft.Extensions.ObjectPool;

public class JsonSerializerPoolPolicy : PooledObjectPolicy<JsonSerializerOptions>
{
    public override JsonSerializerOptions Create()
    {
        return new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            WriteIndented = false,
            DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
        };
    }

    public override bool Return(JsonSerializerOptions obj) => true;
}

// DI'da kayıt
services.AddSingleton<ObjectPool<JsonSerializerOptions>>(
    new DefaultObjectPool<JsonSerializerOptions>(new JsonSerializerPoolPolicy(), 32));

// Kullanım
public class OrderService
{
    private readonly ObjectPool<JsonSerializerOptions> _optionsPool;

    public OrderService(ObjectPool<JsonSerializerOptions> optionsPool)
    {
        _optionsPool = optionsPool;
    }

    public string Serialize(Order order)
    {
        var options = _optionsPool.Get();
        try
        {
            return JsonSerializer.Serialize(order, options);
        }
        finally
        {
            _optionsPool.Return(options);
        }
    }
}

Async Performans Tuzakları

Async/await güçlü bir araçtır ama iş hacmini sessizce öldüren tuzaklarla doludur.

Sync-over-Async

Async kod üzerinde bloklama yapmak, kod incelemelerinde karşılaştığım en yaygın performans katilidir. Thread pool tükenmesine ve deadlock'lara neden olur.

csharp
// ÇOK KÖTÜ: Async işi beklerken bir thread pool thread'ini bloklar
public string GetData()
{
    // ASP.NET'te deadlock yapabilir ve thread pool'u tüketir
    return _httpClient.GetStringAsync("https://api.example.com/data").Result;
}

// DOĞRU: Baştan sona async
public async Task<string> GetDataAsync()
{
    return await _httpClient.GetStringAsync("https://api.example.com/data");
}

Kütüphane Kodunda ConfigureAwait(false)

Kütüphane kodunda her zaman ConfigureAwait(false) kullanarak senkronizasyon bağlamının yakalanmasını engelleyin. Bu, deadlock'ları önler ve performansı artırır:

csharp
// Kütüphane/paylaşılan kodda (controller veya UI'da değil)
public async Task<Order> GetOrderAsync(int id)
{
    var json = await _httpClient
        .GetStringAsync($"/orders/{id}")
        .ConfigureAwait(false);

    return JsonSerializer.Deserialize<Order>(json)!;
}

Basit Durumlar İçin Async State Machine'den Kaçınma

Tamamlanmış bir task'ı doğrudan döndürebiliyorsanız, async mekanizmasını atlayın:

csharp
// Gereksiz: Cache'ten dönen değer için tam bir async state machine oluşturur
public async Task<Config> GetConfigAsync()
{
    if (_cache.TryGetValue("config", out var config))
        return config;

    return await LoadConfigFromDatabaseAsync();
}

// Daha iyi: Sık kullanılan yol için state machine'den kaçın
public Task<Config> GetConfigAsync()
{
    if (_cache.TryGetValue("config", out var config))
        return Task.FromResult(config);

    return LoadConfigFromDatabaseAsync();
}

Eşzamanlılığı Sınırlama

Sınırsız paralellik eşzamanlılık değildir -- kendi downstream bağımlılıklarınıza karşı bir DoS saldırısıdır.

csharp
// TEHLİKELİ: Binlerce HTTP çağrısını aynı anda ateşler
var tasks = orderIds.Select(id => _httpClient.GetAsync($"/orders/{id}"));
await Task.WhenAll(tasks);

// KONTROLLÜ: Eşzamanlı istek sayısını 20 ile sınırlar
using var semaphore = new SemaphoreSlim(20);
var tasks = orderIds.Select(async id =>
{
    await semaphore.WaitAsync();
    try
    {
        return await _httpClient.GetAsync($"/orders/{id}");
    }
    finally
    {
        semaphore.Release();
    }
});
await Task.WhenAll(tasks);

Sık Yapılan Performans Hataları

Bunlar üretim .NET kod tabanlarında tekrar tekrar gördüğüm kalıplardır.

Sıcak Yollarda LINQ

LINQ zarif ama yoğun allocation yapar. Sıkı döngülerde manuel iterasyon kazanır:

csharp
// Iterator, delegate ve ara koleksiyonlar allocate eder
var total = orders.Where(o => o.Status == OrderStatus.Completed)
                  .Sum(o => o.Amount);

// Sıcak yollar için sıfır allocation alternatifi
decimal total = 0;
foreach (var order in CollectionsMarshal.AsSpan(orders))
{
    if (order.Status == OrderStatus.Completed)
        total += order.Amount;
}

Exception ile Akış Kontrolü

Exception fırlatmak ve yakalamak son derece pahalıdır. Beklenen durumlar için asla kullanmayın:

csharp
// KÖTÜ: Basit bir null kontrolünden 50 kat daha yavaş
try
{
    var order = _repository.GetOrder(id);
    return order;
}
catch (NotFoundException)
{
    return null;
}

// İYİ: Pattern tabanlı API kullanın
public Order? TryGetOrder(int id)
{
    return _repository.FindOrder(id); // Bulamazsa null döner
}

Loglama Allocation'ları

Log çağrılarındaki string interpolation, log seviyesi devre dışı olsa bile çalışır:

csharp
// KÖTÜ: Debug devre dışı olsa bile string'i allocate eder
_logger.LogDebug($"Sipariş {order.Id} işleniyor, {order.Items.Count} kalem");

// İYİ: Ertelenmiş değerlendirme ile yapılandırılmış loglama
_logger.LogDebug("Sipariş {OrderId} işleniyor, {ItemCount} kalem",
    order.Id, order.Items.Count);

Large Object Heap Parçalanması

85.000 byte'ı aşan her allocation Large Object Heap'e (LOH) gider. LOH yalnızca Gen2 collection'larında toplanır ve parçalanabilir:

csharp
// KÖTÜ: Her seferinde LOH'a 100KB+ dizi allocate eder
var largeBuffer = new byte[100_000];

// İYİ: Havuzdan kirala, LOH allocation'larını yeniden kullanır
var largeBuffer = ArrayPool<byte>.Shared.Rent(100_000);
try
{
    // buffer'ı kullan...
}
finally
{
    ArrayPool<byte>.Shared.Return(largeBuffer);
}

Dictionary Kapasitesi

Eleman sayısı bilindiğinde koleksiyonları ön boyutlandırmamak, tekrarlanan yeniden boyutlandırma ve yeniden hash'lemeye neden olur:

csharp
// KÖTÜ: Varsayılan kapasiteyle başlar, birçok kez yeniden boyutlandırılır
var lookup = new Dictionary<int, Order>();
foreach (var order in orders) // 10.000 sipariş
    lookup[order.Id] = order;

// İYİ: Bilinen kapasiteyle önceden allocate et
var lookup = new Dictionary<int, Order>(orders.Count);
foreach (var order in orders)
    lookup[order.Id] = order;

Veritabanı Erişim Optimizasyonu

N+1 Sorgu Problemini Ortadan Kaldırma

N+1 problemi en yaygın veritabanı performans sorunudur. Tek bir sorguyu yüzlerce sorguya dönüştürür:

csharp
// KÖTÜ: Siparişler için 1 sorgu + kalemler için N sorgu
var orders = await _context.Orders.ToListAsync();
foreach (var order in orders)
{
    // Her iterasyon bir lazy-load sorgusu tetikler
    var items = order.Items.ToList();
}

// İYİ: Eager loading ile tek sorgu
var orders = await _context.Orders
    .Include(o => o.Items)
    .AsSplitQuery()
    .ToListAsync();

Salt Okunur Senaryolar İçin Projeksiyon

Sadece birkaç alan gerektiğinde tam entity yüklemeyin:

csharp
// KÖTÜ: Tüm entity graph'ını belleğe yükler ve değişiklikleri izler
var orders = await _context.Orders.Include(o => o.Customer).ToListAsync();

// İYİ: Sadece ihtiyacınız olanı projelendirin, change tracking yükü yok
var orderSummaries = await _context.Orders
    .AsNoTracking()
    .Select(o => new OrderSummaryDto
    {
        Id = o.Id,
        CustomerName = o.Customer.Name,
        Total = o.Items.Sum(i => i.Price * i.Quantity)
    })
    .ToListAsync();

Sonuç

Sürdürülebilir .NET performansı disiplinli ölçüm, hedefli değişiklikler ve sürekli regresyon izlemesinden gelir. Kalıp her zaman aynıdır: ölç, gerçek darboğazı tespit et, düzelt, düzeltmeyi doğrula ve devam et. Spekülatif optimizasyon dürtüsüne direnin -- her optimizasyon karmaşıklık ekler ve faydası kanıtlanmamış karmaşıklık sadece teknik borçtur.

Üretimde uyguladığım en etkili optimizasyonlar sıcak yollarda Span<T> ve ArrayPool ile allocation azaltma, sync-over-async thread tükenmesini düzeltme ve N+1 sorgularını ortadan kaldırma oldu. Bu üç kategori, gerçek dünyadaki .NET performans sorunlarının büyük çoğunluğunu oluşturur.

Performans audit'i ve optimizasyon planı için iletişime geçebilirsiniz.

İlgili Makaleler

Flutter Projeniz mi Var?

iOS, Android ve web için yüksek performanslı Flutter uygulamaları geliştiriyorum.

İletişime Geç