Async ve Multi-Thread Debugging: Parallel Stacks, Tasks Penceresi, Deadlock ve Race Condition
Async bir hang'de bekleyen kod hiçbir thread'in stack'inde görünmez; Parallel Stacks'in Tasks görünümüne ya da dump'taki async zincire bakmak gerekir. Lock sırası deadlock'unu ve .Result kaynaklı sync-over-async deadlock'unu dotnet-stack çıktısıyla okuyor, thread pool starvation'ı ölçüyor, Dictionary, sayaç ve check-then-act race condition'larını gerçek koşularla gösterip freeze/thaw ile race'i zorlamayı anlatıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ve dotnet-stack 10.0 ile derlenip denenmiştir. Visual Studio 2026'nın Parallel Stacks, Tasks ve Threads pencereleri bu makinede (macOS) çalıştırılmadı; anlatım Microsoft Learn dokümanından. Thread stack'leri, aynı process'lerden dotnet-stack ile alındı.
Önce Tahmin Et
Bookstore'un checkout'u test ortamında arada bir donuyor: istek gidiyor, yanıt hiç gelmiyor, CPU (Central Processing Unit) kullanımı sıfıra yakın. Debugger'ı bağlayıp Break All diyorsun. Call Stack penceresinde ana thread Main'de bekliyor ve CheckoutAsync, WaitForPaymentAsync gibi method'ların hiçbiri hiçbir thread'in stack'inde yok. Sence kod nerede takıldı? Ve bu durumda hangi pencereye bakmalısın?
Bu yazı iki tür bug üzerine: bir şeyin hiç olmadığı bug'lar (deadlock, hang) ve bir şeyin iki kez olduğu bug'lar (race condition). Async'in kendisi C# Asenkron serisinde; burada odak, bu bug'ları görmek ve kanıtlamak.
Async Call Stack'leri Okumak: Thread Yoksa Stack de Yok
Yukarıdaki durumu küçük bir programla gerçekten ürettik. Ödeme callback'i hiç gelmeyen bir checkout:
public static class AsyncHang
{
private static readonly TaskCompletionSource<string> PaymentCallback = new();
public static async Task RunAsync() => Console.WriteLine(await CheckoutAsync(1042));
private static async Task<string> CheckoutAsync(int orderId)
{
string paymentId = await WaitForPaymentAsync(orderId);
return $"order {orderId} paid with {paymentId}";
}
private static async Task<string> WaitForPaymentAsync(int orderId)
{
await Task.Yield();
return await PaymentCallback.Task;
}
}
Process asılı kalırken dotnet-stack report -p <pid> ile bütün thread'lerin anlık stack'ini aldık. Ana thread'in stack'i (thread pool worker'larının boş bekleyen stack'leri kısaltıldı):
Thread (0xCE3379):
[Native Frames]
System.Private.CoreLib!System.Threading.Monitor.Wait(class System.Object,int32)
System.Private.CoreLib!System.Threading.ManualResetEventSlim.Wait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Threading.Tasks.Task.SpinThenBlockingWait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Threading.Tasks.Task.InternalWaitCore(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Threading.Tasks.Task.InternalWait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification(class System.Threading.Tasks.Task,value class System.Threading.Tasks.ConfigureAwaitOptions)
Bookstore.Concurrency!Program.<Main>(class System.String[])
CheckoutAsync ve WaitForPaymentAsync hiçbir thread'de yok. Bu bir eksiklik değil, async'in doğası: await edilen ve henüz tamamlanmamış bir method thread'i bırakır. Geriye heap'te duran bir state machine nesnesi ve "tamamlandığında beni devam ettir" diyen bir continuation kalır. Thread odaklı her araç (Call Stack penceresi, Parallel Stacks'in Threads görünümü, dotnet-stack) bu yüzden async bir hang'de "kimse bir şey yapmıyor" der ve haklıdır: kimse bir şey yapmıyor, herkes bir şeyi bekliyor.
Soru şuna dönüşüyor: hangi task, hangi task'ı bekliyor? Bunun cevabını thread'lerde değil, task zincirinde ararsın. Visual Studio'da bu iş Parallel Stacks'in Tasks görünümünün; IDE (Integrated Development Environment) olmadan ise dump'ta dumpasync komutunun. Dump tarafı serinin dokuzuncu yazısında.
Parallel Stacks ve Tasks Penceresi
Parallel Stacks (Debug > Windows > Parallel Stacks) yalnız debugger durmuşken açılır ve üç görünümü var. Threads görünümü bütün thread'lerin call stack'lerini tek bir grafikte gösterir; aynı stack'i paylaşan thread'leri tek düğümde toplar ve düğüm başlığına "4 Threads" gibi bir sayı yazar. Yüz thread'lik bir uygulamada bu gruplama hayat kurtarır: 96 thread aynı yerde boşta bekliyorsa tek kutudur, farklı yerde duran dört thread hemen göze çarpar. Tasks görünümü aynı grafiği async task'lar için çizer; dokümana göre async/await kullanan .NET uygulamalarında en faydalı görünüm budur, çünkü yukarıdaki örnekte görünmeyen CheckoutAsync → WaitForPaymentAsync zincirini logical call stack olarak gösterir. Method View ise seçtiğin method'u merkeze alıp onu çağıran ve onun çağırdığı her şeyi gösterir.
Tasks penceresi (Debug > Windows > Tasks) aynı bilgiyi tablo olarak verir. Status sütunundaki değerler okuman gereken asıl şey:
- Scheduled: çalışmak üzere sıraya girmiş, henüz thread'i ve stack'i yok.
- Active: debugger durmadan önce kod çalıştırıyordu.
- Blocked: bir event'i, lock'u ya da başka bir task'ı thread'i bloklayarak bekliyor.
- Awaiting: async/await ile, thread bloklamadan bekliyor.
- Deadlocked: thread'i başka bir thread'le kilitlenmiş durumda.
Deadlocked tespiti Windows'un WCT (Wait Chain Traversal) mekanizmasına dayanıyor; dokümanın uyarısına göre yalnız WCT'nin desteklediği senkronizasyon araçlarıyla bloklanan task'larda raporlanır. Yani "Deadlocked yazmıyor" demek "deadlock yok" demek değildir.
İki pratik ayrıntı. Debugger bir thread'de durduğunda F5 başka bir thread'de bir sonraki breakpoint'e düşebilir; aynı thread'i takip etmek için breakpoint'e ThreadId filter'ı ya da koşulu eklersin. Her şey asılıysa hiçbir breakpoint tetiklenmez; Break All (Ctrl+Alt+Break) ile bütün thread'leri o anda durdurursun. Visual Studio 2022 17.13'ten beri Copilot açıksa Threads görünümündeki düğümlerin yanında AI (Artificial Intelligence) tarafından üretilmiş kısa thread açıklamaları ve Summarize düğmesi var. Bu özet "thread 12 sipariş kilidini bekliyor" gibi bir okumayı hızlandırır, ama okuduğunu yukarıdaki stack'lerle doğrula.
Vaka 1: Lock Sırası Deadlock'u
Stok yenileme ve checkout iki ayrı thread'de çalışıyor ve ikisi de iki lock alıyor, ama ters sırayla:
public static class RestockService
{
public static void Restock()
{
lock (Inventory.StockLock)
{
LockOrder.WaitForOther();
// stok kilidini tutarken sipariş kilidini istiyor
lock (Orders.OrderLock)
{
Console.WriteLine("restocked");
}
}
}
}
public static class CheckoutService
{
public static void Checkout()
{
lock (Orders.OrderLock)
{
LockOrder.WaitForOther();
// sipariş kilidini tutarken stok kilidini istiyor: ters sıra
lock (Inventory.StockLock)
{
Console.WriteLine("checked out");
}
}
}
}
WaitForOther, bir Barrier ile iki thread'i ilk lock'u aldıktan sonra buluşturuyor; production'da bu "buluşma" şans eseri, yükün en yoğun olduğu anda olur. Burada her çalıştırmada olur. Process'in dotnet-stack çıktısından iki worker thread:
Thread (0xCE31F4):
[Native Frames]
System.Private.CoreLib!System.Threading.Monitor.Enter_Slowpath(class System.Object)
System.Private.CoreLib!System.Threading.Monitor.Enter(class System.Object,bool&)
Bookstore.Concurrency!Bookstore.Concurrency.RestockService.Restock()
System.Private.CoreLib!System.Threading.Thread.StartCallback()
Thread (0xCE31F5):
[Native Frames]
System.Private.CoreLib!System.Threading.Monitor.Enter_Slowpath(class System.Object)
System.Private.CoreLib!System.Threading.Monitor.Enter(class System.Object,bool&)
Bookstore.Concurrency!Bookstore.Concurrency.CheckoutService.Checkout()
System.Private.CoreLib!System.Threading.Thread.StartCallback()

Okuma şöyle: iki thread de Monitor.Enter'da, yani bir lock satırında bekliyor ve Enter_Slowpath lock'un başkası tarafından tutulduğunu söylüyor. Biri Restock'tan, diğeri Checkout'tan geliyor. Stack, hangi lock'u beklediklerini ve hangisini tuttuklarını söylemiyor; bunu koddan çıkarıyoruz (görseldeki lock adları bu çıkarım). Dump'ta syncblk komutu lock'un sahibini doğrudan gösterir; o da dokuzuncu yazıda. Visual Studio'da aynı durumda Break All'dan sonra Threads görünümünde iki thread ayrı düğümler olarak görünür ve dokümana göre .NET için deadlock'u işaretleyen bir durum ikonu çıkar.
Düzeltme: birden fazla lock alan bütün kod yolları lock'ları aynı global sırayla alır (örneğin her zaman önce StockLock, sonra OrderLock). Daha iyisi, iki lock'a birden ihtiyaç duymayan bir tasarım: stok ve sipariş güncellemesini tek bir lock'un ya da veritabanı transaction'ının arkasına almak. Lock'u Monitor.TryEnter(lockObject, TimeSpan.FromSeconds(5)) ile almak deadlock'u çözmez, ama sonsuz donma yerine log'a düşen bir timeout'a çevirir; teşhis için bu bile büyük kazanç.
Vaka 2: Sync-over-Async ve SynchronizationContext
İkinci klasik: async bir method'u .Result ile beklemek. Teorisi C# Asenkron serisinin "C# Deadlock, SynchronizationContext ve ConfigureAwait" yazısında. Console ve ASP.NET Core uygulamalarında varsayılan bir SynchronizationContext yok, bu yüzden deadlock'u görmek için UI (User Interface) thread'ini taklit eden bir context kurduk:
public sealed class BlockingQueueContext : SynchronizationContext
{
// UI thread'ini taklit eder: devamlar yalnız bu thread boşalınca çalışır
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object?)> _queue = [];
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
}
public static class SyncOverAsync
{
public static void Run()
{
SynchronizationContext.SetSynchronizationContext(new BlockingQueueContext());
decimal total = PriceService.GetTotalAsync(1042).Result;
Console.WriteLine(total);
}
}
Thread (0xCE32BC):
[Native Frames]
System.Private.CoreLib!System.Threading.Monitor.Wait(class System.Object,int32)
System.Private.CoreLib!System.Threading.ManualResetEventSlim.Wait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Threading.Tasks.Task.SpinThenBlockingWait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Threading.Tasks.Task.InternalWaitCore(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Threading.Tasks.Task.InternalWait(int32,value class System.Threading.CancellationToken)
System.Private.CoreLib!System.Threading.Tasks.Task`1[System.Decimal].GetResultCore(bool)
System.Private.CoreLib!System.Threading.Tasks.Task`1[System.Decimal].get_Result()
Bookstore.Concurrency!Bookstore.Concurrency.SyncOverAsync.Run()
Bu stack'i lock deadlock'undan ayıran işaret get_Result() ve InternalWait: thread bir lock'u değil, bir task'ın bitmesini bekliyor. Task ise Task.Delay'den sonraki devamını context'e post etmiş ve o devam, context'in tek thread'i (bu thread) boşalana kadar çalışamayacak. Process'te ikinci bir ilgili thread yok; kendi kuyruğunu bekleyen tek bir thread var. Diğer thread'ler thread pool worker'ları ve timer thread'i, hepsi boşta.
Düzeltme, zincirin sonuna kadar await: Run'ı async Task yapıp await PriceService.GetTotalAsync(1042) demek. Kütüphane kodunda ConfigureAwait(false) bu deadlock'u kütüphane tarafında engeller ama çağıran tarafın .Result'ını meşrulaştırmaz.
Thread Pool Starvation'ın Belirtileri
ASP.NET Core'da context olmadığı için .Result kilitlenmez, ama başka bir şey olur: her bloklayan çağrı bir thread pool thread'ini rehin tutar ve bloklanan task'ın devamı da bir thread pool thread'ine ihtiyaç duyar. Aynı anda N tane Task.Run(() => GetTotalAsync(i).Result) başlatıp ardından boş bir Task.Run'ın ne kadar sonra çalışmaya başladığını ölçtük (10 çekirdekli makine):
processors: 10, min worker threads: 10
0 blocking calls queued, a trivial Task.Run started after 1 ms, threads: 2
8 blocking calls queued, a trivial Task.Run started after 0 ms, threads: 10
50 blocking calls queued, a trivial Task.Run started after 217 ms, threads: 28
200 blocking calls queued, a trivial Task.Run started after 1478 ms, threads: 49
Bloklayan çağrı sayısı minimum thread sayısını (10) geçince hiçbir şey hata vermiyor; yalnız her şey yavaşlıyor. Thread pool yeni thread'leri yavaş yavaş ekliyor, 200 bloklayan çağrıda boş bir iş 1,5 saniye kuyrukta bekliyor. Debugger'da bu, Parallel Stacks'te aynı get_Result() stack'inde bekleyen onlarca thread'lik tek bir düğüm olarak görünür. Teşhisin production tarafı, yani dotnet-counters'ta thread pool queue length ve dotnet-trace, serinin onuncu yazısında.
Race Condition: Dictionary, Sayaç ve Check-Then-Act
Deadlock'ta bir şey hiç olmuyordu; race condition'da bir şey iki kez oluyor ya da hiç olmamış gibi kayboluyor. Üç küçük deney, üçü de gerçek koşu:
Dictionary<int, int> soldByBook = [];
Parallel.For(0, 100_000, i =>
{
int bookId = i % 1_000;
soldByBook.TryGetValue(bookId, out int sold);
soldByBook[bookId] = sold + 1;
});
AggregateException: InvalidOperationException: Operations that change non-concurrent collections must have exclusive access. A concurrent update was performed on this collection and corrupted its state. The collection's state is no longer correct.
Bu şanslı bir sonuç: .NET'in Dictionary'si iç yapısının bozulduğunu fark etti ve exception fırlattı. Her zaman bu kadar şanslı olmazsın; bozulma fark edilmeden yanlış sayılarla devam edebilir. Exception mesajında "concurrent" kelimesini gördüğün an, kodun bu collection'a birden fazla thread'den yazdığını bil.
Parallel.For(0, 1_000_000, _ =>
{
sold++;
Interlocked.Increment(ref safeSold);
});
sold++: 216,512, Interlocked: 1,000,000
Bir milyon satışın 783.488'i kayboldu ve hiçbir hata yok. sold++ oku, artır, yaz adımlarından oluşur; iki thread aynı değeri okuyunca biri diğerinin yazdığını ezer.
Üçüncüsü en sinsi olanı, check-then-act. Stokta bir kitap var, iki müşteri aynı anda rezerve ediyor:
public sealed class StockRoom(int stock)
{
// iki thread'i aynı anda kontrol satırında tutan test kancası (debugger'daki freeze gibi)
private readonly Barrier _bothChecked = new(2);
public int Stock { get; private set; } = stock;
public bool TryReserve(string customer)
{
if (Stock > 0)
{
_bothChecked.SignalAndWait();
Stock--;
return true;
}
return false;
}
}
Ada: True, Alan: True, stock left: 0
Ada: True, Alan: True, stock left: 0
Ada: True, Alan: True, stock left: 0
Ada: True, Alan: True, stock left: -1
Ada: True, Alan: True, stock left: -1
Ada: True, Alan: True, stock left: 0
Altı koşunun altısında da tek kitap iki müşteriye satıldı. Daha kötüsü, dördünde stok 0 görünüyor: Stock--'taki ikinci race, fazla satışın izini de sildi. Raporda her şey tutarlı görünürken depoda bir kitap eksik.
Barrier burada debugger'daki freeze/thaw'ın kod karşılığı. Visual Studio'da aynı etkiyi şöyle elde edersin: if (Stock > 0)'ın içine breakpoint koy; ilk thread durunca Threads penceresinde onu Freeze et, F5 ile devam et; ikinci thread de aynı breakpoint'e gelir. İkisi de kontrolü geçmiştir; şimdi ilkini Thaw edip ikisini sırayla ilerletirsin ve race'i her seferinde, istediğin sırayla üretirsin. Tasks penceresinde de bir task'ın thread'ini Freeze Assigned Thread ile durdurabilirsin. Race'i bir kez zorla ürettikten sonra sıra kırmızı teste gelir: aynı Barrier kancasıyla bir xUnit testi yazar, düzeltmeyi (lock içinde kontrol ve azaltma, ya da veritabanında UPDATE ... WHERE Stock > 0 ve etkilenen satır sayısı) o testle doğrularsın.
Üretimde Dikkat
Production'da donan bir process'e debugger bağlayıp Break All demek, zaten donmuş bir sistemi tamamen durdurmak demek; health check'ler düşer ve orchestrator process'i öldürür, kanıt da onunla gider. Önce kanıtı topla: dotnet-stack ile anlık stack (saniyeler sürer, process'i durdurmaz), gerekirse dump. Yeniden başlatmak çoğu zaman doğru karar, ama önce değil sonra.
Timing bug'ları debugger altında kaybolur. Breakpoint'te durmak thread'lerin göreli zamanlamasını tamamen değiştirir; race penceresi milisaniyelerden saatlere açılır ya da tamamen kapanır. "Debugger'la denedim, olmuyor" bir kanıt değil. Bu tür bug'larda debugger yerine tracepoint, log (thread ID'siyle) ve dump kullan.
Deadlock'a karşı lock'larda timeout kullanmak bir güvenlik ağıdır. SemaphoreSlim.WaitAsync(timeout) ya da Monitor.TryEnter false döndüğünde mutlaka log yaz: hangi lock, hangi işlem, ne kadar beklendi. Sonsuz donma yerine teşhis edilebilir bir hata elde edersin.
Race condition'lar yük altında çıkar. Yerelde tek kullanıcıyla bin kez çalışan kod, production'da saniyede yüz istekle ilk dakikada patlayabilir. Paylaşılan mutable state'i (static Dictionary, singleton service'teki field) code review'da özellikle ara; ASP.NET Core'da singleton bir service'in field'ı, bütün request'lerin aynı anda yazdığı bir değişkendir.
Sık Düşülen Tuzaklar
Async bir hang'de thread'lerin stack'ine bakıp "hiçbir şey çalışmıyor, sorun dış sistemde" demek. Async kodda bekleyen iş thread'de görünmez; Tasks görünümüne ya da dump'taki async zincire bakmadan karar verme.
Deadlock'u Thread.Sleep ya da Task.Delay ekleyerek "çözmek". Zamanlamayı değiştirip deadlock olasılığını düşürür, döngüyü kaldırmaz; yük artınca geri gelir.
Race condition'ı Dictionary'yi ConcurrentDictionary ile değiştirip kapandı saymak. TryGetValue ve ardından gelen yazma iki ayrı işlem olarak kaldıkça check-then-act race'i sürer; AddOrUpdate gibi atomik bir method kullanmalısın.
Debugger'da durmuşken bir thread'i Freeze edip unutmak. Freeze edilmiş thread devam etmez; oturumun geri kalanında gördüğün her şey bu yapay durumun sonucudur.
AI thread özetine kanıt gibi güvenmek. "Bu iki thread deadlock'ta" demesi, stack'leri ve kodu okuyup lock sırasını doğrulamanın yerini tutmaz.
Küçük Bir Deneme
★☆☆ CheckoutService.Checkout'taki lock sırasını RestockService ile aynı yap ve programı on kez çalıştır. Hiç donuyor mu? Sonra LockOrder.WaitForOther() çağrılarını kaldırıp eski, ters sıralı koda dön ve yüz kez çalıştır: kaç kez donuyor?
★★☆ StockRoom.TryReserve'ü lock ile düzelt ve aynı Barrier kancasıyla bir xUnit testi yaz: test düzeltmeden önce kırmızı, sonra yeşil olmalı. Kancayı production kodundan nasıl çıkarırsın?
★★★ Starvation deneyini ThreadPool.SetMinThreads(200, 200) ile tekrarla. Gecikme kayboluyor mu? Bu bir düzeltme mi, yoksa belirtiyi gizlemek mi; bedeli ne?
Debugger'da durmak bu yazıdaki bug'ların yarısını sakladı; diğer yarısını ancak durmadan alınan stack'lerle görebildik. Bir sonraki yazıda aynı yöntemi frontend'e uyguluyoruz: Angular uygulamasında signal, change detection, RxJS ve HTTP (Hypertext Transfer Protocol) katmanında bug avı.