C#'ta async/await tuzakları
async ve await C#‘ta asenkron kodu senkron kod kadar okunur hale getiriyor. Sorun da burada başlıyor: kod senkron gibi görünüyor ama öyle davranmıyor. Derleniyor, testleri geçiyor, sonra canlıda yük altında kilitleniyor, bir hatayı sessizce yutuyor ya da thread pool’u tüketiyor.
Yıllar içinde code review’larda en sık gördüğüm hataları topladım. Her birinde sorun, belirtisi ve düzeltmesi var.
Senkron koddan async çağırmak
// Yapma
var user = _repository.GetUserAsync(id).Result;
_service.SaveAsync(order).Wait();
.Result ve .Wait(), işlem bitene kadar çağıran thread’i bloklar.
Klasik ASP.NET, WinForms ve WPF gibi bir SynchronizationContext olan ortamlarda bu doğrudan deadlock demek. await sonrasındaki devam kodu yakalanan context’e, yani UI thread’ine ya da isteğin context’ine dönmek istiyor. O context ise .Result’ta bekleyerek bloklanmış durumda. İkisi de birbirini bekliyor.
ASP.NET Core’da SynchronizationContext yok, bu yüzden bu deadlock olmuyor. Ama sorun bitmiyor. Beklerken bloklanan her thread, thread pool’dan bir thread eksiltiyor. Yük arttığında pool yeni thread’leri yavaş ekliyor ve istekler sıraya giriyor. Buna thread pool starvation deniyor. CPU boşta görünürken yanıt süreleri saniyelere çıkıyor.
Düzeltme: Async’i baştan sona taşı. Bir metot async bir şey çağırıyorsa kendisi de async olsun, bu zincir controller’a kadar çıksın.
var user = await _repository.GetUserAsync(id);
await _service.SaveAsync(order);
Gerçekten senkron kalması gereken bir yer varsa, örneğin eski bir arayüz, GetAwaiter().GetResult() en azından hatayı AggregateException içine sarmadan fırlatır. Yine de bloklar ve deadlock riski aynen durur. Bunu kalıcı bir çözüm değil, geçici bir istisna olarak gör.
ConfigureAwait(false) ne zaman gerekli
ConfigureAwait(false), devam kodunun yakalanan SynchronizationContext’e dönmemesini söyler. Nerede kullanılacağı çok karıştırılıyor.
- Kütüphane kodunda her
await’te kullan. Paketini kimin, hangi ortamda çağıracağını bilmiyorsun. Çağıran bir WPF uygulaması ya da klasik ASP.NET olabilir. - ASP.NET Core uygulama kodunda hiçbir şey değiştirmez, çünkü dönülecek bir context yok. Yazmasan da olur.
.Resultdeadlock’unun çaresi değil. İşe yaraması için zincirdeki herawait’te, çağırdığın kütüphanelerin içi de dahil, olması gerekir. Tek bir eksik yeter. Ayrıca beklenenTaskzaten tamamlanmışsa hiçbir etkisi olmaz, metodun geri kalanı yine orijinal context’te çalışır.
Düzeltme: Kütüphanelerde ConfigureAwait(false) kullan, CA2007 eksik olanları gösterir. Deadlock’u ConfigureAwait ile yamamaya çalışma, bloklayan çağrıyı kaldır.
async void
// Yapma
public async void SendWelcomeEmail(User user)
{
await _mailer.SendAsync(user.Email);
}
async void bir metodu çağıran taraf bekleyemez ve hatasını yakalayamaz. İçeride fırlatılan istisna, çağıranın try/catch’ine hiç ulaşmaz. Metodun başladığı SynchronizationContext üzerinde, o yoksa thread pool’da yeniden fırlatılır. ASP.NET Core dahil çoğu ortamda bu, süreci doğrudan çökertir.
Düzeltme: Her zaman Task döndür. async void’in tek meşru kullanımı UI olay işleyicileri, çünkü imzaları değiştirilemiyor. Orada da gövdeyi try/catch ile sar.
Daha sinsi bir hali de var: Action bekleyen bir yere async lambda vermek.
// Bu lambda async void olur
users.ForEach(async u => await _mailer.SendAsync(u.Email));
List<T>.ForEach bir Action<T> alıyor. Lambda Task döndüremediği için async void oluyor. ForEach e-postaların gitmesini beklemeden dönüyor, hepsi aynı anda arka planda gönderiliyor. Biri hata verirse çağıran bunu yakalayamıyor ve süreç çöküyor. Normal bir foreach döngüsü kullan.
Await edilmeyen Task
public async Task<IActionResult> Register(RegisterRequest request)
{
var user = await _users.CreateAsync(request);
_audit.LogAsync(user.Id); // await yok
return Ok();
}
Bu fire-and-forget çağrı iki sorun yaratıyor. Birincisi, LogAsync hata verirse kimse görmüyor. İkincisi daha kötü: istek bittiğinde scoped servisler dispose ediliyor. LogAsync içinde bir DbContext kullanılıyorsa, işi bitmeden ObjectDisposedException alıyor. Hata zamanlamaya bağlı olduğu için ancak yük altında ve rastgele görünüyor.
Düzeltme: Ya await et, ya da işi gerçekten arka plana al. Bunun için bir kuyruk ve onu işleyen bir BackgroundService kullan. Arka plan işi kendi scope’unu IServiceScopeFactory ile açmalı.
Derleyici burada yardım ediyor: async bir metotta await edilmeyen Task için CS4014 uyarısı veriyor. Bu uyarıyı hataya çevirmek ucuz bir güvence. Mevcut listeyi ezmemek için değeri sona ekle:
<PropertyGroup>
<WarningsAsErrors>$(WarningsAsErrors);CS4014</WarningsAsErrors>
</PropertyGroup>
CS4014 sadece async metotlarda çıkıyor. Senkron bir metotta await edilmeyen Task için uyarı yok; o boşluğu VSTHRD110 kapatıyor.
CancellationToken’ı yolda bırakmak
Kullanıcı sayfayı kapattı ya da istemci zaman aşımına düştü. İstek iptal oldu ama sunucu hâlâ veritabanında ağır bir sorgu çalıştırıyor.
[HttpGet]
public async Task<IActionResult> Search(string q, CancellationToken ct)
{
var results = await _db.Products
.Where(p => p.Name.Contains(q))
.ToListAsync(ct);
var prices = await _pricing.GetAsync(results, ct);
return Ok(prices);
}
ASP.NET Core, action’daki CancellationToken parametresini HttpContext.RequestAborted’a, yani isteğin iptal sinyaline otomatik bağlıyor. Ama bu sinyal ancak zincirin sonuna kadar taşınırsa işe yarıyor. EF Core, HttpClient ve çoğu kütüphane bir token kabul ediyor.
Düzeltme: Token’ı her katmandan geçir. CA2016 analizörü, elinde token olan bir metot bunu token alabilen bir çağrıya iletmediğinde uyarıyor.
Sırayla beklemek ya da her şeyi aynı anda başlatmak
Birbirinden bağımsız üç çağrıyı sırayla beklemek, toplam süreyi üçünün toplamı yapar.
var profile = await _profiles.GetAsync(id);
var orders = await _orders.GetAsync(id);
var points = await _loyalty.GetAsync(id);
Bunlar birbirini beklemiyorsa aynı anda başlatılabilir:
var profileTask = _profiles.GetAsync(id);
var ordersTask = _orders.GetAsync(id);
var pointsTask = _loyalty.GetAsync(id);
await Task.WhenAll(profileTask, ordersTask, pointsTask);
var profile = await profileTask;
Burada iki tuzak var.
Aynı DbContext. Bu üç servis aynı scoped DbContext’i kullanıyorsa, “A second operation was started on this context instance before a previous operation completed” hatası gelir. DbContext thread-safe değil ve aynı anda tek bir işlem çalıştırabilir. Ya sırayla bekle, ya da her paralel iş için ayrı bir context kullan, örneğin IDbContextFactory<TContext> ile.
Sınırsız paralellik. Bin elemanlık bir liste için bin HTTP isteğini aynı anda başlatmak, karşı tarafı ya da kendi bağlantı havuzunu boğar. Eşzamanlılığı sınırla:
await Parallel.ForEachAsync(items,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
async (item, token) => await _api.SendAsync(item, token));
Task.WhenAll ve kaybolan hatalar
await Task.WhenAll(...), birden fazla task hata verse bile sadece ilk istisnayı fırlatır. Diğerleri logda görünmez.
var all = Task.WhenAll(tasks);
try
{
await all;
}
catch (Exception) when (all.IsFaulted)
{
foreach (var ex in all.Exception!.InnerExceptions)
_logger.LogError(ex, "Task başarısız");
throw;
}
Düzeltme: Bütün hatalar önemliyse, WhenAll’un döndürdüğü task’ın Exception.InnerExceptions listesine bak. when (all.IsFaulted) filtresini atlama: task’lar hata vermeden sadece iptal edilmişse all.Exception null olur ve catch içinde NullReferenceException alırsın.
Async görünsün diye Task.Run
// Yapma
public Task<Report> BuildAsync() => Task.Run(() => BuildReport());
ASP.NET Core’da bu hiçbir şey kazandırmıyor. İstek zaten bir thread pool thread’inde çalışıyor. İşi başka bir pool thread’ine taşımak, sadece ek bir geçiş maliyeti ekliyor. Üstelik çağıran taraf, metodun gerçekten I/O beklediğini sanıyor.
Düzeltme: Metot senkron ise senkron kalsın. Task.Run, UI uygulamalarında CPU yoğun bir işi arayüz thread’inden almak için anlamlı.
await’i atlamak
await etmeden Task’ı doğrudan döndürmek, küçük bir durum makinesi maliyetinden kurtarıyor. Ama using ve try bloklarının içinde bu davranışı değiştiriyor:
// Yanlış: client, istek bitmeden dispose edilir
public Task<string> FetchAsync(string url)
{
using var client = _factory.CreateClient();
return client.GetStringAsync(url);
}
Metot Task’ı döndürür döndürmez using bloğu kapanıyor ve istek sürerken HttpClient dispose ediliyor. HttpClient dispose edilirken bekleyen isteklerini iptal eder, bu yüzden çağıran her seferinde TaskCanceledException alıyor. (IHttpClientFactory’den alınan client’ı dispose etmen zaten gerekmiyor. Buradaki hata using’in await olmadan kullanılması.) Aynı şekilde, try/catch içinde await edilmeyen bir task’ın hatası o catch’e düşmüyor.
Düzeltme: await’i sadece using ya da try içermeyen, tek satırlık yönlendirme metotlarında atla. Diğer her yerde async ve await kullan. Kazanılan performans, bu hatanın maliyetinin yanında önemsiz.
LINQ ile Task üretmek
var tasks = ids.Select(id => _userApi.GetAsync(id));
await Task.WhenAll(tasks);
var first = await tasks.First(); // sorgu yeniden çalışır
Select lazy çalışıyor. tasks bir liste değil, her dolaşıldığında yeniden çalışan bir sorgu. Yukarıdaki kod GetAsync’i fazladan bir kez daha çağırıyor.
Düzeltme: ToList() ya da ToArray() ile hemen listeye çevir. Sonuçlar gerekiyorsa WhenAll’un dönüş değerini kullan:
var users = await Task.WhenAll(ids.Select(id => _userApi.GetAsync(id)));
Bu çağrıların hepsi aynı anda başlıyor. Yukarıdaki iki tuzak burada da geçerli: aynı DbContext’i paylaşmasınlar ve liste büyükse paralelliği sınırla.
lock içinde await
lock bloğunun içinde await kullanılamıyor, derleyici CS1996 hatası veriyor. Sebebi şu: lock arkasındaki Monitor, kilidi alan thread’e bağlı ve aynı thread’in bırakması gerekiyor. await sonrasında devam kodu başka bir thread’de çalışabilir.
Düzeltme: SemaphoreSlim kullan. lock’tan farklı olarak reentrant değil; aynı akış kilidi ikinci kez isterse kendini bekler.
private readonly SemaphoreSlim _gate = new(1, 1);
public async Task RefreshAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
await ReloadCacheAsync(ct);
}
finally
{
_gate.Release();
}
}
Araçlar
Bu hataların çoğunu review’da yakalamak zor, ama analizörler derleme anında yakalıyor:
- CS4014: Async metotta await edilmeyen
Task. Hataya çevir. - CA2016: İletilmeyen
CancellationToken. - CA1849: Async bir metotta, async karşılığı olan bir metodun senkron halini çağırmak. Varsayılan olarak kapalı, açman gerekiyor.
- CA2007: Kütüphane kodunda
ConfigureAwaiteksikliği. - Microsoft.VisualStudio.Threading.Analyzers:
VSTHRDile başlayan ek kurallar. Örneğinasync voidiçin VSTHRD100,Actionyerine verilen async lambda için VSTHRD101,.Resultve.Wait()için VSTHRD002, senkron metotta gözlenmeyenTaskiçin VSTHRD110.
Bunları .editorconfig ile hata seviyesine çekmek, ekipteki herkesin aynı kuralla çalışmasını sağlıyor.
Kısa kontrol listesi
.Resultve.Wait()yok. Async baştan sona.- Kütüphanelerde
ConfigureAwait(false), ama deadlock çaresi olarak değil. async voidsadece UI olay işleyicilerinde.- Her
Taskya await ediliyor ya da bilinçli bir arka plan kuyruğuna gidiyor. CancellationTokenen alt katmana kadar taşınıyor.- Bağımsız çağrılar
WhenAllile, ama aynıDbContextüzerinde değil. - Paralellik sınırlı.
WhenAllsonrası bütün hatalar loglanıyor.- Sunucu tarafında sahte async için
Task.Runyok. usingvetryiçindeawaitatlanmıyor.- LINQ ile üretilen task’lar hemen listeye çevriliyor.
- Asenkron kilit için
SemaphoreSlim. - Analizörler açık ve hata seviyesinde.