C# Task ve Task<T>: Sıcak Başlayan Bir Söz
Async method çağrıldığı anda çalışmaya başlar; await bir işi başlatmaz, sonucunu bekler. Task'ın yaşam döngüsü ve Status değerleri, Task.FromResult ve TaskCompletionSource ile task üretmek, aynı task'ı iki kez await etmek, işleri önce başlatıp sonra beklemek ve Task.Factory.StartNew'un Task<Task> 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ş ekranı açılınca sepet toplamını async bir method hesaplıyor. Ekrana beş satır yazılıyor; ikisi ana koddan, üçü method'un içinden. Bu beş satır hangi sırayla çıkar? Özellikle "Calculation started" satırına dikkat et: method çağrıldığı anda mı çalışmaya başlar, yoksa await satırına gelince mi?
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Console.WriteLine("Order screen opened");
Task<decimal> totalTask = CalculateTotalAsync();
Console.WriteLine("Waiting for the total");
decimal total = await totalTask;
Console.WriteLine($"Total: {total}");
static async Task<decimal> CalculateTotalAsync()
{
Console.WriteLine("Calculation started");
await Task.Delay(300); // fiyat servisini bekliyormuş gibi
Console.WriteLine("Calculation finished");
return 37.49m;
}
Cevap: Task Sıcak Başlar
Order screen opened
Calculation started
Waiting for the total
Calculation finished
Total: 37.49
"Calculation started", "Waiting for the total"dan önce geldi. Yani CalculateTotalAsync() çağrıldığı anda çalışmaya başladı; await satırını beklemedi. Async bir method çağrıldığında gövdesi, normal bir method gibi, çağıranın thread'inde çalışmaya başlar. İlk await'e ve henüz bitmemiş bir işe gelince durur, çağırana elinde bir Task<decimal> ile döner. Ana kod yoluna devam eder, "Waiting for the total" yazar ve sonra kendisi await eder. 300 ms dolunca method kaldığı yerden devam eder, sonucu döndürür, ana kod da toplamı yazar.
Bunun adı "hot task": .NET'te async method'ların ve Task.Run'ın döndürdüğü task'lar elinize geçtiğinde zaten başlamıştır. await bir işi başlatmaz; başlamış bir işin sonucunu bekler. Bu ayrım küçük görünür ama, yazının ilerisinde göreceğimiz gibi, iki işi aynı anda yürütmenin anahtarıdır.
Task Bir Sözdür, Thread Değil
Kalabalık bir kafede sipariş verdiğinde kasiyer sana titreşimli bir cihaz verir. Cihaz kahve değildir; "kahven hazırlanıyor, hazır olunca haber vereceğiz" sözüdür. Elinde cihazla masana oturabilir, telefonuna bakabilir, başka bir iş yapabilirsin. Cihaz titrediğinde gidip kahveni alırsın. Ya da kahve makinesi bozulmuştur; cihaz yine titrer, ama tezgâhta kahve değil bir özür seni bekler.
Task<T> o cihazdır. "İleride bir T vereceğim ya da neden veremediğimi söyleyeceğim" sözünü temsil eden bir object'tir. Task (generic olmayan hâli) sonuç taşımaz, sadece "iş bitti" ya da "iş başarısız oldu" haberini verir; dönüş değeri olmayan void method'ların async karşılığıdır. İkisinde de söz bir kez tutulur ve bir daha değişmez: biten bir task sonsuza kadar bitmiş kalır.
Bir önceki yazıdan hatırla: task ile thread aynı şey değildir. Task.Run'ın task'ı bir pool thread'inde çalışan bir işi temsil eder; Task.Delay'in task'ı ise arkasında hiçbir thread olmayan bir timer'ı. İkisi de aynı tiptir, çünkü task'ın vaat ettiği şey "bir thread'in çalışması" değil, "bir sonucun gelmesi"dir.
Task'ın Yaşam Döngüsü
Her task'ın bir Status property'si var. Bir task'ı farklı yollarla oluşturup durumuna bakalım:
Task cold = new Task(() => Console.WriteLine("never runs"));
Console.WriteLine($"new Task: {cold.Status}");
Task<int> delayed = DelayedStockAsync();
Console.WriteLine($"async method: {delayed.Status}");
int stock = await delayed;
Console.WriteLine($"after await: {delayed.Status}, result {stock}");
Task failed = Task.Run(() => throw new InvalidOperationException("Out of stock"));
try
{
await failed;
}
catch (InvalidOperationException)
{
}
Console.WriteLine($"thrown: {failed.Status}, IsFaulted {failed.IsFaulted}");
Task canceled = Task.FromCanceled(new CancellationToken(canceled: true));
Console.WriteLine($"canceled: {canceled.Status}, IsCanceled {canceled.IsCanceled}");
static async Task<int> DelayedStockAsync()
{
await Task.Delay(200);
return 5;
}
new Task: Created
async method: WaitingForActivation
after await: RanToCompletion, result 5
thrown: Faulted, IsFaulted True
canceled: Canceled, IsCanceled True
Bir task'ın hikâyesi üç sondan biriyle biter: RanToCompletion (başarıyla bitti), Faulted (exception fırlattı) ya da Canceled (iptal edildi). IsCompleted bu üçünün hepsinde true döner; yani "bitti" demek "başarılı oldu" demek değildir. Başarıyı sormak istiyorsan IsCompletedSuccessfully'yi kullan.
Bitmemiş durumların adı sana pek bir şey söylemez. Async method'ların döndürdüğü task WaitingForActivation görünür; çünkü bu task'ı bir thread çalıştırmıyor, derleyicinin ürettiği kod bir şeyin bitmesini bekliyor. Task.Run ile başlayan bir task'ı ise yakaladığın ana göre WaitingToRun ya da Running görebilirsin. Pratikte bu ara durumlara göre karar vermezsin; işin bitmesini await ile beklersin.
Listedeki tek garip satır Created. new Task(...) ile oluşturulan task "soğuk"tur: Start() çağrılana kadar hiç çalışmaz. TPL'in ilk günlerinden kalan bu yol bugün neredeyse hiç kullanılmaz; ona tuzaklar bölümünde döneceğiz.
Task Üretmenin Yolları
Günlük kodda task'ı dört yerden alırsın. Birincisi, en sık olanı, async method'lar: async Task<T> yazdığında derleyici task'ı senin için üretir ve return ettiğin değerle tamamlar. İkincisi I/O API'leri: ReadAsync, GetStringAsync, Task.Delay. Üçüncüsü Task.Run: CPU-bound bir işi thread pool'a verip onu temsil eden task'ı almak. Dördüncüsü, sonucu zaten elinde olan hazır task'lar:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Dictionary<string, decimal> priceCache = new() { ["978-0441172719"] = 12.99m };
decimal cached = await GetPriceAsync("978-0441172719");
decimal fetched = await GetPriceAsync("978-0553293357");
Console.WriteLine($"Cached: {cached}, fetched: {fetched}");
Task<decimal> GetPriceAsync(string isbn)
{
if (priceCache.TryGetValue(isbn, out decimal price))
{
return Task.FromResult(price); // sonuç hazır, beklemeye gerek yok
}
return FetchPriceAsync(isbn);
}
async Task<decimal> FetchPriceAsync(string isbn)
{
await Task.Delay(300); // fiyat servisine gidiyormuş gibi
priceCache[isbn] = 9.50m;
return 9.50m;
}
Cached: 12.99, fetched: 9.50
GetPriceAsync async değil, ama Task<decimal> döndürüyor. Fiyat cache'teyse Task.FromResult ile zaten tamamlanmış bir task döner; değilse gerçek async işe yönlendirir. Çağıran taraf iki durumu ayırt etmez, ikisini de await eder. Bitmiş bir task'ı await etmek çok ucuzdur: kod beklemeden, aynı thread'de devam eder. Sonuç taşımayan bir işi "hemen bitti" diye döndürmek için Task.CompletedTask var; bir interface'in async method'unu senkron biten bir şekilde implement ederken sık kullanılır.
TaskCompletionSource: Event'i Task'a Çevirmek
Bazen elinde task döndürmeyen, sonucu bir event ya da callback ile bildiren bir API olur; bir önceki yazıdaki EAP gibi. TaskCompletionSource<T> böyle bir API'yi task dünyasına bağlayan köprüdür. Sen bir task üretirsin ve onu ne zaman, hangi sonuçla bitireceğine sen karar verirsin:
PaymentTerminal terminal = new PaymentTerminal();
string receipt = await PayAsync(terminal, 37.49m);
Console.WriteLine($"Receipt: {receipt}");
static Task<string> PayAsync(PaymentTerminal terminal, decimal amount)
{
TaskCompletionSource<string> source = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
terminal.PaymentCompleted += (sender, receiptNo) => source.TrySetResult(receiptNo);
terminal.PaymentFailed += (sender, reason) => source.TrySetException(new InvalidOperationException(reason));
terminal.StartPayment(amount);
return source.Task;
}
class PaymentTerminal
{
public event EventHandler<string>? PaymentCompleted;
public event EventHandler<string>? PaymentFailed;
public void StartPayment(decimal amount)
{
// eski usul bir API: sonucu bir süre sonra event ile bildirir
ThreadPool.QueueUserWorkItem(_ =>
{
Thread.Sleep(200);
if (amount > 0)
{
PaymentCompleted?.Invoke(this, "R-1001");
}
else
{
PaymentFailed?.Invoke(this, "Invalid amount");
}
});
}
}
Receipt: R-1001
source.Task dışarıya giden sözdür; TrySetResult, TrySetException ve TrySetCanceled o sözü tutmanın üç yolu. Hangisi önce çağrılırsa task o sonla biter, sonrakiler etkisiz kalır; "Try" önekinin anlamı bu. RunContinuationsAsynchronously seçeneği, task'ı bekleyen kodun event'i tetikleyen thread'in içinde, onun call stack'inde çalışmasını engeller. Kütüphane kodunda TaskCompletionSource kurarken bu seçeneği vermek iyi bir alışkanlıktır.
Sonucu Almak: await
Task'ın sonucunu almanın doğru yolu await. Task'ın bir söz olduğunu, bir fonksiyon olmadığını şu deney iyi gösterir:
int calls = 0;
Task<int> stockTask = CountStockAsync();
int first = await stockTask;
int second = await stockTask;
Console.WriteLine($"first {first}, second {second}, method ran {calls} time(s)");
async Task<int> CountStockAsync()
{
calls++;
await Task.Delay(100);
return 5;
}
first 5, second 5, method ran 1 time(s)
Aynı task'ı iki kez await ettik, method bir kez çalıştı. Task sonucu saklar; ikinci await işi yeniden başlatmaz, hazır sonucu verir.
Task<T>'nin bir de Result property'si var. Task bitmişse sonucu verir; bitmemişse çağıran thread'i iş bitene kadar bloklar. Bu, bir önceki yazılarda konuştuğumuz bloklamanın ta kendisidir ve bazı ortamlarda programı sonsuza kadar kilitler. Kuralı şimdiden koyalım: async kodda sonucu await ile al; .Result ve .Wait() yazma. Neden kilitlendiğini deadlock yazısında adım adım göreceğiz.
Task'ların sıcak başlamasının pratik bir faydası var. Birbirinden bağımsız iki işi önce başlatıp sonra beklersen, ikisi aynı anda yürür:
using System.Diagnostics;
Stopwatch watch = Stopwatch.StartNew();
decimal price = await GetPriceAsync();
int stock = await GetStockAsync();
Console.WriteLine($"One after another: {watch.ElapsedMilliseconds} ms");
watch.Restart();
Task<decimal> priceTask = GetPriceAsync(); // ikisi de şimdi başlıyor
Task<int> stockTask = GetStockAsync();
decimal price2 = await priceTask;
int stock2 = await stockTask;
Console.WriteLine($"Started together: {watch.ElapsedMilliseconds} ms");
static async Task<decimal> GetPriceAsync()
{
await Task.Delay(300);
return 12.99m;
}
static async Task<int> GetStockAsync()
{
await Task.Delay(300);
return 5;
}
One after another: 606 ms
Started together: 301 ms
Süreler senin makinende biraz farklı çıkar, ama oran aynı kalır. İlk yarıda ikinci iş, birincisi bitene kadar başlamadı. İkinci yarıda iki iş aynı anda başladı ve toplam süre en uzun işin süresine indi. Bunu daha düzenli yazmanın yolu Task.WhenAll; ona concurrency yazısında ayrıntılı bakacağız.
Task.Run ve Task.Factory.StartNew
Eski kodlarda Task.Run yerine Task.Factory.StartNew görürsün. .NET Framework 4.0'da task başlatmanın yolu buydu; Task.Run 4.5 ile geldi ve doğru varsayılanlarla ayarlanmış bir kısayoldur. Fark, async bir lambda verdiğinde ortaya çıkar:
using System.Diagnostics;
Stopwatch watch = Stopwatch.StartNew();
Task<Task> nested = Task.Factory.StartNew(async () => await Task.Delay(1000));
await nested;
Console.WriteLine($"StartNew: {watch.ElapsedMilliseconds} ms");
watch.Restart();
await Task.Run(async () => await Task.Delay(1000));
Console.WriteLine($"Task.Run: {watch.ElapsedMilliseconds} ms");
watch.Restart();
await Task.Factory.StartNew(async () => await Task.Delay(1000)).Unwrap();
Console.WriteLine($"StartNew + Unwrap: {watch.ElapsedMilliseconds} ms");
StartNew: 2 ms
Task.Run: 1000 ms
StartNew + Unwrap: 1002 ms
İlk satır bir saniye beklemedi. Async lambda bir Task döndürür; StartNew bu lambda'yı çalıştırır ve sonucunu, yani iç task'ı, kendi task'ının içine koyar. Elinde Task<Task> olur: dış task "lambda başladı ve ilk await'e geldi" anında biter, iç task ise bir saniye sonra. Biz dış task'ı bekledik. Task.Run bu durumu bilir ve iç task'ı otomatik açar; StartNew ile aynı sonuç için Unwrap() çağırman gerekir. Ayrıca StartNew, varsayılan olarak o anki TaskScheduler'ı kullanır, Task.Run ise her zaman thread pool'u. Kısacası, bir işi thread pool'a vermek için Task.Run kullan; StartNew'u ancak TaskCreationOptions.LongRunning gibi özel bir ayara ihtiyacın varsa ve ne yaptığını bilerek seç.
Sık Düşülen Tuzaklar
Task'ı await etmeyi unutmak. Async bir method'u çağırıp dönen task'ı bir kenara atarsan iş arka planda yürür, ama ne zaman bittiğini ve hata verip vermediğini kimse bilmez. Derleyici bu durumda seni uyarır:
await ProcessOrderAsync();
static async Task ProcessOrderAsync()
{
SaveOrderAsync();
await Task.Delay(200); // sipariş ekranı başka işlere devam ediyor
Console.WriteLine("Order saved");
}
static async Task SaveOrderAsync()
{
await Task.Delay(100);
throw new InvalidOperationException("Database is down");
}
Program.cs(5,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.
Programı çalıştırınca "Order saved" yazar ve sorunsuz biter. Veritabanı hatası hiçbir yerde görünmez; exception task'ın içinde kalır ve kaybolur. CS4014'ü görmezden gelme; ya await ekle ya da task'ı bilinçli olarak sakla. Bu konunun devamı async void ve fire-and-forget yazısında.
new Task(...) ile soğuk task oluşturmak. Start()'ı unutursan task Created durumunda sonsuza kadar kalır ve onu await eden kod hiç devam etmez. Bir işi başlatmak için Task.Run'ı, sonucu hazır bir task için Task.FromResult'ı, kendi kontrolünde bitecek bir task için TaskCompletionSource'u kullan.
Bağımsız işleri gereksiz yere sıraya dizmek. Her satırda ayrı ayrı await yazmak doğrudur, ama işler birbirine bağlı değilse önce başlatıp sonra beklemek süreyi en uzun işe indirir. Tersine, bağımlı işleri aynı anda başlatmaya çalışma: siparişi kaydetmeden fatura kesemezsin.
IsCompleted'ı başarı sanmak. Faulted ve Canceled task'lar da tamamlanmış sayılır. Başarıyı IsCompletedSuccessfully ile kontrol et.
Küçük Bir Deneme
★☆☆ Yazının başındaki programda await Task.Delay(300) satırını await Task.CompletedTask ile değiştir. Beş satırın sırası değişti mi? Neden?
★★☆ PaymentTerminal örneğinde tutarı 0 yap. PayAsync'i try/catch içinde await et ve hangi exception'ın, hangi mesajla yakalandığını yazdır. Sonra task'ın Status'una bak.
★★★ Task.Factory.StartNew örneğine Console.WriteLine(nested.Result.Status) ekle ve iç task'ın o anki durumunu gör. Ardından await await nested; yazmanın neden Unwrap() ile aynı sonucu verdiğini açıkla.
Task'ın bir söz olduğunu, nasıl üretildiğini ve sonucunun nasıl alındığını gördük. Ama await'in içinde gerçekte ne olduğunu hâlâ bilmiyoruz: method nasıl yarıda durup başka bir thread'de kaldığı yerden devam ediyor? Bir sonraki yazıda derleyicinin perdesini aralayıp async method'un state machine'ine ve continuation'lara bakacağız.