C#'ta senkron koddan async çağırmak: deadlock, thread pool ve doğru yaklaşım
Bir Task'ı bloklayarak beklemek UI uygulamalarında nasıl deadlock'a, ASP.NET Core'da nasıl thread pool tükenmesine yol açar; bloklamak kaçınılmazsa ne yapılır; ConfigureAwait(false) ve ConfigureAwaitOptions nerede önemlidir; gizli async void process'i nasıl çökertir.
Senkron kodun er ya da geç bir async metodu çağırması gerekir: size ait olmayan senkron bir arayüz, bir constructor, eski usul bir Main. İlk refleks .Result ya da .Wait() olur. WinForms veya WPF'te bir event handler içinde bu, uygulamayı deadlock ile dondurabilir. ASP.NET Core'da deadlock olmaz, ama yük altında thread pool'u sessizce tüketip servisi yanıt veremez hale getirebilir.
Bu iki arıza birbirine hiç benzemez ve altlarındaki platform da değişti. Klasik ASP.NET ve masaüstü UI framework'leri bir şekilde davranır, ASP.NET Core başka şekilde. Vardığımız sonuç ise ikisinde de aynı: baştan sona async yazın, bloklamayı son çare olarak görün. Bu yazıda deadlock'un gerçekte nasıl oluştuğunu, ASP.NET Core'un belirtileri neden değiştirip sonucu değiştirmediğini, bloklamanın kaçınılmaz olduğu nadir durumlarda ne yapılacağını ve iki akraba tuzağı, ConfigureAwait ile async void'i anlatıyorum.
Deadlock nasıl oluşur
Bir şeyi await ettiğinizde derleyici o anki SynchronizationContext'i yakalar ve metodun geri kalanını sonradan oraya gönderir. UI thread'inin context'inde tek bir thread vardır. Bütün mesele bu:
private static readonly HttpClient Http = new();
private void LoadButton_Click(object sender, EventArgs e)
{
// UI thread'i burada task'ı beklerken bloklanıyor...
string html = GetPageAsync().Result;
outputBox.Text = html;
}
private async Task<string> GetPageAsync()
{
string html = await Http.GetStringAsync("https://example.com");
// ...ama bu devam kodunun çalışması için UI thread'i gerekiyor. Deadlock.
return html.Trim();
}
UI thread'i task'ı, task da UI thread'ini bekliyor. İkisi de ilerleyemiyor. Eski ASP.NET'in (System.Web) request context'i de aynı şekilde davrandığı için bu deadlock eski ASP.NET koduyla bu kadar özdeşleşmiş durumda.
UI tarafında çözüm basit. async void'in meşru olduğu tek yer event handler'lardır:
private async void LoadButton_Click(object sender, EventArgs e)
{
outputBox.Text = await GetPageAsync();
}
ASP.NET Core ve thread pool starvation
ASP.NET Core bir SynchronizationContext kurmaz. Devam kodları herhangi bir thread pool thread'inde çalışır, yani yukarıdaki kod bir controller içinde deadlock'a girmez. Buradan "sunucuda .Result sorun değil" sonucunu çıkarmak cazip gelebilir. Sorun.
Her bloklayan çağrı, I/O tamamlanana kadar bir thread pool thread'ini rehin tutar. Trafik bir anda artınca bütün thread'ler bloklanmış halde bekler. Onları serbest bırakacak işler (devam kodları) da kuyrukta onların arkasında sıraya girer. Thread pool yeni thread'leri bir anda değil, kademeli ekler. Gecikme tırmanır. CPU boşta olduğu halde servis donmuş gibi görünür. Buna thread pool starvation deniyor ve genellikle ancak production'da, gerçek yük altında ortaya çıkıyor. Şüpheleniyorsanız yük testi sırasında dotnet-counters ile thread pool thread sayısını ve kuyruk uzunluğunu izleyin. Thread sayısı sürekli artarken kuyruk uzun kalıyorsa tipik belirti budur.
Yani kural her yerde aynı: async all the way, baştan sona async.
Baştan sona async: bugünkü araçlar
Uzun süre sync-over-async'ten kaçmak zordu. Giriş noktaları, Dispose metotları ve framework'ün birçok genişleme noktası senkrondu. Bu yüzden yaygın geçici çözümler TaskFactory üzerine kurulmuş "AsyncHelper" metotları, iç içe mesaj döngüleri ya da düz bir .Result oldu. Hiçbiri her yerde çalışmaz, her birinin bir bedeli var. Bugün sıkıştığımız noktaların çoğunun async karşılığı var:
Mainiçinde bloklamak yerinestatic async Task<int> Main(string[] args)(C# 7.1+). Top-level statements içinde de doğrudanawaityazabilirsiniz.Dispose()içinde bloklamak yerineIAsyncDisposableveawait using.- ASP.NET Core'da async filter, middleware, hosted service ve Razor Page handler'ları.
- Akışlar için
IAsyncEnumerable<T>veawait foreach.
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(ct);
Temizlik işi async olan bir tip yazıyorsanız (bir buffer'ı flush etmek gibi) IAsyncDisposable uygulayın. Dispose() içine FlushAsync().Wait() gizlemeyin.
Bloklamak kaçınılmaz olduğunda
Bazen üçüncü parti bir kütüphanenin senkron arayüzünü uyguluyorsunuzdur, bazen bir constructor'ın içinde sıkışmışsınızdır. Ben şunu kullanıyorum:
public string GetSetting(string key)
{
// Bize ait olmayan senkron bir arayüz; store ise sadece async.
return Task.Run(() => _store.GetValueAsync(key))
.GetAwaiter()
.GetResult();
}
Task.Run, GetValueAsync'i bir thread pool thread'inde başlatır. Orada yakalanacak bir SynchronizationContext olmadığı için devam kodlarının bloklanan thread'e ihtiyacı kalmaz. Yine de beklerken bir thread harcarsınız. Bu son çare, her yere kopyalanacak bir kalıp değil.
Neden .Result değil de GetAwaiter().GetResult()? İkisi de bloklar. Fark exception'larda:
try
{
FailAsync().Wait();
}
catch (AggregateException ex)
{
// Asıl exception ex.InnerException içinde
}
try
{
FailAsync().GetAwaiter().GetResult();
}
catch (InvalidOperationException ex)
{
// await etmişsiniz gibi orijinal exception
}
Bütün fark bu. Deadlock açısından ikisinin birbirinden "daha güvenli" bir yanı yok.
Kütüphanelerde ve uygulamalarda ConfigureAwait(false)
ConfigureAwait(false), awaiter'a yakalanan context'e geri dönmemesini söyler. Microsoft'un ConfigureAwait FAQ yazısı bunu tek cümlede özetliyor: genel amaçlı kütüphane kodu yazıyorsanız ConfigureAwait(false) kullanın. Uygulama kodunda, özellikle await sonrasında kontrollere dokunan UI kodunda kullanmayın.
ASP.NET Core uygulama kodunda yakalanacak bir context olmadığından ConfigureAwait(false) çoğunlukla gürültüden ibaret. Controller'lara ve uygulama servislerine eklemiyorum. WPF'ten de kullanılabilecek bir NuGet paketinde ise her await'e ekliyorum:
public async Task<Order> GetOrderAsync(int id, CancellationToken ct = default)
{
using var response = await _http.GetAsync($"orders/{id}", ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return (await response.Content
.ReadFromJsonAsync<Order>(cancellationToken: ct)
.ConfigureAwait(false))!;
}
Şunu unutmayın: kütüphanedeki ConfigureAwait(false) sadece çağıranın deadlock'a düşme riskini azaltır. Sync-over-async'i doğru hale getirmez.
ConfigureAwaitOptions (.NET 8+)
.NET 8, flags tipinde bir enum alan ConfigureAwait(ConfigureAwaitOptions) overload'unu getirdi: None, ContinueOnCapturedContext, SuppressThrowing ve ForceYielding.
// Arka plandaki flush'ın bitmesini bekle; hata alıp almadığı umurumuzda değil.
await _flushTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
// Task zaten tamamlanmış olsa bile metodun kalanını asenkron çalışmaya zorla.
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
Dikkat edilecek iki ayrıntı var. Birincisi, None yakalanan context'e dönme demek, yani ConfigureAwait(false) ile aynı. Eski varsayılan davranışı istiyorsanız ContinueOnCapturedContext'i de ekleyin. İkincisi, SuppressThrowing sadece generic olmayan Task için destekleniyor. Task<T> üzerinde ArgumentOutOfRangeException fırlatır, çünkü döndürülecek bir sonuç kalmaz.
Akraba bir tuzak: async void
async void aynı yanlış anlamanın öbür yüzü. async void bir metot await edilemez, dolayısıyla exception'larının gidecek yeri yoktur. Metot başladığında geçerli olan SynchronizationContext üzerinde fırlatılırlar. ASP.NET Core'da, worker service'te ya da console uygulamasında olduğu gibi context yoksa thread pool üzerinde fırlatılırlar ve process'i çökertirler.
async void'i nadiren bilerek yazarsınız. Genelde farkında olmadan ortaya çıkar:
// List<T>.ForEach bir Action<T> alır, yani bu lambda async void olur.
orders.ForEach(async o => await _mailer.SendAsync(o));
// Aynı sorun: Parallel.ForEach lambda'nın async olduğunu bilmez.
// Her lambda ilk await'e geldiği anda geri döner.
Parallel.ForEach(orders, async o => await _mailer.SendAsync(o));
İkisi de e-postalar gönderilmeden geri döner, ikisinde de bir exception uygulamayı düşürebilir. Parallel.ForEachAsync (.NET 6+) kullanın. Paralelliğe ihtiyacınız yoksa düz bir foreach ve await yeterli:
await Parallel.ForEachAsync(
orders,
new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct },
async (order, token) => await _mailer.SendAsync(order, token));
Kontrol listesi
- Çağrı zincirini baştan sona async yapın. Async
Main,await usingve async handler'lar kullanın. - UI uygulamalarında UI thread'inden asla bir task'ı bloklayarak beklemeyin.
async void'i sadece event handler'larda kullanın. - ASP.NET Core'da
.Resultdeadlock yapmaz ama yük altında thread pool'u tüketir. Bunu bir bug olarak ele alın. - Bloklamak zorundaysanız
Task.Run(...).GetAwaiter().GetResult()kullanın, tek bir yerde toplayın ve nedenini yorum satırıyla yazın. - Kütüphanelerde
ConfigureAwait(false)kullanın, ASP.NET Core uygulama kodunda gerek yok. ConfigureAwaitOptions.None'ınfalseile aynı olduğunu,SuppressThrowing'in sadeceTasküzerinde çalıştığını unutmayın.- Kod tabanında
.ForEach(asyncve async lambda alanParallel.ForEach(çağrılarını arayın. Bunlarıforeach+awaitya daParallel.ForEachAsyncile değiştirin.
- ---
- baslik: "C#'ta senkron koddan async çağırmak: deadlock, thread pool ve doğru yaklaşım"
- ozet: "Bir Task'ı bloklayarak beklemek UI uygulamalarında nasıl deadlock'a, ASP.NET Core'da nasıl thread pool tükenmesine yol açar; bloklamak kaçınılmazsa ne yapılır; ConfigureAwait(false) ve ConfigureAwaitOptions nerede önemlidir; gizli async void process'i nasıl çökertir."
- tarih: 2026-10-13
- etiketler:
- - dotnet
- - csharp
- - async
- kategoriler:
- - dotnet
- ---
- Senkron kodun er ya da geç bir async metodu çağırması gerekir: size ait olmayan senkron bir arayüz, bir constructor, eski usul bir `Main`. İlk refleks `.Result` ya da `.Wait()` olur. WinForms veya WPF'te bir event handler içinde bu, uygulamayı deadlock ile dondurabilir. ASP.NET Core'da deadlock olmaz, ama yük altında thread pool'u sessizce tüketip servisi yanıt veremez hale getirebilir.
- Bu iki arıza birbirine hiç benzemez ve altlarındaki platform da değişti. Klasik ASP.NET ve masaüstü UI framework'leri bir şekilde davranır, ASP.NET Core başka şekilde. Vardığımız sonuç ise ikisinde de aynı: baştan sona async yazın, bloklamayı son çare olarak görün. Bu yazıda deadlock'un gerçekte nasıl oluştuğunu, ASP.NET Core'un belirtileri neden değiştirip sonucu değiştirmediğini, bloklamanın kaçınılmaz olduğu nadir durumlarda ne yapılacağını ve iki akraba tuzağı, `ConfigureAwait` ile `async void`'i anlatıyorum.
- ## Deadlock nasıl oluşur
- Bir şeyi `await` ettiğinizde derleyici o anki `SynchronizationContext`'i yakalar ve metodun geri kalanını sonradan oraya gönderir. UI thread'inin context'inde tek bir thread vardır. Bütün mesele bu:
- ```csharp
- private static readonly HttpClient Http = new();
- private void LoadButton_Click(object sender, EventArgs e)
- {
- // UI thread'i burada task'ı beklerken bloklanıyor...
- string html = GetPageAsync().Result;
- outputBox.Text = html;
- }
- private async Task<string> GetPageAsync()
- {
- string html = await Http.GetStringAsync("https://example.com");
- // ...ama bu devam kodunun çalışması için UI thread'i gerekiyor. Deadlock.
- return html.Trim();
- }
- ```
- UI thread'i task'ı, task da UI thread'ini bekliyor. İkisi de ilerleyemiyor. Eski ASP.NET'in (`System.Web`) request context'i de aynı şekilde davrandığı için bu deadlock eski ASP.NET koduyla bu kadar özdeşleşmiş durumda.
- UI tarafında çözüm basit. `async void`'in meşru olduğu tek yer event handler'lardır:
- ```csharp
- private async void LoadButton_Click(object sender, EventArgs e)
- {
- outputBox.Text = await GetPageAsync();
- }
- ```
- ## ASP.NET Core ve thread pool starvation
- ASP.NET Core bir `SynchronizationContext` kurmaz. Devam kodları herhangi bir thread pool thread'inde çalışır, yani yukarıdaki kod bir controller içinde deadlock'a girmez. Buradan "sunucuda `.Result` sorun değil" sonucunu çıkarmak cazip gelebilir. Sorun.
- Her bloklayan çağrı, I/O tamamlanana kadar bir thread pool thread'ini rehin tutar. Trafik bir anda artınca bütün thread'ler bloklanmış halde bekler. Onları serbest bırakacak işler (devam kodları) da kuyrukta onların arkasında sıraya girer. Thread pool yeni thread'leri bir anda değil, kademeli ekler. Gecikme tırmanır. CPU boşta olduğu halde servis donmuş gibi görünür. Buna thread pool starvation deniyor ve genellikle ancak production'da, gerçek yük altında ortaya çıkıyor. Şüpheleniyorsanız yük testi sırasında `dotnet-counters` ile thread pool thread sayısını ve kuyruk uzunluğunu izleyin. Thread sayısı sürekli artarken kuyruk uzun kalıyorsa tipik belirti budur.
- Yani kural her yerde aynı: async all the way, baştan sona async.
- ## Baştan sona async: bugünkü araçlar
- Uzun süre sync-over-async'ten kaçmak zordu. Giriş noktaları, `Dispose` metotları ve framework'ün birçok genişleme noktası senkrondu. Bu yüzden yaygın geçici çözümler `TaskFactory` üzerine kurulmuş "AsyncHelper" metotları, iç içe mesaj döngüleri ya da düz bir `.Result` oldu. Hiçbiri her yerde çalışmaz, her birinin bir bedeli var. Bugün sıkıştığımız noktaların çoğunun async karşılığı var:
- - `Main` içinde bloklamak yerine `static async Task<int> Main(string[] args)` (C# 7.1+). Top-level statements içinde de doğrudan `await` yazabilirsiniz.
- - `Dispose()` içinde bloklamak yerine `IAsyncDisposable` ve `await using`.
- - ASP.NET Core'da async filter, middleware, hosted service ve Razor Page handler'ları.
- - Akışlar için `IAsyncEnumerable<T>` ve `await foreach`.
- ```csharp
- await using var connection = new SqlConnection(connectionString);
- await connection.OpenAsync(ct);
- ```
- Temizlik işi async olan bir tip yazıyorsanız (bir buffer'ı flush etmek gibi) `IAsyncDisposable` uygulayın. `Dispose()` içine `FlushAsync().Wait()` gizlemeyin.
- ## Bloklamak kaçınılmaz olduğunda
- Bazen üçüncü parti bir kütüphanenin senkron arayüzünü uyguluyorsunuzdur, bazen bir constructor'ın içinde sıkışmışsınızdır. Ben şunu kullanıyorum:
- ```csharp
- public string GetSetting(string key)
- {
- // Bize ait olmayan senkron bir arayüz; store ise sadece async.
- return Task.Run(() => _store.GetValueAsync(key))
- .GetAwaiter()
- .GetResult();
- }
- ```
- `Task.Run`, `GetValueAsync`'i bir thread pool thread'inde başlatır. Orada yakalanacak bir `SynchronizationContext` olmadığı için devam kodlarının bloklanan thread'e ihtiyacı kalmaz. Yine de beklerken bir thread harcarsınız. Bu son çare, her yere kopyalanacak bir kalıp değil.
- Neden `.Result` değil de `GetAwaiter().GetResult()`? İkisi de bloklar. Fark exception'larda:
- ```csharp
- try
- {
- FailAsync().Wait();
- }
- catch (AggregateException ex)
- {
- // Asıl exception ex.InnerException içinde
- }
- try
- {
- FailAsync().GetAwaiter().GetResult();
- }
- catch (InvalidOperationException ex)
- {
- // await etmişsiniz gibi orijinal exception
- }
- ```
- Bütün fark bu. Deadlock açısından ikisinin birbirinden "daha güvenli" bir yanı yok.
- ## Kütüphanelerde ve uygulamalarda ConfigureAwait(false)
- `ConfigureAwait(false)`, awaiter'a yakalanan context'e geri dönmemesini söyler. Microsoft'un [ConfigureAwait FAQ](https://devblogs.microsoft.com/dotnet/configureawait-faq/) yazısı bunu tek cümlede özetliyor: genel amaçlı kütüphane kodu yazıyorsanız `ConfigureAwait(false)` kullanın. Uygulama kodunda, özellikle await sonrasında kontrollere dokunan UI kodunda kullanmayın.
- ASP.NET Core uygulama kodunda yakalanacak bir context olmadığından `ConfigureAwait(false)` çoğunlukla gürültüden ibaret. Controller'lara ve uygulama servislerine eklemiyorum. WPF'ten de kullanılabilecek bir NuGet paketinde ise her await'e ekliyorum:
- ```csharp
- public async Task<Order> GetOrderAsync(int id, CancellationToken ct = default)
- {
- using var response = await _http.GetAsync($"orders/{id}", ct).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
- return (await response.Content
- .ReadFromJsonAsync<Order>(cancellationToken: ct)
- .ConfigureAwait(false))!;
- }
- ```
- Şunu unutmayın: kütüphanedeki `ConfigureAwait(false)` sadece çağıranın deadlock'a düşme riskini azaltır. Sync-over-async'i doğru hale getirmez.
- ### ConfigureAwaitOptions (.NET 8+)
- .NET 8, flags tipinde bir enum alan `ConfigureAwait(ConfigureAwaitOptions)` overload'unu getirdi: `None`, `ContinueOnCapturedContext`, `SuppressThrowing` ve `ForceYielding`.
- ```csharp
- // Arka plandaki flush'ın bitmesini bekle; hata alıp almadığı umurumuzda değil.
- await _flushTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
- // Task zaten tamamlanmış olsa bile metodun kalanını asenkron çalışmaya zorla.
- await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
- ```
- Dikkat edilecek iki ayrıntı var. Birincisi, `None` yakalanan context'e *dönme* demek, yani `ConfigureAwait(false)` ile aynı. Eski varsayılan davranışı istiyorsanız `ContinueOnCapturedContext`'i de ekleyin. İkincisi, `SuppressThrowing` sadece generic olmayan `Task` için destekleniyor. `Task<T>` üzerinde `ArgumentOutOfRangeException` fırlatır, çünkü döndürülecek bir sonuç kalmaz.
- ## Akraba bir tuzak: async void
- `async void` aynı yanlış anlamanın öbür yüzü. `async void` bir metot await edilemez, dolayısıyla exception'larının gidecek yeri yoktur. Metot başladığında geçerli olan `SynchronizationContext` üzerinde fırlatılırlar. ASP.NET Core'da, worker service'te ya da console uygulamasında olduğu gibi context yoksa thread pool üzerinde fırlatılırlar ve **process'i çökertirler**.
- `async void`'i nadiren bilerek yazarsınız. Genelde farkında olmadan ortaya çıkar:
- ```csharp
- // List<T>.ForEach bir Action<T> alır, yani bu lambda async void olur.
- orders.ForEach(async o => await _mailer.SendAsync(o));
- // Aynı sorun: Parallel.ForEach lambda'nın async olduğunu bilmez.
- // Her lambda ilk await'e geldiği anda geri döner.
- Parallel.ForEach(orders, async o => await _mailer.SendAsync(o));
- ```
- İkisi de e-postalar gönderilmeden geri döner, ikisinde de bir exception uygulamayı düşürebilir. `Parallel.ForEachAsync` (.NET 6+) kullanın. Paralelliğe ihtiyacınız yoksa düz bir `foreach` ve `await` yeterli:
- ```csharp
- await Parallel.ForEachAsync(
- orders,
- new ParallelOptions { MaxDegreeOfParallelism = 4, CancellationToken = ct },
- async (order, token) => await _mailer.SendAsync(order, token));
- ```
- ## Kontrol listesi
- - Çağrı zincirini baştan sona async yapın. Async `Main`, `await using` ve async handler'lar kullanın.
- - UI uygulamalarında UI thread'inden asla bir task'ı bloklayarak beklemeyin. `async void`'i sadece event handler'larda kullanın.
- - ASP.NET Core'da `.Result` deadlock yapmaz ama yük altında thread pool'u tüketir. Bunu bir bug olarak ele alın.
- - Bloklamak zorundaysanız `Task.Run(...).GetAwaiter().GetResult()` kullanın, tek bir yerde toplayın ve nedenini yorum satırıyla yazın.
- - Kütüphanelerde `ConfigureAwait(false)` kullanın, ASP.NET Core uygulama kodunda gerek yok.
- - `ConfigureAwaitOptions.None`'ın `false` ile aynı olduğunu, `SuppressThrowing`'in sadece `Task` üzerinde çalıştığını unutmayın.
- - Kod tabanında `.ForEach(async` ve async lambda alan `Parallel.ForEach(` çağrılarını arayın. Bunları `foreach` + `await` ya da `Parallel.ForEachAsync` ile değiştirin.