Skip to content

Bölüm 8 / 13

async/await

Task, senkron/asenkron farkı, deadlock tuzakları, CancellationToken.

13 dakikalık okuma

Hat 02'nin 3. dersi thread'in pahalı bir kaynak olduğunu, 5. dersi de aynı anda çalışan işlerin nasıl birbirine girdiğini anlatmıştı; 4. ders (exception) AggregateException'ın adını, 7. ders (delegate) ise async void event handler tuzağının adını buraya bırakmıştı. Bu ders hepsinin karşılığını ödüyor. Konu, modern .NET kodunun her satırına sinen tek bir fikirdir: beklerken thread tutma. Mülakatta C#'ın en çok sorulan başlığıdır ve yanlış cevapların en pahalıya patladığı başlıktır — kilitlenen istekler, tükenen thread havuzları ve sessizce yutulan hatalar hep bu dersin konusudur.

01Ne öğreneceksin

  • Asenkronluğun amacı: neden hız değil ölçeklenebilirlik; I/O-bound ile CPU-bound ayrımı
  • Task nedir, async/await'i derleyici neye çevirir
  • await sonrası kodun nerede koştuğu: bağlam (context) ve ConfigureAwait(false)
  • İki klasik tuzak: .Result/.Wait() deadlock'u ve async void
  • WhenAll/WhenAny ile birlikte bekleme, hata toplama, AggregateException
  • CancellationToken ile işbirlikçi iptal, zaman aşımı ve saha kararları

02Problem: beklerken tutulan thread

Şu satırı düşün: var veri = File.ReadAllText("rapor.csv"); Disk veriyi hazırlarken CPU'nun bu iş için yapacağı hiçbir şey yoktur; ama satırı çalıştıran thread orada durur, bekler ve hiçbir işe yaramadan bellek (kendi stack'i, tipik olarak 1 MB) ve zamanlayıcı kaydı tutmaya devam eder. Hat 02'nin 3. dersinde thread'in ucuz olmadığını, 4. dersinde her thread'in kendi stack'i olduğunu görmüştün. Asenkron programlama tam olarak bu israfı hedefler.

Etkiyi görmek için bir web sunucusu düşün. .NET'in thread havuzu (thread pool) sınırlıdır ve talep arttığında yavaş büyür. Her istek veritabanından 100 ms bekliyorsa ve her bekleme bir thread'i tutuyorsa, eşzamanlı istek sayısı havuz kapasitesini aştığı anda sunucu kilitlenmez ama kuyruğa girer: yanıt süreleri birden tırmanır. Asenkron sürümde bekleyen istek hiçbir thread tutmaz — aynı donanım kat kat fazla eşzamanlı isteği taşır.

Buradan çıkan cümle, mülakatın ayırt edici cümlesidir:

  • async, işi hızlandırmaz. Tek bir kullanıcı için asenkron sürüm senkron sürümden hızlı değildir; durum makinesi ek yükü yüzünden mikroskobik olarak daha yavaştır bile. Kazandırdığı şey ölçeklenebilirliktir: aynı anda daha çok iş.
  • async, thread yaratmaz. Yaygın yanılgı budur. Asenkron bir I/O çağrısı beklerken hiçbir thread harcanmaz; işletim sistemi iş bitince haber verir (Hat 02'nin 2. dersindeki kesme mekanizması), devamı havuzdan bir thread alır.
  • Ayrım I/O-bound / CPU-bound'dur. Disk, ağ, veritabanı, HTTP = I/O-bound → async/await'in alanı. Sıkıştırma, görüntü işleme, büyük hesap = CPU-bound → burada bekleme yok, iş var; çözüm başka bir thread'e taşımak (Task.Run, paralel işleme) ya da algoritmayı iyileştirmektir.

Masaüstü/mobil tarafında aynı mekanizmanın farklı bir yüzü vardır: orada tek bir UI thread'i vardır ve o thread bloke olursa arayüz donar. "Uygulama yanıt vermiyor" penceresinin arkasındaki sebep, UI thread'inde yapılan senkron beklemedir.

03Task ve derleyicinin ürettiği durum makinesi

Task, "gelecekte tamamlanacak bir iş"i temsil eden nesnedir; Task<T> ayrıca bir sonuç taşır. Bir Task'ın durumu tamamlandı (RanToCompletion), hata verdi (Faulted) ya da iptal edildi (Canceled) olabilir. Kritik ayrım: Task bir thread değildir. Bir Task, çalışan bir thread'i temsil edebileceği gibi (CPU-bound iş), hiçbir thread tutmayan bir bekleyişi de temsil edebilir (I/O).

public async Task<string> RaporGetirAsync(int id)
{
    using var istemci = new HttpClient();
    string govde = await istemci.GetStringAsync($"https://ornek/rapor/{id}");  // I/O: thread yok
    return govde.Trim();
}

async anahtar kelimesi metodun imzasını değiştirmez; derleyiciye metodu bir durum makinesine (state machine) çevir der. Derleyici, her await noktasını bir "durak" sayar: metodu duraklara bölen gizli bir sınıf üretir, yerel değişkenleri o sınıfın alanlarına taşır (yani heap'e — 7. dersteki closure ile aynı mekanizma) ve beklenen iş bitince kaldığı duraktan devam etmesini sağlar.

Dönüş tipleri ve anlamları:

Dönüş tipiAnlamıNe zaman
TaskSonuç taşımayan asenkron işVarsayılan; void'in asenkron karşılığı
Task<T>T döndüren asenkron işVarsayılan; değer döndüren metotlar
ValueTask<T>Çoğu zaman senkron biten işÖlçülmüş sıcak yol; ileri seviye
voidÇağıranın bekleyemediği işYalnız event handler; başka hiçbir yerde
IAsyncEnumerable<T>Akış hâlinde gelen diziawait foreach ile tüketilir

İki pratik ayrıntı. Birincisi adlandırma: asenkron metotlar Async son ekiyle biter (KaydetAsync) — .NET genelinde beklenen sözleşmedir. İkincisi sıcak task (hot task): bir async metot çağrıldığı anda çalışmaya başlar; await onu başlatmaz, bekler. Bu ayrım paralelliğin anahtarıdır:

// Sırayla: toplam süre = a + b
var x = await AAsync();
var y = await BAsync();

// Birlikte: her ikisi de ÇAĞRILDIĞI anda başladı; toplam süre = max(a, b)
Task<int> ta = AAsync();
Task<int> tb = BAsync();
int x2 = await ta;
int y2 = await tb;

04await'ten sonra kod nerede koşar

await gördüğünde derleyicinin ürettiği kod önce şunu sorar: beklenen iş zaten bitmiş mi? Bittiyse hiçbir şey olmaz, kod aynı thread'de düz devam eder (bu sık görülen ve ucuz olan yoldur). Bitmemişse metodun geri kalanı bir devam işi (continuation) olarak kaydedilir ve metot, çağıranına tamamlanmamış bir Task döndürerek geri döner. Çağıran da await ediyorsa aynısını yapar; zincir yukarı doğru böyle çözülür ve en tepede thread serbest kalır.

İş bitince devam işi nerede koşar? Cevabı SynchronizationContext belirler: "devamı benim kurallarımla çalıştır" diyen bir bağlam nesnesi.

  • UI uygulamalarında (WinForms, WPF, MAUI) bir bağlam vardır ve devamı UI thread'ine geri taşır. Bu nedenle await sonrası doğrudan arayüz güncelleyebilirsin — bu bir kolaylıktır, ama az sonraki deadlock'un da sebebidir.
  • ASP.NET Core'da bağlam yoktur (eski ASP.NET Framework'te vardı). Devam işi havuzdaki herhangi bir thread'de koşar; istek başlarken kullandığın thread ile await sonrasındaki thread farklı olabilir — kodun buna bağlı olmaması gerekir.
  • Konsol uygulamasında da bağlam yoktur.

ConfigureAwait(false), "devamı bağlama geri taşıma, havuzda devam et" demektir:

string govde = await istemci.GetStringAsync(url).ConfigureAwait(false);
// buradan sonra UI thread'i GARANTİ DEĞİL — arayüze dokunma
  • Kütüphane yazıyorsan kural: her await'e ConfigureAwait(false). Kütüphanen kimin bağlamında çağrılacağını bilemez; bağlama geri dönmek hem gereksiz maliyet hem de deadlock riskidir.
  • Uygulama kodunda (özellikle ASP.NET Core) gereksizdir; zaten bağlam yoktur. UI kodunda ise await sonrası arayüze dokunacaksan kullanma.

Son bir davranış farkı, 4. dersle bağlanır: await bir hata yakaladığında istisnayı olduğu gibi, yığın izini koruyarak yeniden fırlatır — bu yüzden asenkron kodu try/catch ile sarmalamak senkron koddaki gibi çalışır. .Result ise istisnayı AggregateException içine sarar; hata mesajlarının "bir ya da daha fazla hata oluştu" diye başlaması bunun izidir.

05İki klasik tuzak: sync-over-async ve async void

Birincisi: asenkron bir metodu .Result, .Wait() ya da .GetAwaiter().GetResult() ile senkron beklemek — literatürdeki adıyla sync over async. UI uygulamasında bu kesin bir deadlock reçetesidir:

// UI thread'inde (buton olayında) çalışıyor:
string s = RaporGetirAsync(7).Result;   // 1) UI thread BLOKE oldu, bekliyor
// 2) HTTP bitti; devam işi "UI thread'ine dön" diyor
// 3) UI thread meşgul — 1. adımda bloke; devam edemez
// 4) İki taraf birbirini bekliyor: KİLİT. Uygulama sonsuza kadar donar.

Bu, Hat 02'nin 5. dersindeki dairesel bekleme koşulunun aynısıdır; yalnız kilitler yerine bağlam ve thread vardır. ASP.NET Core'da bağlam olmadığı için bu klasik deadlock görülmez — ama tehlike bitmez: bloke edilen her istek bir havuz thread'i tutar ve yük altında thread havuzu açlığı (thread pool starvation) doğar. Belirtisi çok tanıdıktır: CPU düşük, istekler yavaş, uygulama yük arttıkça daha da yavaşlar. Kural nettir: async all the way — zinciri senkron bir halkayla kesme. Main bile artık static async Task Main() olabilir.

İkincisi: async void. Görünüşte zararsızdır, üç şeyi birden bozar:

  • Çağıran bekleyemez: geri dönen bir Task yoktur; iş bitmeden akış devam eder.
  • İstisna yakalanamaz: async void içindeki bir hata çağıranın try/catch'ine düşmez; bağlama fırlatılır ve tipik olarak süreci çökertir.
  • Test edilemez: bittiğini anlamanın yolu yoktur.

Tek meşru kullanım, 7. dersin senedidir: event handler'lar. Bir event imzası void döndürdüğü için handler'ı asenkron yapmanın başka yolu yoktur. Kural o zaman şudur — handler'ın gövdesini baştan sona try/catch ile sarmala ve gerçek işi await edilebilir bir metoda devret:

private async void KaydetTiklandi(object gonderen, EventArgs e)   // tek istisna: event handler
{
    try { await KaydetAsync(); }
    catch (Exception ex) { HataGoster(ex); }   // yakalanmazsa süreç çöker
}

06Birlikte bekleme: WhenAll, WhenAny ve hata toplama

Birbirinden bağımsız üç çağrıyı sırayla await etmek, sürelerini toplamak demektir. Task.WhenAll hepsini birden bekler:

Task<Musteri> tm = MusteriGetirAsync(id);
Task<List<Siparis>> ts = SiparisleriGetirAsync(id);
Task<decimal> tb = BakiyeGetirAsync(id);

await Task.WhenAll(tm, ts, tb);            // süre = en yavaşı
var ozet = new Ozet(tm.Result, ts.Result, tb.Result);   // tamamlandığı KESİN: .Result burada güvenli

Hata davranışı 4. dersin senedini öder: WhenAll'un döndürdüğü Task await edilirse yalnızca ilk istisna fırlatılır. Hepsini görmek istiyorsan Task nesnesinin Exception özelliğine bakmalısın — orada AggregateException vardır ve InnerExceptions koleksiyonu tüm hataları taşır:

var hepsi = Task.WhenAll(t1, t2, t3);
try { await hepsi; }
catch (Exception)
{
    foreach (var hata in hepsi.Exception!.InnerExceptions)   // hepsi burada
        Logla(hata);
}

Task.WhenAny ise ilk biteni verir; yedekli çağrı ("iki kaynaktan hangisi önce cevaplarsa") ve klasik zaman aşımı deseni bununla kurulurdu. .NET 6'dan beri zaman aşımı için daha temiz bir yol var: await isTask.WaitAsync(TimeSpan.FromSeconds(5));

Üç saha uyarısı:

  • Her şeyi paralelleştirme. DbContext gibi thread-safe olmayan nesneler üzerinde aynı anda iki işlem başlatmak çalışma zamanı hatası verir; paralellik yalnız gerçekten bağımsız işler içindir.
  • Ateşle-unut yasağı. await edilmeyen bir Task'ın istisnası sessizce yutulur ve iş bitmeden uygulama kapanabilir. "Sonucu umursamıyorum" diyorsan bile Task'ı tut, en azından hatasını logla.
  • Sınırsız paralellik yük demektir. 10.000 kaydı Select(...) + WhenAll ile aynı anda göndermek karşı tarafı devirir; toplu işlerde eşzamanlılığı sınırlamak gerekir (SemaphoreSlim ya da Parallel.ForEachAsync'in MaxDegreeOfParallelism ayarı).

07İptal, zaman aşımı ve saha kararları

.NET'te iptal işbirlikçidir (cooperative): kimse bir işi zorla durdurmaz; bir iptal isteği yayınlanır, çalışan kod onu düzenli aralıklarla kontrol eder ve kendisi çıkar. Taşıyıcı tip CancellationToken, kaynağı CancellationTokenSource'tur.

using var kaynak = new CancellationTokenSource(TimeSpan.FromSeconds(5));   // 5 sn sonra otomatik iptal

public async Task<int> IsleAsync(IEnumerable<Kayit> kayitlar, CancellationToken token)
{
    int sayac = 0;
    foreach (var k in kayitlar)
    {
        token.ThrowIfCancellationRequested();       // düzenli kontrol
        await YazAsync(k, token);                   // token'ı AŞAĞI GEÇİR — zincir kopmasın
        sayac++;
    }
    return sayac;
}
  • Token'ı taşımak zorunludur. Bir metot token alıp aşağıdaki çağrılara geçirmiyorsa iptal fiilen çalışmaz. Kod incelemesinde ilk bakılan şeydir.
  • ASP.NET Core'da bedava gelir: controller action'ına bir CancellationToken parametresi koyarsan çerçeve HttpContext.RequestAborted'ı bağlar; kullanıcı sekmeyi kapattığında veritabanı sorgun da iptal olur.
  • OperationCanceledException hata değildir, normal akıştır. 4. dersin diliyle: bunu "beklenen durum" sayıp ayrı yakala; hata sayacına yazma, alarm üretme.
  • Bağlı token (CreateLinkedTokenSource) ile "isteğin iptali veya 5 saniyelik zaman aşımı" gibi bileşik koşullar kurulur.

Kalan saha kararları, kıdemi belli eden ayrıntılardır:

DurumDoğru hamle
UI'da uzun süren hesap (CPU-bound)await Task.Run(() => Hesapla()) — UI thread'i boşalt
ASP.NET'te senkron metodu "asenkronlaştırmak"Yapma. Task.Run aynı havuzdan thread çalar; kazanç yok, zarar var
Metotta hiç await yokasync'i kaldır, doğrudan Task döndür (durum makinesi maliyeti kalksın)
Sıcak yolda çoğu zaman önbellekten dönen çağrıValueTask<T> — ama yalnız ölçtüysen; bir kez await edilir, saklanmaz
Milyon satırlık sonucu işlemekIAsyncEnumerable<T> + await foreach — belleğe hepsini almadan akıt
Lambda'ya asenkron gövde vermekFunc<Task> al; Action alan bir API'ye async lambda verirsen async void üretmiş olursun

08Mini alıştırma — Ölç, kilitle, iptal et

Yaklaşık 20 dakika · dotnet run

  1. Sıralı mı birlikte mi: Task<int> IsAsync(int no) yaz (await Task.Delay(1000) yapıp no döndürsün). Beş işi önce sırayla await et, sonra beşini birden başlatıp Task.WhenAll ile bekle. İki süreyi Stopwatch ile ölç ve farkı tek cümleyle açıkla.
  2. Sıcak task kanıtı: IsAsync'in ilk satırına ekrana yazan bir satır koy. var t = IsAsync(1); yazıp await etmeden araya Console.WriteLine("çağrıdan sonra") koy. Hangi satır önce yazıldı? Bu, "await başlatmaz, bekler" cümlesinin kanıtıdır.
  3. Hata toplama: üç task kur; ikisi farklı istisna fırlatsın. await Task.WhenAll(...)'ı try/catch ile sarmala — catch'e kaç istisna düştü? Sonra Task nesnesinin Exception.InnerExceptions listesini yazdır: kaç tane var? Farkı not et.
  4. İptal: 10 saniyelik bir döngü yaz (her turda await Task.Delay(500, token)), CancellationTokenSource'u 2 saniyeye ayarla. OperationCanceledException'ı ayrı yakala ve "iptal edildi, N tur tamamlandı" yazdır. Sonra token'ı Task.Delay'e geçirmeden tekrar dene: iptal ne zaman etkili oluyor?
  5. async void kanıtı: async void Patla() metodu yaz; await Task.Delay(100) sonrası istisna fırlatsın. Çağrısını try/catch içine al ve Console.ReadLine() ile programı bekletip gör: catch çalışmadı, süreç çöktü. Aynısını async Task imzasıyla tekrarla — bu kez yakalandı. Bu deney tek başına dersin en önemli kuralını kanıtlar.

09Kendini sına

async/await tam olarak ne yapar? "async kodu hızlandırır" doğru mu, async yeni thread açar mı?

async/await, I/O beklerken thread'i serbest bırakmayı sağlar: derleyici metodu bir durum makinesine çevirir, her await noktasını durak yapar, iş bitince metot kaldığı yerden devam eder. "Hızlandırır" yanlıştır — tek bir işlem asenkron olduğu için hızlanmaz, hatta durum makinesi ek yükü kadar yavaşlar; kazanç ölçeklenebilirliktir: aynı donanım daha çok eşzamanlı işi taşır. "Yeni thread açar" da yanlıştır: asenkron I/O beklerken hiçbir thread harcanmaz, iş bitince devam işi havuzdaki bir thread'de koşar. Yeni thread yalnız Task.Run gibi açıkça CPU-bound işi taşıdığında devreye girer — ki o da asenkronluk değil paralelliktir.

.Result / .Wait() deadlock'u nasıl oluşur? ConfigureAwait(false) ne yapar ve ASP.NET Core'da durum nedir?

SynchronizationContext'i olan bir ortamda (WinForms/WPF, eski ASP.NET) UI thread'i .Result ile bloke edilir; içerideki await'in devam işi "bağlama, yani UI thread'ine dön" der; UI thread meşgul olduğu için devam iş çalışamaz, iş bitmediği için .Result serbest kalmaz — dairesel bekleme, kilit. ConfigureAwait(false) devam işinin bağlama dönmesini engeller, havuzda devam ettirir; bu yüzden kütüphane kodunda kuraldır ve deadlock'u kırar (UI kodunda await sonrası arayüze dokunacaksan kullanılmaz). ASP.NET Core'da SynchronizationContext yoktur, dolayısıyla klasik deadlock görülmez; ama sync-over-async yine de her istekte bir havuz thread'i bloke eder ve yük altında thread havuzu açlığına yol açar — belirtisi düşük CPU'ya rağmen tırmanan yanıt süreleridir. Doğru kural her iki ortamda da aynıdır: async all the way.

async void neden kaçınılması gereken bir imzadır, tek istisnası nedir?

async void döndüren metot bir Task üretmez: çağıran işin bitmesini bekleyemez, sonucunu ya da hatasını göremez, testte tamamlanmasını bekleyemez. En tehlikelisi istisna davranışıdır — hata çağıranın try/catch'ine düşmez, doğrudan SynchronizationContext'e/thread havuzuna fırlatılır ve tipik olarak süreci çökertir. Tek meşru kullanımı event handler'lardır, çünkü event imzası void döndürür ve başka seçenek yoktur; orada da kural, handler gövdesini baştan sona try/catch ile sarmalamak ve asıl işi await edilebilir bir async Task metoduna devretmektir. Aynı tuzak, Action tipi bekleyen bir API'ye async lambda vermekte de gizlidir: derleyici orada da async void üretir.

WhenAll ile sırayla await arasındaki fark nedir? WhenAll'da hatalar nasıl toplanır, CancellationToken nasıl çalışır?

Sırayla await toplam süreyi toplar; WhenAll bağımsız işleri birlikte bekler ve süreyi en yavaş işe indirir (async metotlar çağrıldığı anda başladığı için, task'ları önce başlatıp sonra beklemek de aynı sonucu verir). WhenAll'un döndürdüğü task await edildiğinde yalnız ilk istisna fırlatılır; tüm hatalar Task.Exception içindeki AggregateException'ın InnerExceptions koleksiyonundadır. Paralellik ancak işler gerçekten bağımsızsa doğrudur — DbContext gibi thread-safe olmayan nesnelerde yasaktır — ve sınırsız paralellik karşı tarafı devirir, eşzamanlılık sınırlanır. İptal işbirlikçidir: kimse işi zorla durdurmaz; CancellationTokenSource bir istek yayınlar, kod ThrowIfCancellationRequested ile kontrol eder ve token'ı alt çağrılara geçirir — geçirilmezse iptal etkisizdir. OperationCanceledException hata değil normal akıştır; ASP.NET Core action'ında token parametresi RequestAborted'a bağlanır.

10Sınav köşesi

Yazılıda nasıl sorulur

(1) Tanım/ayrım: "senkron ile asenkron farkı", "Task ile Thread farkı", "async yeni thread açar mı?" (hayır). (2) Doğru/yanlış: "async metot kodu hızlandırır" (yanlış — ölçeklenebilirlik). (3) Kod okuma: sırayla üç await ile WhenAll sürümünün toplam süresini karşılaştırma; sıcak task sorusu ("await etmeden çağırırsam iş başlar mı?" — evet). (4) Tuzak sorusu: .Result içeren kod parçası verilip "bu kod neden kilitlenir?" (5) İmza sorusu: async void nerede kullanılır (yalnız event handler), istisnası nereye gider. (6) Kavram: CancellationToken işbirlikçi iptal; OperationCanceledException hata mı (değil). (7) Çapraz: AggregateException hangi durumda görülür — Hat 03'ün 4. dersiyle birlikte sorulur.

Sözlüde nasıl anlatılır — 2 dakikalık taslak

"async/await'i anlat" için düzen: (1) Çekirdek — "amaç hız değil, beklerken thread tutmamak; I/O beklerken thread serbest kalır, aynı sunucu kat kat fazla isteği taşır." (2) Mekanizma — "derleyici metodu durum makinesine çevirir, await'ler durak olur; iş bitince devam işi çalışır — nerede çalışacağını SynchronizationContext belirler, ASP.NET Core'da bağlam yoktur." (3) Pratik katman — "bağımsız işleri WhenAll ile birlikte beklerim, token'ı zincir boyunca geçiririm, kütüphane kodunda ConfigureAwait(false) kullanırım." (4) Kıdem vuruşu — ".Result ve .Wait() kullanmam: UI'da deadlock, ASP.NET'te thread havuzu açlığı üretir — belirtisi düşük CPU'ya rağmen tırmanan yanıt süreleridir; async void'i yalnız event handler'da, o da try/catch ile kullanırım." Bu son iki cümle, async'i ezberden bilenle üretimde kullananı ayıran cevaptır.