Mert Özen Her Satırda Daha İleri
Backend C# Asenkron Programlama — #09

C# async void ve Fire-and-Forget: Kaybolan Exception'lar

Mert Özen 7 Kas 2026 8 dk 1 görüntülenme
C# async void ve Fire-and-Forget: Kaybolan Exception'lar

async void method'dan çıkan exception neden try/catch'e gelmeden process'i çökertir ve async Task bunu nasıl çözer. Event handler'larda async void'in doğru kullanımı, List.ForEach ile gizli async void, beklenmeyen task'ın sessizce kaybolan hatası ve UnobservedTaskException, CS4014 uyarısı, hatayı log'a düşüren güvenli Forget extension'ı ve ASP.NET Core'da scope tuzağı.

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

Sipariş kaydeden bir method yazdık ama dönüş tipi Task değil void. Çağrıyı bir try/catch'in içine koyduk, sonra yarım saniye bekleyip "Done" yazdırıyoruz. Method 100 ms sonra exception fırlatıyor. catch bloğu çalışır mı, "Done" ekrana gelir mi?

try
{
    SaveOrderAsync(42);
    Console.WriteLine("SaveOrderAsync called");
}
catch (Exception ex)
{
    Console.WriteLine($"Caught: {ex.Message}");
}

await Task.Delay(500);
Console.WriteLine("Done");

static async void SaveOrderAsync(int orderId)
{
    await Task.Delay(100); // veritabanına yazıyormuş gibi
    throw new InvalidOperationException($"Order {orderId} could not be saved");
}

async void Exception'ı Kimseye Teslim Etmez

Çıktı:

SaveOrderAsync called
Unhandled exception. System.InvalidOperationException: Order 42 could not be saved

Bunu birkaç satırlık stack trace izliyor ve process 134 çıkış koduyla kapanıyor; macOS ve Linux'ta bu, process'in abort ile sonlandırıldığı anlamına gelir. catch çalışmadı, "Done" hiç yazılmadı. Uygulama çöktü.

Sebebi dönüş tipinde. async Task bir method exception fırlattığında exception o task'ın içine konur ve task'ı await eden kişiye teslim edilir. async void method'un ise teslim edecek bir task'ı yok. Method ilk await'e geldiğinde çağırana geri döndü; çağıran da try bloğundan çıkıp yoluna devam etti. 100 ms sonra exception fırladığında onu bekleyen kimse yoktu. Runtime da onu, method başladığında aktif olan SynchronizationContext'e, o yoksa thread pool'a yeniden fırlatır. Stack trace'teki Task.<>c.<ThrowAsync> ve ThreadPoolWorkQueue.Dispatch satırları tam olarak bunu gösteriyor. Thread pool'da yakalanmamış exception ise process'i sonlandırır.

Bunu kargo ile göndermeye benzetebilirsin. async Task takip numarası olan bir gönderidir: paket kaybolursa sen bilirsin, takip numarasıyla sorarsın. async void ise takip numarasız bırakılmış bir paket. Yolda bir şey olursa haber verilecek kimse yok; en kötü durumda depo kapanır.

async Task ile Aynı Kod

Tek kelimeyi değiştirip çağrıyı await ediyoruz:

static async Task SaveOrderAsync(int orderId)
{
    await Task.Delay(100);
    throw new InvalidOperationException($"Order {orderId} could not be saved");
}
Caught: Order 42 could not be saved
Done

Exception task'ın içinde taşındı, await onu yeniden fırlattı ve catch yakaladı. Uygulama yoluna devam etti. Ayrıca async void bir method'u bekleyemezsin bile: await SaveOrderAsync(42) derlenmez, çünkü ortada beklenecek bir şey yok. Ne zaman bittiğini öğrenemez, testte sonucunu kontrol edemezsin.

Kural basit: async bir method yazıyorsan dönüş tipi Task ya da Task<T> olsun. async void için tek bir istisna var.

async void'in Tek Meşru Yeri: Event Handler

Event handler'ların signature'ı void döner; EventHandler delegate'i böyle tanımlı ve değiştiremezsin. UI uygulamalarındaki buton tıklamaları bu yüzden async void yazılır. Burada da kural, method'un gövdesini tamamen bir try/catch içine almaktır:

async void OnSaveClicked(object? sender, int orderId)
{
    try
    {
        await SaveOrderAsync(orderId);
        Console.WriteLine($"Order {orderId} saved");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"[error] save failed: {ex.Message}"); // gerçek uygulamada log'a yaz ve kullanıcıya göster
    }
}
[error] save failed: Order 42 could not be saved
Still running

Handler exception'ı kendi içinde yakaladı ve uygulama çalışmaya devam etti. Böyle bir handler'ı ince tut: içinde sadece try/catch ve asıl işi yapan bir async Task method'a yapılan await çağrısı olsun. Mantığın kendisi test edilebilir Task döndüren method'da kalır. UI framework'lerinde handler, UI thread'inin context'i üzerinde çalıştığı için yakalanmamış exception o context'e gider ve framework'ün global hata event'ine düşer. Yine de kaçırılmış bir exception'ı orada yakalamak, kullanıcıya anlamlı bir mesaj göstermekten çok daha kötü bir seçenektir.

Gizli async void: Lambda'lar

async void'i hiç yazmadan da üretebilirsin. Siparişleri tek tek kargoya veren şu koda bak:

List<int> orderIds = new() { 1, 2, 3 };

orderIds.ForEach(async id =>
{
    await Task.Delay(100);
    Console.WriteLine($"Order {id} shipped");
});
Console.WriteLine("All orders shipped");

await Task.Delay(300);
All orders shipped
Order 2 shipped
Order 1 shipped
Order 3 shipped

"All orders shipped" önce yazıldı, siparişler sonra ve karışık sırayla. List<T>.ForEach bir Action<T> alır, yani void dönen bir delegate. Ona verdiğin async lambda derleyici tarafından async void bir method'a çevrilir. ForEach her lambda'yı çağırır, lambda ilk await'te geri döner ve ForEach bir sonrakine geçer. Beklenen hiçbir şey yok. Lambda'lardan biri exception fırlatsaydı ilk örnekteki gibi process çökerdi. Derleyici burada uyarı da vermez.

Async lambda'yı bir parametreye verirken parametrenin tipine bak. Func<Task> ya da Func<T, Task> ise sorun yok; Action ya da Action<T> ise elinde gizli bir async void var. Parallel.ForEach da aynı tuzağı taşır. Çözüm düz bir foreach içinde await ya da hepsini aynı anda başlatmak istiyorsan await Task.WhenAll(orderIds.Select(ShipAsync)).

Fire-and-Forget: Beklemediğin Task

Bazı işleri gerçekten beklemek istemezsin. Sipariş kaydedildi, onay e-postası arkadan gitsin; kullanıcı SMTP sunucusunu beklemesin. Method async Task ama sonucu bilerek await etmiyoruz:

TaskScheduler.UnobservedTaskException += (sender, e) =>
{
    Console.WriteLine($"UnobservedTaskException: {e.Exception.InnerException?.Message}");
};

_ = SendConfirmationEmailAsync(42); // bilerek beklemiyoruz
await Task.Delay(300);
Console.WriteLine("Email task is long gone, nobody noticed");

GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("After GC");

static async Task SendConfirmationEmailAsync(int orderId)
{
    await Task.Delay(100);
    throw new InvalidOperationException($"SMTP server rejected the email for order {orderId}");
}
Email task is long gone, nobody noticed
UnobservedTaskException: SMTP server rejected the email for order 42
After GC

Bu sefer process çökmedi, çünkü exception task'ın içinde saklandı. Ama kimse ona bakmadı. E-posta gitmedi ve 300 ms boyunca bunu bilen hiç kimse yoktu. Exception ancak garbage collector o task'ı topladığında TaskScheduler.UnobservedTaskException event'ine düştü. Örnekte GC.Collect()'u biz çağırdık; gerçek uygulamada bunun ne zaman olacağı belli değil, belki dakikalar sonra, belki hiç. .NET Framework 4.5'ten beri bu event process'i çökertmez de; varsayılan davranış exception'ı sessizce yutmaktır.

Yani async Task ile fire-and-forget çökmez ama sessizce başarısız olur. Kullanıcı onay e-postası alamaz ve log'da tek bir satır yoktur.

Derleyicinin Uyarısı: CS4014

Async bir method'un içinde Task döndüren bir çağrıyı beklemeden bırakırsan derleyici seni uyarır:

await PlaceOrderAsync(42);

async Task PlaceOrderAsync(int orderId)
{
    await Task.Delay(50);
    SendConfirmationEmailAsync(orderId);
    Console.WriteLine($"Order {orderId} placed");
}

static async Task SendConfirmationEmailAsync(int orderId)
{
    await Task.Delay(100);
}
Program.cs(6,5): warning CS4014: Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.
Order 42 placed

Bu uyarıyı ciddiye al; çoğu zaman gerçekten unutulmuş bir await'i gösterir. Bilerek beklemiyorsan _ = SendConfirmationEmailAsync(orderId); yazarak niyetini açıkça belirtirsin ve uyarı kaybolur. Ama discard sadece uyarıyı susturur; bir önceki örnekte gördüğün gibi exception hâlâ sahipsizdir.

Güvenli Fire-and-Forget

Beklemediğin bir task'ın hatasını en azından log'a yazmak için küçük bir extension method yeter:

static class TaskExtensions
{
    // bilinçli olarak async void: hiçbir exception dışarı kaçmaz
    public static async void Forget(this Task task, string operation)
    {
        try
        {
            await task.ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[error] {operation} failed: {ex.Message}"); // gerçek uygulamada ILogger
        }
    }
}
SendConfirmationEmailAsync(42).Forget("confirmation email");
Console.WriteLine("Order 42 placed");

await Task.Delay(300);
Order 42 placed
[error] confirmation email failed: SMTP server rejected the email for order 42

Burada async void'i bilerek kullandık ve bu yazıdaki kuralı çiğnemiyoruz: method'un gövdesinin tamamı try/catch içinde, hiçbir exception dışarı kaçamaz. ConfigureAwait(false) da bir UI context'ine geri dönmeye gerek olmadığı için var. Artık hata kaybolmuyor, log'da görünüyor. Gerçek kodda Console.WriteLine yerine bir ILogger geçirirsin.

Bu yardımcı hatayı görünür kılar ama işin yapılacağını garanti etmez. Process o sırada kapanırsa e-posta yine gitmez. Kaybolmaması gereken işler için bir queue gerekir; bunu bir sonraki bölümde konuşalım.

ASP.NET Core'da Fire-and-Forget

Web uygulamalarında fire-and-forget'in bir tuzağı daha var: isteğin scope'u. Controller'da _ = SendConfirmationEmailAsync(order) yazdığında response hemen döner ve ASP.NET Core isteğin dependency injection scope'unu kapatır. O scope'tan gelen DbContext gibi scoped service'ler dispose edilir. Arka planda çalışmaya devam eden task o DbContext'e dokunduğu an ObjectDisposedException alır, ve bu exception da yukarıda gördüğün gibi sessizce kaybolur. Scoped lifetime'ın ayrıntıları için ASP.NET Core serisinin dependency injection yazısına bakabilirsin.

Doğru pattern, işi isteğin içinde başlatmak yerine bir queue'ya bırakmaktır. İstek queue'ya bir mesaj yazar ve hemen döner. Arka planda uygulama boyunca yaşayan bir BackgroundService queue'yu okur ve her iş için IServiceScopeFactory ile kendi scope'unu açar. Böylece hem scoped service'ler doğru yaşar hem de uygulama kapanırken host bu service'in bitmesini bekleyebilir. Uygulama içi queue için System.Threading.Channels çok uygun, onu bu serinin Channels yazısında göreceğiz. Process çöktüğünde bile kaybolmaması gereken işler için ise kalıcı bir queue (bir veritabanı tablosu, RabbitMQ, Azure Service Bus gibi) gerekir.

Sık Düşülen Tuzaklar

Event handler dışında async void yazmak. Exception process'i çökertir, method beklenemez ve test edilemez.

Event handler'ı async void yapıp try/catch koymamak. Handler'daki tek bir hata uygulamanın tamamını kapatabilir.

List<T>.ForEach, Parallel.ForEach ya da Action alan herhangi bir API'ye async lambda vermek. Derleyici uyarmaz, elinde gizli bir async void olur.

CS4014'ü _ = ile susturup işi bitmiş saymak. Uyarı gider, ama exception'ı gözlemleyen hâlâ kimse yoktur.

ASP.NET Core'da isteğin scope'undan gelen service'leri arka plan task'ında kullanmak. Response döndüğünde scope kapanır ve service dispose edilir.

Küçük Bir Deneme

★☆☆ İlk örneği çalıştır ve çıkış kodunu gör (echo $?). Sonra method'u async Task yapıp çağrıyı await etmeden bırak. Çıktı ve çıkış kodu nasıl değişiyor?

★★☆ Kargo örneğini ForEach yerine Task.WhenAll ile yeniden yaz. "All orders shipped" satırının en sonda geldiğinden ve bir siparişin fırlattığı exception'ın yakalanabildiğinden emin ol.

★★★ Forget extension'ına bir Action<Exception> parametresi ekle ve OperationCanceledException'ı hata olarak değil, ayrı bir bilgi mesajı olarak raporla.

Buraya kadar hep Task ve Task<T> kullandık. Her Task<T> bir object'tir ve heap'te yer kaplar; sonuç çoğu zaman cache'ten hemen geliyorsa bu maliyet gereksizdir. Bir sonraki yazıda ValueTask'ı, onu ne zaman seçeceğini ve kullanırken uyman gereken kuralları BenchmarkDotNet ölçümleriyle görüyoruz.