C# Ustası — Bölüm 3: async/await'in İç Mekaniği — State Machine, ValueTask, ConfigureAwait
async/await'in derleyici tarafindan uretilen state machine'i, AsyncMethodBuilder pattern'i, Task vs ValueTask farki, ConfigureAwait'in ne zaman onemli oldugu, IAsyncEnumerable, deadlock kaynaklari ve sync-over-async anti-pattern'i. C# async dunyasinin arkasindaki mekanizma adim adim.
Merhaba arkadaşlar, C# Ustası serisinin üçüncü bölümünde, her gün kullandığımız ama çok azımızın gerçekten nasıl çalıştığını bildiği bir konuya dalıyoruz — async/await'in iç mekaniği. Bu iki küçük anahtar kelime derleyici tarafından devasa bir state machine'e çevriliyor, ValueTask ile Task'ın ne zaman doğru seçim olduğu dev projelerde bile yanlış biliniyor, ve ConfigureAwait(false) konusunda internette birbirine zıt yüzlerce yazı var. Başlamadan önce bir kahve alın, çünkü bu sefer kendimizi derleyici seviyesinde düşünmeye zorlayacağız.
async/await Neden İcat Edildi?
Bir I/O işlemi (disk, network, DB) başlattığınızda thread'iniz genelde bekler. Sorun: thread'ler ucuz değil. Bir thread yaklaşık 1 MB stack kaplar, context switch maliyeti vardır. Saniyede 10 bin istekte her biri bir thread bloklarsa, 10 GB RAM ve sürekli context switch — ölümcül.
Çözüm: I/O beklerken thread'i serbest bırakmak. Ama klasik callback yaklaşımı ("callback hell") okunamaz kodu üretiyordu. async/await bu problemi çözer: kod senkronmuş gibi yazılır, derleyici arka planda callback tabanlı state machine'e çevirir. Hem okunabilir, hem verimli.
Derleyici Dönüşümü — async Method Ne Olur?
Basit bir async method yazalım:
public async Task<int> YuklemeAsync(HttpClient client)
{
var response = await client.GetAsync("https://api.semgoksu.com/data");
var text = await response.Content.ReadAsStringAsync();
return text.Length;
}
Derleyici bunu yaklaşık olarak şöyle dönüştürür (basitleştirilmiş):
public Task<int> YuklemeAsync(HttpClient client)
{
var sm = new <YuklemeAsync>d__0();
sm._client = client;
sm._builder = AsyncTaskMethodBuilder<int>.Create();
sm._state = -1;
sm._builder.Start(ref sm);
return sm._builder.Task;
}
private struct <YuklemeAsync>d__0 : IAsyncStateMachine
{
public int _state;
public AsyncTaskMethodBuilder<int> _builder;
public HttpClient _client;
private TaskAwaiter<HttpResponseMessage> _awaiter1;
private TaskAwaiter<string> _awaiter2;
private HttpResponseMessage _response;
private string _text;
public void MoveNext()
{
int state = _state;
try
{
if (state == 0) goto RESUME1;
if (state == 1) goto RESUME2;
// ilk await
_awaiter1 = _client.GetAsync("...").GetAwaiter();
if (!_awaiter1.IsCompleted)
{
_state = 0;
_builder.AwaitUnsafeOnCompleted(ref _awaiter1, ref this);
return;
}
RESUME1:
_response = _awaiter1.GetResult();
// ikinci await
_awaiter2 = _response.Content.ReadAsStringAsync().GetAwaiter();
if (!_awaiter2.IsCompleted)
{
_state = 1;
_builder.AwaitUnsafeOnCompleted(ref _awaiter2, ref this);
return;
}
RESUME2:
_text = _awaiter2.GetResult();
_builder.SetResult(_text.Length);
}
catch (Exception ex)
{
_builder.SetException(ex);
}
}
}
Okunması zor gelebilir, ama öz şu: her await noktası state machine'de bir "resume point"e dönüşür. Method ilk çağrıldığında Start çağrılır, MoveNext bir kez koşar, ilk await'e gelince IsCompleted kontrol edilir. Task zaten bitmişse senkron devam, bitmemişse AwaitUnsafeOnCompleted ile callback kaydı yapılır ve method geri döner (stack'ten pop olur!). Task bitince callback MoveNext'i çağırır, bu kez diğer state'ten devam eder.
AsyncMethodBuilder Pattern'i
Yukarıdaki kodda AsyncTaskMethodBuilder'ı görüyoruz. Bu aslında bir pattern — .NET 7'den beri kendi builder'ınızı yazıp Task yerine custom bir tip (örn. kendi telemetry wrapper'ınız) döndürebiliyorsunuz:
[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]
public struct MyTask
{
// builder bu tipi nasıl inşa edeceğini biliyor
}
Bu sayede IAsyncEnumerable, ValueTask, hatta UniTask (Unity için) gibi özel tipler dilin ayrıcalığı olmaktan çıktı, herkes yazabilir hale geldi.
Task vs ValueTask — Hangisi Ne Zaman?
Task bir class (reference type), her çağrıda heap'te bir obje oluşur. ValueTask ise struct; ama sihirli değil — içinde ya T değeri, ya Task<T>, ya da IValueTaskSource<T> tutar.
ValueTask ne zaman kazandırır:
- Method çoğu zaman senkron dönebiliyorsa (örn. cache hit): heap allocation yok.
- Yüksek trafikli hot path: saniyede milyonlarca çağrıda fark görünür olur.
ValueTask ne zaman zarar verir veya nötrdür:
- Method her zaman gerçek async yapıyorsa: Task zaten optimize ediliyor (TaskAwaiterCache, vs.). ValueTask ekstra yük.
- Çift await yaparsanız: ValueTask sadece tek await'e izin verir. İki kez await etmek undefined behavior.
- WhenAll/WhenAny'ye sokuyorsanız: Bu API'ler Task array ister. ValueTask'ı AsTask()'a çevirmek eski allocation'ınızı geri getirir.
Pratik kural: Önce Task yazın. Profile edip o method'un gerçekten hot path olduğunu ve çoğu zaman senkron döndüğünü kanıtlayınca ValueTask'a geçin. Prematür değişiklik olmasın.
Gerçek Hayat Örneği — Caching'li Repository
public ValueTask<User?> GetUserAsync(int id)
{
if (_cache.TryGetValue(id, out var user))
return new ValueTask<User?>(user); // senkron yol — allocation YOK
return new ValueTask<User?>(GetUserSlowAsync(id));
}
private async Task<User?> GetUserSlowAsync(int id)
{
var u = await _db.Users.FindAsync(id);
if (u is not null) _cache.Set(id, u);
return u;
}
Bu pattern çok yaygın ve ValueTask'ın parladığı noktadır — %95 cache hit oluyorsa bu method'un ezici çoğunluğu heap allocation yapmaz.
ConfigureAwait(false) — Hâlâ Önemli mi?
Kısa cevap: kütüphane kodunda evet, uygulama kodunda genelde hayır.
Uzun cevap: await edildiğinde, varsayılan olarak SynchronizationContext.Current yakalanır ve devam (resume) o context'te yapılır. WinForms'ta UI thread, eski ASP.NET'te request context. Modern ASP.NET Core'da SynchronizationContext yok — bu yüzden Core projelerinde ConfigureAwait(false) kozmetik.
Ama kütüphane yazıyorsanız, tüketicinizin hangi ortamda (WinForms, WPF, eski ASP.NET) kullanacağını bilmezsiniz. Deadlock'u önlemek için her await'e .ConfigureAwait(false) ekleyin:
// Kütüphane metodunuz
public async Task<string> FetchAsync()
{
var resp = await _client.GetAsync(_url).ConfigureAwait(false);
var body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
return body;
}
Nuget paketi yazıyorsanız bu disiplin tam değildir. Eski bir WinForms uygulaması sizin paketinizi kullanınca deadlock'tan kurtulur.
Deadlock Hikayesi — sync-over-async
Klasik tuzak:
// UI thread'den çağrılıyor (WinForms, WPF, eski ASP.NET)
var result = FetchAsync().Result; // veya .GetAwaiter().GetResult()
Ne olur? UI thread Result ile bloklar. FetchAsync await'e gelip devamı UI thread'e kuyruğa koyar. Ama UI thread bloklu — devam edemez. Sonsuz bekleme. Deadlock.
Çözüm: async'in en üstten aşağı olması. async Task Main, async IActionResult, her seviyeden await'le iniş. .Result ve .GetAwaiter().GetResult()'u unutun. Sadece gerçek senkron bir giriş noktasında (örn. Main'de bile artık async Task Main var) son çare olarak.
Visual Studio tarafında bir async deadlock'u avlarken en faydalı araç Debug menüsünden Windows > Parallel Stacks (Ctrl+Shift+D, S). Uygulama bir await'te duruyorsa normal Call Stack size sadece şu an yürüyen frame'i gösterir; Parallel Stacks ise askıda bekleyen tüm Task'ları, hangi continuation'a bağlı olduklarını, hangi thread'in hangisini çalıştırdığını tek ekranda çıkarır. Graph görünümünde ok başları continuation zincirini gösterir. Deadlock şüphesi varsa önce buraya bakarım — iki Task'ın birbirini beklediği kısır döngü anında gözükür. Yan pencereden Debug > Windows > Tasks'ı da açın; orada tüm active Task'lar ID'leri ile listelenir, her satırda "Status" sütununda "Awaiting / Running / Blocked" durumlarını canlı görürsünüz.
IAsyncEnumerable ve await foreach
.NET Core 3.0 ile gelen IAsyncEnumerable<T>, asenkron stream'ler için. Dosya satır satır okuyun, DB cursor'dan veri alın, gRPC stream dinleyin — hepsi aynı pattern:
public async IAsyncEnumerable<string> OkuDosyaAsync(string yol,
[EnumeratorCancellation] CancellationToken ct = default)
{
using var reader = new StreamReader(yol);
while (!reader.EndOfStream)
{
ct.ThrowIfCancellationRequested();
var line = await reader.ReadLineAsync(ct);
if (line is not null) yield return line;
}
}
await foreach (var satir in OkuDosyaAsync("buyuk.txt").WithCancellation(cts.Token))
{
Console.WriteLine(satir);
}
Dikkat: [EnumeratorCancellation] olmadan WithCancellation çalışmaz — bir süre önce sinirli sinirli debugged ettiğim bir konu.
TaskScheduler ve SynchronizationContext
İki farklı kavram, sık karışıyor:
- TaskScheduler: Task'ların nerede çalışacağını karar verir. TaskScheduler.Default thread pool'u kullanır. FromCurrentSynchronizationContext() mevcut sync context'e postalar.
- SynchronizationContext: "Şu delegate'i şu ortamda çalıştır" diyen bir sınıf. UI framework'leri bunu kurar.
Sen genelde ikisine de direkt dokunmazsın. Ama library yazarken biliyor olman gerekiyor — özellikle Task.Run ile thread pool'a sıçrattığın kodun context'i kaybedebileceğini unutma.
Visual Studio tarafında async debug ayarları önemlidir: Tools > Options > Debugging > General altında "Enable Just My Code" varsayılan açıktır. Bunu açık bıraktığınızda Roslyn'in ürettiği state machine iç frame'leri Call Stack'te gizlenir, sadece sizin kodunuzu görürsünüz — ilk bakışta rahat. Ama iç mekaniği öğrenmek veya üçüncü parti kütüphanede deadlock avlamak için bu seçeneği kapatın ve "Show external code" tercihini açın; o zaman MoveNext, AsyncMethodBuilder, AwaitUnsafeOnCompleted frame'leri tam açık gelir. Debug sırasında Locals penceresinde state machine struct'ın field'larını (örn. <>1__state) gözetleyerek async metodunuzun hangi aşamada olduğunu sayısal olarak takip edebilirsiniz.
Tuzaklar ve Anti-Pattern'ler
- async void: Event handler dışında asla. Exception yakalanamaz, çağırılan tarafı await edemez. Yerine async Task.
- Task.Run'u sarma: API'de her async method'u Task.Run'a sarmak yaygın hata. Context switch maliyeti ekler, performansı bozar.
- ConfigureAwait'i uygulamada saplantı haline getirmek: ASP.NET Core'da kozmetik. Önce ölçün.
- ValueTask'ı çift await etmek: Tek kullanımlıktır. Sakla, sonra kullan değil.
- async'in yarıda kesilmesi: var t = DoAsync(); /* ... */ await t; — Task unu bekleme unutulursa UnobservedTaskException ile karşılaşırsınız.
- CancellationToken'ı pas atmamak: Uzun süren her async method CancellationToken almalı, alt çağrılara geçirmeli.
Ne Zaman async Kullanmamalıyız?
- Pure CPU-bound iş: Prime number hesabı için async yazmak anlamsız. Task.Run kullanın.
- Çok kısa method'lar: State machine kurma maliyeti, kazanacağınız asynchrony'den fazla olabilir.
- Deterministik senkron API zorunluluğu: Bazı framework eski senkron extension point'ler ister.
Özet
async/await'in altındaki state machine'i gördükten sonra kod yazarken karar vermek farklı bir perspektife oturuyor. ValueTask bir araç — her yere değil. ConfigureAwait bir disiplin — kütüphane yazarken. IAsyncEnumerable modern stream'in cevabı. Ve deadlock'un tek çaresi sync-over-async'i tamamen unutmak.
Bir sonraki bölümde pattern matching'in son sürümüne dalacağız — list patterns, relational patterns, recursive patterns, switch expressions'ın exhaustiveness'ı. Parser örneği yapacağız, pattern matching'in derleme çıktısını okuyacağız. Görüşürüz!