C# async/await Nasıl Çalışır: State Machine ve Continuation
await her zaman beklemez: bitmiş bir task'ta kod aynı thread'de devam eder. async kelimesinin aslında ne yaptığı, GetAwaiter/IsCompleted/OnCompleted adımları, elle yazılmış çalışan bir state machine, reflection ile derleyicinin ürettiği tipe bakmak (Debug'da class, Release'de struct), continuation'ın hangi thread'de çalıştığı ve awaitable pattern.
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
Katalog yükleyen bir method iki kez await ediyor: önce zaten bitmiş bir task'ı, sonra 100 ms süren bir gecikmeyi. Her satır, o an hangi thread'de çalıştığını yazıyor. Program 1 numaralı main thread'de başlıyor. Beş satırdan hangileri 1 yazar, hangileri başka bir numara?
Console.WriteLine($"Main before: thread {Environment.CurrentManagedThreadId}");
await LoadCatalogAsync();
Console.WriteLine($"Main after: thread {Environment.CurrentManagedThreadId}");
static async Task LoadCatalogAsync()
{
Console.WriteLine($"Start: thread {Environment.CurrentManagedThreadId}");
await Task.CompletedTask;
Console.WriteLine($"After completed: thread {Environment.CurrentManagedThreadId}");
await Task.Delay(100);
Console.WriteLine($"After delay: thread {Environment.CurrentManagedThreadId}");
}
Cevap: await Her Zaman Beklemez
Main before: thread 1
Start: thread 1
After completed: thread 1
After delay: thread 5
Main after: thread 5
İlk üç satır 1 numaralı thread'de. Method çağrıldığında gövdesi çağıranın thread'inde başlar; bunu bir önceki yazıda gördük. Yeni olan üçüncü satır: await Task.CompletedTask hiçbir şeyi beklemedi. Task zaten bittiği için kod, aynı thread'de, kesintisiz devam etti. Ancak Task.Delay(100)'e gelince method gerçekten durdu, thread 1 serbest kaldı ve 100 ms sonra devam eden kod bir pool thread'inde koştu. Ana kod da LoadCatalogAsync'in task'ını beklediği için, o bittiğinde aynı thread'de devam etti. Numara senin makinende 5 olmayabilir, ama 1 olmayacaktır.
await "dur ve bekle" demek değil. Daha çok "bu iş bitti mi? Bittiyse devam et; bitmediyse geri kalanımı sonraya not et ve şimdilik çık" demek. Bu yazıda o notun nasıl alındığına, yani derleyicinin senin method'unu neye çevirdiğine bakacağız.
async Kelimesi Ne Yapar
Şaşırtıcı ama async kelimesi runtime'da hiçbir şey yapmaz. Yeni thread açmaz, method'u arka plana atmaz. Yalnızca iki şeyi değiştirir: method içinde await kullanmana izin verir ve derleyiciye "bu method'un gövdesini bir state machine'e çevir, dönüş değerini de Task'a sar" der.
Console.WriteLine($"Caller: thread {Environment.CurrentManagedThreadId}");
Task<int> countTask = CountBooksAsync();
Console.WriteLine($"Completed before await: {countTask.IsCompleted}");
Console.WriteLine($"Count: {await countTask}");
static async Task<int> CountBooksAsync()
{
Console.WriteLine($"Method: thread {Environment.CurrentManagedThreadId}");
return 3;
}
Caller: thread 1
Method: thread 1
Completed before await: True
Count: 3
Method async olduğu hâlde baştan sona çağıranın thread'inde, senkron çalıştı ve elimize zaten tamamlanmış bir task verdi. İçinde hiç await olmayan bir async method budur: ek maliyetli, sıradan bir senkron method. .NET 8 SDK'sının compiler'ı bu kod için CS1998 uyarısı verir ("This async method lacks 'await' operators and will run synchronously"); denediğim .NET 10 SDK'sı (10.0.402) ise bu uyarıyı vermedi. Uyarı gelse de gelmese de kural aynı: içinde await olmayan bir method'u async yapma; sonucu hazırsa Task.FromResult döndür.
await'in Arkasındaki Üç Soru
Derleyici bir await gördüğünde onu kabaca üç adıma çevirir. Aynı adımları elle yazabiliriz:
using System.Globalization;
using System.Runtime.CompilerServices;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
TaskCompletionSource done = new TaskCompletionSource();
Task<decimal> priceTask = GetPriceAsync();
TaskAwaiter<decimal> awaiter = priceTask.GetAwaiter(); // 1. awaiter'ı al
if (awaiter.IsCompleted) // 2. bitti mi?
{
Console.WriteLine($"Already done: {awaiter.GetResult()}");
done.SetResult();
}
else
{
awaiter.OnCompleted(() => // 3. bitince bunu çalıştır
{
Console.WriteLine($"Continuation on thread {Environment.CurrentManagedThreadId}: {awaiter.GetResult()}");
done.SetResult();
});
Console.WriteLine($"Not done yet, returning from thread {Environment.CurrentManagedThreadId}");
}
await done.Task;
static async Task<decimal> GetPriceAsync()
{
await Task.Delay(200);
return 12.99m;
}
Not done yet, returning from thread 1
Continuation on thread 5: 12.99
Önce GetAwaiter() ile bir awaiter alınır. Sonra IsCompleted sorulur; task bitmişse GetResult() ile sonuç hemen alınır ve kod aynı thread'de devam eder. Bitmemişse OnCompleted ile "bitince bunu çalıştır" denir ve method geri döner. OnCompleted'e verilen kod parçasının adı continuation: await'ten sonraki kodun tamamı. Son olarak GetResult() sonucu verir ya da task hata ile bittiyse exception'ı fırlatır.
Elle yazdığımız versiyonda continuation'ı bir lambda olarak verdik. Ama bir method'da beş tane await varsa iç içe beş lambda yazmak, APM'in callback cehennemine geri dönmek olurdu. Derleyici bunun yerine daha akıllı bir şey yapar.
Derleyicinin Yazdığı State Machine
Derleyici async method'unu bir state machine'e çevirir: method'un local değişkenlerini field olarak tutan bir tip ve MoveNext() adlı tek bir method. Her await bir durak noktasıdır; state field'ı makinenin hangi durakta olduğunu hatırlar. Continuation olarak ayrı ayrı lambda'lar değil, her seferinde aynı MoveNext() çağrılır ve o da state'e bakıp kaldığı yerden devam eder.
Şu method'u ele alalım:
static async Task<decimal> GetTotalAsync(string isbn, int quantity)
{
Console.WriteLine("Fetching price");
decimal price = await GetPriceAsync(isbn);
Console.WriteLine("Price arrived");
return price * quantity;
}
Derleyicinin ürettiğine benzeyen, sadeleştirilmiş ama gerçekten çalışan bir versiyonu elle yazalım:
using System.Globalization;
using System.Runtime.CompilerServices;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
decimal total = await OrderService.GetTotalManual("978-0441172719", 3);
Console.WriteLine($"Total: {total}");
static class OrderService
{
public static Task<decimal> GetTotalManual(string isbn, int quantity)
{
GetTotalStateMachine machine = new GetTotalStateMachine();
machine.Builder = AsyncTaskMethodBuilder<decimal>.Create();
machine.Isbn = isbn;
machine.Quantity = quantity;
machine.State = -1;
machine.Builder.Start(ref machine); // ilk MoveNext çağrıyı yapanın thread'inde
return machine.Builder.Task;
}
public static async Task<decimal> GetPriceAsync(string isbn)
{
await Task.Delay(200);
return 12.99m;
}
}
struct GetTotalStateMachine : IAsyncStateMachine
{
public int State;
public AsyncTaskMethodBuilder<decimal> Builder;
public string Isbn;
public int Quantity;
private TaskAwaiter<decimal> awaiter;
public void MoveNext()
{
decimal result;
try
{
TaskAwaiter<decimal> current;
if (State != 0)
{
// ilk giriş: await'e kadar olan kod
Console.WriteLine($"Fetching price on thread {Environment.CurrentManagedThreadId}");
current = OrderService.GetPriceAsync(Isbn).GetAwaiter();
if (!current.IsCompleted)
{
State = 0;
awaiter = current;
Builder.AwaitUnsafeOnCompleted(ref current, ref this); // bitince MoveNext'i yeniden çağır
return;
}
}
else
{
// ikinci giriş: task bitti, kaldığımız yerden devam
current = awaiter;
awaiter = default;
State = -1;
}
decimal price = current.GetResult();
Console.WriteLine($"Price arrived on thread {Environment.CurrentManagedThreadId}");
result = price * Quantity;
}
catch (Exception ex)
{
State = -2;
Builder.SetException(ex); // exception task'ın içine konur
return;
}
State = -2;
Builder.SetResult(result);
}
public void SetStateMachine(IAsyncStateMachine stateMachine) => Builder.SetStateMachine(stateMachine);
}
Fetching price on thread 1
Price arrived on thread 5
Total: 38.97
Akışı adım adım izle. GetTotalManual makineyi kurar, parametreleri field'lara kopyalar ve Builder.Start ile ilk MoveNext()'i çağırır. Bu çağrı çağıranın thread'inde olur; async method'un ilk kısmının neden senkron çalıştığının cevabı bu. MoveNext() await'e kadar olan kodu çalıştırır, task'ın bitmediğini görür, state'i 0 yapar, awaiter'ı saklar ve builder'a "bu bitince beni yeniden çağır" der. Sonra döner. GetTotalManual da builder'ın task'ını, henüz bitmemiş hâliyle, çağırana verir.
200 ms sonra fiyat task'ı bitince builder MoveNext()'i bir pool thread'inde yeniden çağırır. State 0 olduğu için kod bu sefer else branch'ine girer, saklanan awaiter'dan sonucu alır, kalan işi yapar ve SetResult ile dışarıya verilen task'ı tamamlar. Hata olursa catch onu SetException ile task'ın içine koyar; exception'ın neden çağırana "fırlamadığını", task'ta beklediğini bir sonraki yazıda bu satırdan yola çıkarak konuşacağız.

Gerçek State Machine'e Reflection ile Bakmak
Elle yazdığımız tip bir benzetme. Derleyicinin gerçekte ne ürettiğini reflection ile görebiliriz:
using System.Globalization;
using System.Reflection;
using System.Runtime.CompilerServices;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Type machine = typeof(OrderService)
.GetNestedTypes(BindingFlags.NonPublic)
.Single(t => typeof(IAsyncStateMachine).IsAssignableFrom(t) && t.Name.Contains("GetTotalAsync"));
Console.WriteLine($"{machine.Name}, value type: {machine.IsValueType}");
foreach (FieldInfo field in machine.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
{
Console.WriteLine($" {field.FieldType.Name,-26} {field.Name}");
}
Console.WriteLine(await OrderService.GetTotalAsync("978-0441172719", 3));
static class OrderService
{
public static async Task<decimal> GetTotalAsync(string isbn, int quantity)
{
decimal price = await GetPriceAsync(isbn);
return price * quantity;
}
private static async Task<decimal> GetPriceAsync(string isbn)
{
await Task.Delay(200);
return 12.99m;
}
}
<GetTotalAsync>d__0, value type: False
Int32 <>1__state
AsyncTaskMethodBuilder`1 <>t__builder
String isbn
Int32 quantity
Decimal <price>5__1
Decimal <>s__2
TaskAwaiter`1 <>u__1
38.97
Tanıdık geldi mi? <>1__state bizim State'imiz, <>t__builder builder, <>u__1 saklanan awaiter. Parametreler isbn ve quantity field olmuş; local değişken price da öyle; <>s__2 ise await'in sonucunu price'a atamadan önce geçici olarak tutan yardımcı bir field. Method durup başka bir thread'de devam edebildiği için, local değişkenler stack'te yaşayamaz; makinenin field'larına taşınırlar. İsimlerdeki açılı parantezler C#'ta geçerli bir isim olmadığı için seçilmiş; kodunla çakışmaları imkânsız.
Bu çıktı Debug derlemeden. Aynı projeyi dotnet build -c Release ile derleyip çalıştırınca tablo değişiyor:
<GetTotalAsync>d__0, value type: True
Int32 <>1__state
AsyncTaskMethodBuilder`1 <>t__builder
String isbn
Int32 quantity
TaskAwaiter`1 <>u__1
38.97
Release'de makine bir struct ve price artık field değil. Struct olması şu anlama gelir: method hiç gerçekten beklemeden biterse makine heap'e hiç çıkmaz; yalnızca ilk gerçek beklemede builder onu heap'e kopyalar. price ise await'ten sonra atanıp hemen kullanıldığı için durak noktasını aşmıyor; optimize eden compiler onu sıradan bir local olarak bırakmış. Debug derleme, debugger'ın her değişkeni görebilmesi için bütün local'leri field yapar.
Continuation Hangi Thread'de Çalışır
Builder bir continuation'ı kaydederken o anki SynchronizationContext'e bakar. Bir context varsa continuation'ı ona teslim eder; yoksa thread pool'a. Console uygulamasında context yoktur:
Console.WriteLine($"Context: {SynchronizationContext.Current?.GetType().Name ?? "none"}");
await Task.Delay(100);
Console.WriteLine($"After await: thread {Environment.CurrentManagedThreadId}, pool thread: {Thread.CurrentThread.IsThreadPoolThread}");
Context: none
After await: thread 5, pool thread: True
Bu yüzden bütün örneklerimizde await'ten sonra bir pool thread'inde devam ettik. WinForms ve WPF'de UI thread'inin bir context'i vardır ve continuation'ı UI thread'ine geri yollar; await'ten sonra bir label'ı güvenle güncelleyebilmen bu sayede mümkün. ASP.NET Core'da ise, console'daki gibi, context yoktur. Bu context'in deadlock'a nasıl yol açtığını ve ConfigureAwait(false)'un ne işe yaradığını serinin deadlock yazısında konuşacağız.
await Edilebilen Her Şey
await yalnızca Task'a özel değil. Derleyici bir pattern arar: üzerinde GetAwaiter() method'u olan bir tip; dönen awaiter'da IsCompleted, GetResult() ve INotifyCompletion interface'inden gelen OnCompleted. Bu pattern'e uyan her şey await edilebilir; GetAwaiter bir extension method bile olabilir:
using System.Diagnostics;
using System.Runtime.CompilerServices;
Stopwatch watch = Stopwatch.StartNew();
await TimeSpan.FromMilliseconds(300);
Console.WriteLine($"Waited {watch.ElapsedMilliseconds} ms");
static class TimeSpanAwaiterExtensions
{
public static TaskAwaiter GetAwaiter(this TimeSpan delay) => Task.Delay(delay).GetAwaiter();
}
Waited 303 ms
Bunu production'da yazmanı önermem, ama mekanizmayı iyi gösterir: await bir dil özelliği, Task ise bu özelliği kullanan en yaygın tip. Serinin ilerisinde göreceğimiz ValueTask, ConfigureAwait'in döndürdüğü tip ve Task.Yield() da aynı pattern'le çalışır.
Sık Düşülen Tuzaklar
İlk await'ten önce ağır iş yapmak. Async method'un ilk await'e kadar olan kısmı çağıranın thread'inde senkron çalışır. O kısımda bir saniyelik hesap varsa, çağıran bir saniye bloklanır; method'un async olması bunu değiştirmez.
Her await'in thread değiştirdiğini sanmak. Task zaten bitmişse kod aynı thread'de devam eder, ilk örnekte Task.CompletedTask ile gördüğümüz gibi. Thread'e bağlı varsayımlar (thread'e özel değişkenler, thread kimliğine göre karar) async kodda güvenilmezdir.
State machine'in sınırlarını unutmak. Local değişkenler field'a taşındığı için bazı yapılar async method'da yasaktır. ref, out ve in parametreler kullanılamaz; lock bloğunun içinde await yazılamaz:
object stockLock = new object();
int stock = 5;
await ReserveAsync(ref stock);
async Task ReserveAsync(ref int available)
{
lock (stockLock)
{
await Task.Delay(10);
available--;
}
}
Program.cs(10,9): error CS1996: Cannot await in the body of a lock statement
Program.cs(6,33): error CS1988: Async methods cannot have ref, in or out parameters
lock yasağının sebebi derin: lock, lock'u alan thread'e bağlıdır, await'ten sonra ise başka bir thread'de devam edebilirsin ve lock'u alan thread'den başka bir thread onu bırakamaz. Async kodda lock gerekiyorsa SemaphoreSlim'in WaitAsync'ini kullanırsın; senkronizasyon yazısında göreceğiz.
Küçük Bir Deneme
★☆☆ Yazının başındaki programa await Task.Yield(); satırını await Task.CompletedTask;'ın hemen altına ekle. "After completed" satırının thread'i değişti mi? Task.Yield'in ne işe yaradığını çıktıdan çıkar.
★★☆ Elle yazdığımız state machine'de GetPriceAsync'in içine throw new InvalidOperationException("Price service is down"); ekle (await'ten önce). Programın başındaki await satırını try/catch içine alıp exception'ı yakala ve hangi satırın onu task'a koyduğunu bul.
★★★ Reflection örneğine ikinci bir await ekle: fiyattan sonra int stock = await GetStockAsync(isbn);. Debug ve Release çıktısında kaç tane awaiter field'ı görüyorsun? İki await farklı tipte task'ları beklerse ne değişiyor?
Await'in bir sihir değil, derleyicinin yazdığı bir state machine olduğunu gördük. O makinede catch bloğunun exception'ı nasıl task'ın içine koyduğuna da göz attık. Bir sonraki yazıda tam oradan devam ediyoruz: async kodda exception'lar nereye gider, AggregateException neden var ve Task.WhenAll birden fazla hatayı nasıl taşır.