C# Deadlock, SynchronizationContext ve ConfigureAwait
Kendi tek thread'li SynchronizationContext'imizle .Result deadlock'unu gerçekten üretip bir bekçiyle durduruyoruz. await'in context'i nasıl yakalayıp Post ettiği, konsolda ve ASP.NET Core'da neden kilitlenmediği ama thread pool starvation riski, async all the way, kütüphane kodunda ConfigureAwait(false), tamamlanmış task'ta thread'in değişmemesi ve Task.Run ile sarmanın bedeli.
Sürüm notu: Bu yazıdaki kodlar C# 10 ve .NET 6 API'leriyle yazılmıştır; .NET 10 SDK ile derlenip denenmiştir.
Önce Tahmin Et
Aynı async method'u iki yerden .Result ile çağırıyoruz. Önce düz bir konsol uygulamasının içinden, sonra UiThread.Run ile kurduğumuz tek bir "UI thread"inin içinden. Bu thread, WinForms ya da WPF'teki UI thread'i gibi davranıyor; nasıl kurduğumuzu birazdan göreceğiz. Konsoldaki satır stok değerini yazdırıyor. UI thread'indeki aynı satır ne yapar?
Console.WriteLine($"Console context: {SynchronizationContext.Current?.GetType().Name ?? "null"}");
Console.WriteLine($"Console .Result: {GetStockAsync().Result}");
UiThread.Run("1) .Result on the UI thread", () =>
{
int stock = GetStockAsync().Result;
Console.WriteLine($" stock: {stock}");
});
static async Task<int> GetStockAsync()
{
await Task.Delay(200); // stok servisine gidiş-dönüş
return 4;
}
Deadlock
Çıktının ilk kısmı:
Console context: null
Console .Result: 4
1) .Result on the UI thread
[context] Post from a thread pool thread
DEADLOCK: the handler did not finish in 2 s
Konsolda 4 geldi. UI thread'inde ise handler hiç bitmedi. Demo'yu sonsuza kadar asılı bırakmamak için 2 saniyelik bir bekçi kurduk; o da süre dolunca "DEADLOCK" yazdı ve sonraki senaryoya geçti. Bekçi olmasaydı program orada sonsuza kadar dururdu. Gerçek bir masaüstü uygulamasında bu, donmuş bir pencere ve Windows'un "yanıt vermiyor" uyarısı demektir.
Arada bir satır daha var: [context] Post from a thread pool thread. Deadlock'un bütün hikâyesi o satırda. Onu anlamak için önce SynchronizationContext'i açmamız gerekiyor.
SynchronizationContext Nedir
Masaüstü uygulamalarında ekrandaki kontrollere sadece onları oluşturan thread dokunabilir. Bu thread bir döngü içinde queue'daki işleri sırayla çalıştırır: bir tıklama, bir yeniden çizim, bir timer. Başka bir thread'in ekranı güncellemesi gerekiyorsa işi doğrudan yapmaz, bu queue'ya bırakır. SynchronizationContext bu "işi doğru yere bırak" mekanizmasının abstraction'ıdır. En önemli method'u Post: verilen işi context'in thread'ine sıraya koyar.
Demo'daki context'imiz bunun en küçük hâli: bir queue ve onu dolaşan tek bir thread. Bu yazıdaki parçaların hepsi tek bir Program.cs'te duruyor; BlockingCollection için dosyanın başında using System.Collections.Concurrent; satırı var.
sealed class SingleThreadSynchronizationContext : SynchronizationContext
{
private readonly BlockingCollection<(SendOrPostCallback Callback, object? State)> _queue = new();
public override void Post(SendOrPostCallback d, object? state)
{
Console.WriteLine($" [context] Post from {(Thread.CurrentThread.IsThreadPoolThread ? "a thread pool thread" : Thread.CurrentThread.Name)}");
_queue.Add((d, state));
}
public void Run(Action firstWork)
{
SetSynchronizationContext(this);
firstWork();
foreach ((SendOrPostCallback callback, object? state) in _queue.GetConsumingEnumerable())
{
callback(state);
}
}
public void Complete() => _queue.CompleteAdding();
}
await bu mekanizmayı kendiliğinden kullanır. Tamamlanmamış bir task'ı beklerken o anki SynchronizationContext.Current'ı yakalar. Task bittiğinde method'un devamını (continuation) doğrudan çalıştırmaz; yakaladığı context'e Post eder. UI uygulamalarında await'ten sonraki satırın yine UI thread'inde çalışmasının, yani stockLabel.Text = ... yazabilmenin sebebi budur. Konsol uygulamasında Current null olduğu için continuation thread pool'da çalışır.
Demo'yu yöneten yardımcı class da şu. Handler'ı yeni bir "UI" thread'inde çalıştırıyor ve en fazla 2 saniye bekliyor:
static class UiThread
{
public static void Run(string title, Action handler) =>
Run(title, () => { handler(); return Task.CompletedTask; });
public static void Run(string title, Func<Task> handler)
{
Console.WriteLine(title);
SingleThreadSynchronizationContext context = new();
ManualResetEventSlim done = new();
Thread ui = new(() => context.Run(() => handler().ContinueWith(_ => done.Set(), TaskScheduler.Default)))
{
Name = "UI",
IsBackground = true, // kilitlenen thread process'in kapanmasını engellemesin
};
ui.Start();
if (done.Wait(TimeSpan.FromSeconds(2)))
{
context.Complete();
}
else
{
Console.WriteLine(" DEADLOCK: the handler did not finish in 2 s");
}
}
}
UI thread'ini background thread yaptık. Kilitlenen bir thread kendiliğinden asla çözülmez; background olduğu için en azından process'in kapanmasını engellemez. Program bütün senaryoları bitirip kendi kendine kapanıyor, arkasında asılı bir process kalmıyor.
Deadlock Adım Adım
Birinci senaryoda olanları sırayla yazalım:
UI thread'i GetStockAsync()'i çağırır. Method Task.Delay(200)'e gelir, task henüz bitmediği için await UI context'ini yakalar ve çağırana tamamlanmamış bir task döndürür. UI thread'i bu task üzerinde .Result'a gelir ve task bitene kadar bloklanır. 200 ms sonra timer bir thread pool thread'inde tetiklenir; continuation'ı, yani return 4; satırını UI context'ine Post eder. Çıktıdaki satır bu. Ama o queue'yu işleyecek tek thread UI thread'i, o da .Result'ta bekliyor. Task, continuation çalışmadan bitemez; continuation, UI thread'i serbest kalmadan çalışamaz; UI thread'i de task bitmeden serbest kalmaz. Döngü kapandı.

Bir çağrı merkezini aradığını düşün. Temsilci "kontrol edip sizi bu numaradan geri arayacağım" diyor. Sen de cevabı kaçırmamak için telefonu kapatmadan hatta bekliyorsun. Temsilci seni aradığında hat meşgul. Sen geri aranmayı bekliyorsun, geri arama da senin hattı boşaltmanı. İkiniz de haklı bir şey bekliyor ve hiçbir şey olmuyor.
.Wait() ve .GetAwaiter().GetResult() da aynı şekilde kilitlenir; denedik, ikisi de 2 saniyede "DEADLOCK" yazdı. Fark sadece exception'ı nasıl ilettiklerinde. Thread'i bloklayan her bekleme bu tuzağa düşer.
Konsolda ve ASP.NET Core'da Neden Kilitlenmiyor
Konsoldaki satırın çalışmasının sebebi çıktının ilk satırında: Console context: null. Yakalanacak bir context olmadığı için continuation thread pool'da çalıştı, bloklanmış main thread'e ihtiyacı yoktu. Bu yüzden .Result içeren kod konsolda denenip "çalışıyor" diye UI uygulamasına taşındığında patlar.
ASP.NET Core da SynchronizationContext kullanmaz. Küçük bir minimal API ile kontrol ettik:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/context", async () =>
{
string before = SynchronizationContext.Current?.GetType().Name ?? "null";
await Task.Delay(100);
string after = SynchronizationContext.Current?.GetType().Name ?? "null";
return $"before await: {before}, after await: {after}";
});
app.Run();
$ curl http://127.0.0.1:5291/context
before await: null, after await: null
İsteğin içinde de, await'ten sonra da context yok. .NET Framework üzerindeki klasik ASP.NET'in ise kendi context'i vardı ve orada bu deadlock'a aynen düşülürdü; eski bir projeyi okurken karşına çıkan .Result'lar bu yüzden tehlikelidir.
Context olmaması .Result'ı ASP.NET Core'da masum yapmaz. Her bloklayan bekleme bir thread pool thread'ini iş yapmadan tutar. Az trafikte fark edilmez. Yük arttığında pool'daki thread'ler bloklanmış isteklerle dolar, yeni gelen işler sıra bekler ve uygulama hiçbir hata vermeden yavaşlar. Bu duruma thread pool starvation denir; deadlock kadar gürültülü değildir ama teşhisi çok daha zordur.
Çözüm 1: Async All the Way
Asıl çözüm, async kodu bloklayarak beklememek. Handler'ı da async yaparsın ve await ile beklersin:
UiThread.Run("2) ConfigureAwait(false) in the library", () =>
{
int stock = GetStockNoContextAsync().Result;
Console.WriteLine($" stock: {stock}");
});
UiThread.Run("3) async all the way", async () =>
{
Console.WriteLine($" before await: {Thread.CurrentThread.Name}");
int stock = await GetStockAsync();
Console.WriteLine($" after await: {Thread.CurrentThread.Name}, stock: {stock}");
});
2) ConfigureAwait(false) in the library
stock: 4
3) async all the way
before await: UI
[context] Post from a thread pool thread
after await: UI, stock: 4
Üçüncü senaryoya bak. await sırasında UI thread'i bloklanmadı, queue'ya dönüp başka işlere bakmaya devam etti. Delay bitince continuation yine Post edildi ve bu sefer boşta olan UI thread'i onu hemen çalıştırdı. after await: UI: kod kaldığı yerden, aynı thread'de devam etti. Context zaten bunun için var; sorun context'te değil, onu bloklamakta.
"Async all the way" kuralının anlamı bu: zincirin bir halkası async ise onu çağıran halka da async olmalı, en üstteki event handler'a ya da controller action'ına kadar. Zinciri ortada bir .Result ile senkrona çevirdiğin an, yukarıda gördüğün döngünün kapanması için gereken her şey hazır olur.
Çözüm 2: ConfigureAwait(false)
İkinci senaryoda çağırdığımız method'un tek farkı ConfigureAwait(false):
static async Task<int> GetStockNoContextAsync()
{
await Task.Delay(200).ConfigureAwait(false);
return 4;
}
Bu, await'e "context'i yakalama, devamı nerede olursa orada çalıştır" der. Continuation thread pool'da çalıştı, task bitti ve UI thread'indeki .Result geri döndü. Çıktıda Post satırı da yok, çünkü context'e hiç uğranmadı.
Bunu bir deadlock ilacı gibi her yere serpmek yanlış olur. Kural şu: kütüphane kodunda, yani hangi uygulamada çalışacağını bilmediğin ve UI'a dokunmayan kodda ConfigureAwait(false) kullan. Hem kütüphaneni çağıran birinin .Result'ından kaynaklı deadlock riskini azaltırsın, hem de her continuation'ı UI thread'ine taşımanın gereksiz maliyetinden kurtulursun. Uygulama kodunda, yani event handler'da ya da await'ten sonra ekrana dokunan kodda kullanma. Orada context'e dönmek tam olarak istediğin şey.
Bir de garanti meselesi var. ConfigureAwait(false) sadece yazıldığı await'i etkiler ve sadece task gerçekten beklenirse işe yarar:
static async Task<int> GetStockFirstAwaitOnlyAsync()
{
await Task.CompletedTask.ConfigureAwait(false); // zaten tamamlanmış, thread değişmez
await Task.Delay(200);
return 4;
}
4) ConfigureAwait(false) only on the first await
[context] Post from a thread pool thread
DEADLOCK: the handler did not finish in 2 s
İlk await'e verilen task zaten tamamlanmıştı. Tamamlanmış bir task'ı await etmek beklemez, kod aynı thread'de senkron devam eder. Yani thread değişmedi, hâlâ UI thread'indeyiz ve context hâlâ Current. İkinci await'te ConfigureAwait(false) olmadığı için context yakalandı ve deadlock geri geldi. Pratikte bu, cache'ten hemen dönen bir çağrıda olur. Sonuç: bir kütüphanede ConfigureAwait(false) kullanacaksan her await'e yaz, sadece ilkine değil.
Task.Run ile Sarmak: Son Çare
Eski bir kod tabanında async API'yi senkron bir yerden çağırmak zorunda kaldığında görülen bir pattern daha var:
UiThread.Run("5) Task.Run(...).Result", () =>
{
int stock = Task.Run(() => GetStockAsync()).Result; // async kod context'siz bir pool thread'inde çalışır
Console.WriteLine($" stock: {stock}");
});
5) Task.Run(...).Result
stock: 4
Çalışıyor, çünkü Task.Run async method'u context'i olmayan bir thread pool thread'inde başlatır. Ama bedeli var: UI thread'i yine 200 ms bloklandı, yani pencere o süre boyunca donuk kaldı. Üstüne bir de pool thread'i harcadın. Bu, async'e geçiş yapamadığın bir yerde geçici bir yamadır, tasarım değildir.
Sık Düşülen Tuzaklar
Async bir method'u .Result, .Wait() ya da .GetAwaiter().GetResult() ile beklemek. Context olan her ortamda deadlock, context olmayan ortamda thread pool starvation riski.
Kodu konsolda ya da ASP.NET Core'da denedikten sonra "deadlock olmuyor" sonucuna varmak. Orada context yok; aynı kod WinForms, WPF, MAUI ya da klasik ASP.NET'te kilitlenir.
ConfigureAwait(false)'u sadece ilk await'e yazmak. Tamamlanmış bir task'ta thread değişmez ve sonraki await context'i yine yakalar.
Uygulama kodunda ConfigureAwait(false) kullanmak. UI handler'ında await'ten sonra bir kontrolü güncellersen kod pool thread'inde çalışır ve framework "yanlış thread" hatası fırlatır.
Constructor'da ya da property getter'ında async iş yapmak için .Result yazmak. Bunların async karşılığı yok; çözüm, işi async bir factory method'a ya da ayrı bir InitializeAsync method'una taşımak.
Küçük Bir Deneme
★☆☆ Demo'yu çalıştır ve birinci senaryoda .Result yerine .Wait() kullan. Çıktıda ne değişiyor, ne değişmiyor?
★★☆ SingleThreadSynchronizationContext.Post'a o anki queue uzunluğunu yazdıran bir satır ekle. Üçüncü senaryoda birden fazla await kullan ve her biri için bir Post görüp görmediğini kontrol et.
★★★ Kendi kütüphane method'unu yaz: cache'te varsa hemen dönen, yoksa Task.Delay ile servisi taklit eden bir GetStockAsync. Önce sadece ilk await'e ConfigureAwait(false) koyup cache dolu ve boşken deadlock'u yeniden üret, sonra doğru düzelt.
Deadlock'un kaynağı, async bir işi senkron beklemekti. Ters yöndeki hata da en az onun kadar yaygın: async bir işi hiç beklememek. Bir sonraki yazıda async void'in neden process'i çökertebildiğini ve fire-and-forget'i ne zaman, nasıl güvenli yapacağını görüyoruz.