YazılarYayımlandı

.NET’te Threading ve Asynchrony Gerçekleri: “ThreadPool Derinlikleri ve Starvation Tuzakları”

Gece saat 02:47. Bir anda slack grubuna mesajlar gelmeye başlar. API timeout veriyor. Hemen dashboard’a bakarsınız ve CPU %4, RAM %20, Network normal ve Database ayaktadır. Ama sistem cevap vermez…

Edep; insanın dili sustuğunda bile karakterinin konuşmasıdır.

Görsel Daniil Komov’a aittir

Gece saat 02:47. Bir anda slack grubuna mesajlar gelmeye başlar. API timeout veriyor. Hemen dashboard’a bakarsınız ve CPU %4, RAM %20, Network normal ve Database ayaktadır. Ama sistem cevap vermez. Buna benzer deneyimleri ya yaşadınız ya da muhakkak yaşayacaksınız kaçışı yok. Ama yaşayanlar buna sebep olanın ThreadPool Starvation olabileceğini biliyorlar. Bir önceki makalede async/await mekanizmasının gerçekte nasıl çalıştığını incelemeye çalıştık. Bu makaleye geçmeden önce orada yaptığımız tanımlar aslında alt yapıyı oluşturmak için önemliydi. Ama production ortamda sorunlar bu mekanizmaları bilmemekten değil de onları yanlış şekilde kullanmak çökertir. O zaman ilk anatomi analizi ile başlayalım.

ThreadPool Anatomisi

.NET ThreadPool iki ayrı ama iç içe çalışan thread havuzudur. Bunlar Worker Threads ve IOCP Threads olarak gruplanırlar.

Worker Threads: Task.Run, Task.Factory.StartNew, ThreadPool.QueueUserWorkItem ile kuyruktaki görevleri yerine getirir. Environment.ProcessorCount default min değerini verirken resmi dokümanında maksimumum genel de 32767 olduğu yazmaktadır. Her worker için local kuyruk + global kuyruk vardır.

IOCP Threads: İşletim sisteminden gelen I/O completion notificationlarını alır ve işi devam ettirir. Default min değeri Environment.ProcessorCount ile alınırken tek görevi al ve devam ettir olduğu için max değerine nadiren ulaşılır. Max değeri öğrenmek için de ThreadPool.GetMaxThreads kullanılır.

Küçük bir hatırlatma; IOCP Windows kökenlidir. .NET runtime çalıştığı platforma göre uygun yapıyı kullanır. İlk makale de buna değinmiştik. Hatırlayalım! Linux için epoll, macOS için kqueue

// Mevcut limitleri görmek için:
ThreadPool.GetMinThreads(out int minWorker, out int minIocp);
ThreadPool.GetMaxThreads(out int maxWorker, out int maxIocp);
ThreadPool.GetAvailableThreads(out int availWorker, out int availIocp);

Buraya kadar her şey gayet güzel. ThreadPool default değerlerle başlıyor, ihtiyaç duydukça pool’dan yeni thread alıyor. Peki bu ihtiyaç anında ne kadar hızlı tepki veriyor. Bu nasıl kontrol ediliyor? İşte burada devreye bir algoritma giriyor. ThreadPool anatomisinin bence en kritik yeri de bu algoritmadır. Production ortamında yaşanan sorunların birçoğu bu algoritmanın çalışma prensibini bilmemekten kaynaklanır. Şimdi bu algoritmaya bir göz atalım.

Hill Climbing Algoritması

Görsel Vedat Kandemir’e aittir. Hill Climbing algoritması: pool, ihtiyaç olduğunda bu yolu adım adım, yavaşça tırmanır. Aceleyi sevmez

ThreadPool min değerin üzerinde bir thread oluşturduğunda Varyemez Amca karakterine bürünür. Burada ki varyemezimiz Hill Climbing’tir. Sistem şöyle çalışır;

  1. Pool’da boşta thread yok ve kuyrukta iş bekliyor.
  2. ThreadPool yeni bir thread oluşturmaya karar verir.
  3. Saniyede yaklaşık 1–2 thread’den fazla oluşturmaz
  4. Her yeni thread’den sonra throughput’a bakar.
  5. Bu bir Gradient Ascent algoritmasıdır. Matematiğini merak edenler buraya örnek uygulamayı kontrol etmek isteyenler ise buraya bakabilirler

Burada anlatılmak istenen aslında şudur; Eğer 200 threadiniz aniden bloklanırsa (.Result örneğinde göreceğiz) pool’un toparlanması 200 sn’ye yi bulabilir. Bu süre boyunda gelen her istek kuyruktar birikir ve timeout hatası alınır. Api’ye istek atanlar sürekli deneme yapamaya devam eder ve kar topu etkisiyle sistem felakate süreklenmeye başlar.

Önemli: .NET 6+ ile bu davranış biraz iyileştirildi (özellikle minimum thread sayısının altındayken daha agresif scale up yapıyor), ama temel felsefe değişmedi: ThreadPool yavaş büyür!

Thread Starvation: Anatomik İnceleme

Klasik .Result Felaketi

[HttpGet("orders/{id}")]
public IActionResult GetOrder(int id)
{
    var order = _orderRepo.GetByIdAsync(id).Result;
    return Ok(order);
}

Yukarıda ki kodu 8 core bir makine de çalıştırırsak ne olur? Adım adım değerlendirelim;

  1. Request 1 gelir. Worker thread #1 alır, GetByIdAsync çağrılır.
  2. GetByIdAsync içeride DB’ye async sorgu açar. Bu çağrı continuation’ı thread havuzundan başka bir thread’de çalıştırmak ister.
  3. Ama thread #1 hâlâ .Result ile bekliyor! Yani worker thread bloklanmış durumda.
  4. DB cevabı gelir, continuation kuyruğa girer. Çalıştırılması için bir worker thread lazım.
  5. Sırada başka bir request var. Worker thread #2 onu da alır, aynı şekilde bloklar.
  6. Bu döngü minimum thread sayısı kadar (default 8) tekrarlanır.
  7. Pool tükenir. Continuation’lar kuyrukta beklemeye başlar.
  8. Hill Climbing devreye girer: saniyede 1 yeni thread oluşturur. Ama yeni gelen istekler de hızla bloklanıyor.
  9. Deadlock benzeri bir durum: Continuation’lar bekleyen thread’leri serbest bırakacak, ama continuation’ları çalıştıracak free thread yok.

Bu akışın gerçekte nasıl olduğunu görmek sitersekte örneğimiz;

// küçük ölçekli bir pod'u simule ediyoruz
ThreadPool.SetMinThreads(4, 4);

// 200 paralel "request" her biri .Result kullanıyor
var tasks = Enumerable.Range(0, 200).Select(i => Task.Run(() =>
{
    return FakeAsyncWork(i).Result; 
})).ToArray();

static async Task FakeAsyncWork(int id)
{
    await Task.Delay(200);  // I/O simülasyonu
    return $"Result-{id}";
}

Kaynak koda buradan erişebilirsiniz. Kendi geliştirme ortamımda elde ettiğim çıktı;

Çıktı:
```
Toplam süre:           3269 ms
Beklenen süre (async): ~200 ms (hepsi paralel)
Gerçek/beklenen oranı: 16.3x
```
**Test ortamı:** macOS, Apple Silicon 10 core, .NET 8. Daha eski .NET 
sürümlerinde veya daha küçük container'larda bu oran çok daha yüksek 
çıkar.

CPU %5’te Çöken API Bilmecesi

Çünkü CPU iş yapmıyor. Thread’ler bloklanmış durumda Waiting state’inde. CPU çoğu zaman idle. Ama yeni gelen iş için thread yok. Sistem yavaş değil, kısır.

Burada .Result örneği ile starvation anatomisini ve ne kadar yıkıcı olabileceğiniz göstermeye çalıştım. Peki bu yeterli mi? Hayır! Bu sadece bir anti-pattern di. Bunu iki farklı şekilde ele alabiliriz.

Async API’yi sync gibi çağırmak

public string GetData()
{
    return _service.GetDataAsync().Result; // sorun
    return _service.GetDataAsync().Wait(); // sorun
    return _service.GetDataAsync().GetAwaiter().GetResult(); // hala sorun
}

Burada ki örneklerin hepsi aynı; thread bloklar, continuation için thread bekler.

Sync API’yi async gibi kullanmak

public async Task GetReport()
{
    var report = await Task.Run(() => _legacyService.GenerateReport());
    return Ok(report);
}

Bu kod ASP.NET Core’da hiçbir şey kazandırmaz. Request thread’i serbest kaldı diye seviniriz ama Task.Run o iş yükünü başka bir worker thread’e taşıdı. Pool’daki toplam bloklanan thread sayısı: aynı. Üstüne allocation ve scheduling maliyeti var. Buna halı altına süpürme ya da en sevdiğim teknik borç (ne öderiz ne de inkar ederiz) denir.

UI vs Server: WPF/WinForms’da Task.Run(SyncMethod) mantıklıdır çünkü UI thread tek ve özel; serbest bırakmanın değeri var. ASP.NET Core’da ise her thread aynı havuzdan gelir; Task.Run sadece bir worker’ı diğeriyle değiştirir. Maliyet net, fayda sıfır.

Peki bu anti-pattern’lerin davranışı her ortamda aynı mı? Hayır. ASP.NET Core’da .Result starvation üretir; WPF’te ve legacy ASP.NET’te ise tam deadlock’a kilitler. Bu farkın altında tek bir kavram yatar: SynchronizationContext.

Synchronization Context

SynchronizationContext, continuation nerede çalışsın? sorusunun cevabını veren nesnedir. Üç farklı durum:

  1. Legacy ASP.NET (.NET Framework): AspNetSynchronizationContext vardı; .Result kullanımı klasik deadlock üretirdi (continuation request context’e dönmek ister, context bloklu).
  2. ASP.NET Core: SynchronizationContext.Current her zaman null. Continuation’lar direkt ThreadPool’da çalışır. Deadlock gitti ama starvation kaldı.
  3. WPF / WinForms: UI thread’i DispatcherSyncContext veya WindowsFormsSyncContext ile temsil edilir. .Result burada da deadlock üretir (demo: threadpool-observations). ConfigureAwait(false) ne zaman anlamlı? Framework yazıyorsanız. Çünkü kodunuz hangi context’te çalışacağını bilmiyor. ASP.NET Core uygulamasında ise zaten yakalanacak context yok; eklemek zarar vermez ama gereklilik değil.

Peki hep sorunlardan ve sorunların nedenlerinden bahsederken bunların bir çözümü ya da önlemenin yolu yok mu? Elbette var. 3 temel kural ile starvation çoğundan kutulabiliriz.

Starvation’dan kurtulmanın 3 yolu

  1. Container start’ında min thread’i artırın. Hill Climbing’in sürünmesinden korur.
ThreadPool.SetMinThreads(workerThreads: Environment.ProcessorCount * 4, 
                        completionPortThreads: Environment.ProcessorCount * 2);

2. .Result / .Wait() / .GetAwaiter().GetResult() kullanımını uygulamının derlenmesi esnasında derleme hatası olarak ayarlayın. Microsoft.VisualStudio.Threading.Analyzers paketi ile VSTHRD002 kuralı bunu yapar. Bir kez kurun, bir daha bu hataya düşmezsiniz.

  1. Async başladıysanız async devam edin. Controllerden repository’ye oradan da DB’ye giden yolda her şey async ike bir tanesini sync yapmanız zincirin kırılmasına neden olur.

async all the way down” bir pazarlama sloganı olmamalı. Uygulamada da bu yöntem geçerli olmalı ki bahsi geçen hatalarla karşılaşmayalım. Bu bölümü özetlemek gerekirse;

  1. .Result / .Wait() yanlış kullanımı ASP.NET Core’da ThreadPool starvation sebep olabilecekken çözümün Async all the way mottosunun uygulanmasıdır
  2. Task.Run(() => SyncMethod()) yaklaşımı server’da sorunu “Halı altına süpürme” süpermektir. Bunun yerine “Native async API” kullanımını alışkanlık haline getirmek gerekir
  3. ASP.NET Core’da ConfigureAwait(false) panik halinde her yere eklenmesi gereksiz olsa da zararsızdır. Kütüphanelerde tutmaya devam etmek gerekirken, uygulama kodunda kullanımı isteğe bağlı bırakılabilir.
  4. Default min thread sayısı ilk başlangıçta starvation oluşturabilir. Bunun yerine container starttında ThreadPool.SetMinThreads(N*4, N*2) belirtmek nefes aldırır

Buraya kadar küçük örneklerle sorunları görüp çözümler üzerinden bir yol haritası belirledik. Serinin son kısmında ise BenchmarkDotNet ile üç farklı endpoint senaryosu, k6 load testleri, dotnet-counters ve dotnet-trace çıktıları, ValueTask allocation kazançları ve IAsyncEnumerable ile streaming konularını cloud instance’lar üzerinden ele alacağız. Serinin son başlığı olan Benchmark, Gözlemlenebilirlik ve Production Pattern’leri kısmında görüşmek üzere. Zaman ayırıp okuduğunuz için teşekkür ederim