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

C# Async Programlama Neden Var: Bloklamak, I/O ve Ölçek

Mert Özen 7 Kas 2026 9 dk 1 görüntülenme
C# Async Programlama Neden Var: Bloklamak, I/O ve Ölçek

Aynı 100 stok sorgusu async ile bir saniyede, thread'i bekleten kodla 8-9 saniyede bitiyor. I/O-bound ve CPU-bound iş farkı, bloklamanın UI'da ve sunucuda bedeli, thread pool starvation, async'in hız değil kapasite kazandırması ve sahte async 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

Bookstore'un sipariş ekranı, her kitap için stok servisine soruyor ve servis her cevabı bir saniyede veriyor. Aşağıdaki program aynı işi iki kez yapıyor: önce 100 sorguyu async bir method ile, sonra aynı 100 sorguyu thread'i bekleten klasik bir method ile gönderiyor. İki tarafta da her sorgu tam bir saniye bekliyor. Sence iki satır da "yaklaşık 1000 ms" mi yazar? Arada fark varsa ne kadardır?

using System.Diagnostics;

const int requestCount = 100;

Stopwatch watch = Stopwatch.StartNew();
Task<int>[] asyncCalls = new Task<int>[requestCount];
for (int i = 0; i < requestCount; i++)
{
    asyncCalls[i] = GetStockAsync("978-0441172719");
}
await Task.WhenAll(asyncCalls);
Console.WriteLine($"Async:    {watch.ElapsedMilliseconds} ms, pool threads: {ThreadPool.ThreadCount}");

watch.Restart();
Task<int>[] blockingCalls = new Task<int>[requestCount];
for (int i = 0; i < requestCount; i++)
{
    blockingCalls[i] = Task.Run(() => GetStock("978-0441172719"));
}
await Task.WhenAll(blockingCalls);
Console.WriteLine($"Blocking: {watch.ElapsedMilliseconds} ms, pool threads: {ThreadPool.ThreadCount}");

static int GetStock(string isbn)
{
    Thread.Sleep(1000); // stok servisinin cevabını bekliyormuş gibi
    return 5;
}

static async Task<int> GetStockAsync(string isbn)
{
    await Task.Delay(1000); // aynı bekleme, ama thread'i bırakarak
    return 5;
}

Task.WhenAll'ı bu serinin ilerleyen yazılarında ayrıntılı göreceğiz; şimdilik "listedeki işlerin hepsi bitene kadar bekle" diye okuman yeterli.

Cevap: Bekleyen Thread Boşa Yanan Işıktır

Benim makinemdeki (Apple M5, 10 çekirdek, .NET 8 runtime'ı) koşulardan biri şunu yazdı. Süreler makineden makineye ve koşudan koşuya değişir; senin sayıların farklı olacaktır, ama aradaki uçurum aynı kalır:

Async:    1005 ms, pool threads: 10
Blocking: 9014 ms, pool threads: 13

Async taraf 100 sorguyu bir saniyede bitirdi. Klasik taraf aynı işi dokuz saniyede bitirdi; dört koşuda bu süre 8 ile 9 saniye arasında gidip geldi. İki tarafta da bilgisayar hiçbir şey hesaplamıyor; sadece bekliyor. Fark, beklerken thread'e ne olduğunda.

Thread.Sleep(1000) çağrıldığında o thread bir saniye boyunca kilitlenir. Başka hiçbir iş yapamaz, ama bellekte yer tutmaya ve thread pool'da bir koltuk işgal etmeye devam eder. Task.Run işleri thread pool'a verir. Pool bu makinede 10 thread ile başlıyor ve queue'da 100 iş var. İlk 10 iş 10 thread'i bir saniyeliğine kilitler, kalan 90'ı sırada bekler. Pool sıkıştığını fark edip yeni thread ekler, ama bunu yavaş ve temkinli yapar: ölçtüğümde yarım saniyede bir thread ekledi ve 12-13'te durdu. Sonuç: 100 iş, aşağı yukarı onar onar, 8-9 saniye.

await Task.Delay(1000) ise thread'i kilitlemez. Method "bir saniye sonra beni buradan devam ettir" diye bir not bırakır ve thread'i serbest bırakır. Bir saniye dolduğunda runtime işin geri kalanını boştaki bir thread'e verir. 100 sorgu aynı anda bekler, ama bekleyen thread yoktur; bekleyen yalnızca 100 küçük nottur.

01-blocking-vs-async-tr.png

Bunu bir restoran garsonu gibi düşünebilirsin. Senkron garson siparişi mutfağa verir ve yemek çıkana kadar tezgâhın önünde dikilir. Restoranda on garson varsa aynı anda ancak on masaya bakılır, on birinci masa bekler. Async garson siparişi mutfağa bırakır, başka masalara geçer ve yemek hazır olunca zil çalınca geri döner. Mutfak aynı hızda çalışır, ama aynı sayıda garsonla çok daha fazla masaya bakılır.

İki Tür İş: I/O-bound ve CPU-bound

Async'in neyi çözdüğünü anlamak için önce işleri ikiye ayırmak gerekiyor. I/O-bound işte program başka bir şeyin cevabını bekler: veritabanı sorgusu, HTTP isteği, disk okuması, bir message queue. Bu sürede CPU boştadır; iş ağ kartında, diskte ya da karşıdaki sunucudadır. CPU-bound işte ise beklenecek kimse yoktur; CPU hesap yapar: fatura toplamları, resim küçültme, şifreleme, büyük bir listeyi sıralamak.

Async/await esas olarak I/O-bound işler için var. CPU-bound bir işi async yapmak onu hızlandırmaz, çünkü işi yapan yine bir CPU çekirdeğidir:

using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;

Stopwatch watch = Stopwatch.StartNew();
string direct = HashCatalog(200_000);
Console.WriteLine($"Direct:   {watch.ElapsedMilliseconds} ms");

watch.Restart();
string awaited = await Task.Run(() => HashCatalog(200_000));
Console.WriteLine($"Task.Run: {watch.ElapsedMilliseconds} ms");
Console.WriteLine(direct == awaited);

static string HashCatalog(int rounds)
{
    byte[] data = Encoding.UTF8.GetBytes("978-0441172719");
    for (int i = 0; i < rounds; i++)
    {
        data = SHA256.HashData(data); // saf CPU işi
    }
    return Convert.ToHexString(data)[..8];
}
Direct:   27 ms
Task.Run: 24 ms
True

İki süre aynı. Task.Run işi başka bir thread'e taşıdı, o kadar. Bu taşımanın yeri var: bir masaüstü uygulamasında ağır hesabı ekranı çizen thread'den almak için kullanılır, böylece pencere donmaz. Ama hesap kısalmaz. CPU-bound işi gerçekten hızlandırmanın yolu onu parçalayıp birden çok çekirdeğe dağıtmaktır; bu da async değil paralellik konusudur ve serinin ilerleyen bir yazısında Parallel.ForEach ve PLINQ ile karşımıza çıkacak.

Bloklamak Ne Demek

Bir thread'i bloklamak, onu bir sonucu beklerken başka bir iş yapamaz hâle getirmektir. Thread.Sleep, senkron File.ReadAllText, senkron bir veritabanı sorgusu, bir task'ın sonucunu .Result ile almaya çalışmak: hepsi çağıran thread'i, sonuç gelene kadar dondurur.

Tek bir bloklama kimseyi öldürmez. Sorun, thread'in ucuz bir şey olmaması. Her thread işletim sisteminin yönettiği gerçek bir kaynaktır; kendi stack'i için bellek ayrılır (Windows'ta varsayılan 1 MB), işletim sistemi thread'ler arasında geçiş yaparken zaman harcar, .NET'in thread pool'u da yeni thread eklemekte bilerek cimri davranır. Bloklanmış her thread, bu pahalı kaynağın hiçbir şey yapmadan beklemesi demek.

Bloklamanın bedeli ortama göre farklı görünür. Masaüstü uygulamasında tek bir UI thread vardır; butona basınca senkron bir HTTP isteği atarsan pencere istek bitene kadar donar, başlık çubuğunda "Not Responding" belirir. Web sunucusunda ise her istek pool'dan bir thread ile işlenir. İstekler veritabanını senkron beklerse thread'ler tükenir, yeni istekler queue'da birikir, CPU yüzde beşteyken bile cevap süreleri saniyelere çıkar. Buna thread pool starvation denir ve production'da en zor teşhis edilen sorunlardan biridir, çünkü sunucu ölçümlerde "boş" görünür.

Async Gerçekte Ne Kazandırır

Async tek bir isteği hızlandırmaz. Tek bir stok sorgusunu iki yolla ölçelim:

using System.Diagnostics;

Stopwatch watch = Stopwatch.StartNew();
int stock = GetStock("978-0441172719");
Console.WriteLine($"Sync:  {stock} in {watch.ElapsedMilliseconds} ms");

watch.Restart();
int asyncStock = await GetStockAsync("978-0441172719");
Console.WriteLine($"Async: {asyncStock} in {watch.ElapsedMilliseconds} ms");

static int GetStock(string isbn)
{
    Thread.Sleep(1000);
    return 5;
}

static async Task<int> GetStockAsync(string isbn)
{
    await Task.Delay(1000);
    return 5;
}
Sync:  5 in 1011 ms
Async: 5 in 1004 ms

İkisi de bir saniye. Servis bir saniyede cevap veriyorsa, sen ne yaparsan yap cevabı bir saniyede alırsın. Async'in kazandırdığı şey süre değil, kapasitedir: bekleme sırasında thread serbest kaldığı için aynı sayıda thread ile çok daha fazla işi aynı anda yürütebilirsin. Yazının başındaki deneyde 8-9 saniyenin bir saniyeye inmesi, tek tek sorguların hızlanmasından değil, 100 sorgunun aynı anda beklenebilmesinden geldi.

Bu yüzden async'in en büyük kazancı sunucu tarafında görülür. ASP.NET Core'da controller'larını async yazdığında, veritabanını bekleyen bir istek thread'ini bırakır ve o thread bu sırada başka bir isteğe bakar. Aynı makine çok daha fazla eşzamanlı isteği kaldırır. .NET Core Web API serisinde controller'ları ve EF Core sorgularını baştan async yazmamızın sebebi buydu.

İlk async Method'umuz

Syntax'a kısaca bakalım; nasıl çalıştığını ilerleyen yazılarda adım adım açacağız. Bookstore'un fiyat servisinden fiyat çeken bir method:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Console.WriteLine($"Before await: thread {Environment.CurrentManagedThreadId}");
decimal price = await GetPriceAsync("978-0441172719");
Console.WriteLine($"Price: {price}");
Console.WriteLine($"After await:  thread {Environment.CurrentManagedThreadId}");

static async Task<decimal> GetPriceAsync(string isbn)
{
    await Task.Delay(500); // fiyat servisine gidiyormuş gibi
    return 12.99m;
}
Before await: thread 1
Price: 12.99
After await:  thread 5

Dört şeye dikkat et. Method'un başında async var; bu, içinde await kullanacağını derleyiciye söyler. Dönüş tipi decimal değil Task<decimal>; method hemen bir "ileride decimal vereceğim" sözü döndürür. İçeride return 12.99m yazarsın, o sözü derleyici tutar. İsim Async ile biter; .NET'te Task döndüren method'lar için yerleşik bir kural.

Dördüncüsü çıktıda: await'ten önce kod 1 numaralı thread'deydi, sonra 5 numaralı thread'de devam etti. Senin makinende numara farklı olabilir, ama çoğu zaman değişir. Bekleme sırasında 1 numaralı thread serbest kaldı; bekleme bitince devamı thread pool'daki boş bir thread üstlendi. Bu console uygulamasının davranışı; masaüstü uygulamalarında devam aynı UI thread'ine döner. Bu ayrımın neden önemli olduğunu deadlock yazısında konuşacağız.

Gerçek I/O: .NET Zaten Async Konuşuyor

Gerçek hayatta Task.Delay yazmayacaksın; .NET'in I/O yapan API'lerinin neredeyse hepsinin async bir karşılığı var: File.ReadAllTextAsync, Stream.ReadAsync, HttpClient.GetStringAsync, ADO.NET'te ExecuteReaderAsync, EF Core'da ToListAsync. Bu method'lar işletim sisteminin asenkron I/O mekanizmalarını kullanır; okuma sürerken senin hiçbir thread'in beklemez.

string path = Path.Combine(Path.GetTempPath(), "books.csv");
string[] lines =
{
    "978-0441172719;Dune;12.99",
    "978-0553293357;Foundation;9.50"
};

await File.WriteAllLinesAsync(path, lines);
string[] read = await File.ReadAllLinesAsync(path);

foreach (string line in read)
{
    string[] parts = line.Split(';');
    Console.WriteLine($"{parts[1]} costs {parts[2]}");
}

File.Delete(path);
Dune costs 12.99
Foundation costs 9.50

Kural basit: elinde bir API'nin hem senkron hem async hâli varsa ve kodun zaten async bir akışın içindeyse, async olanı seç. Bir istisna var: async hâli olmayan bir I/O işlemini Task.Run ile sarmak onu async yapmaz. Bunu bir sonraki bölümde göreceğiz.

Sık Düşülen Tuzaklar

Async'i hız sanmak. Async tek bir işlemi hızlandırmaz, bekleme sırasında thread'i serbest bırakır. Yavaş bir sorguyu async yapmak onu hızlandırmaz; sorguyu düzeltmen gerekir.

Senkron kodu Task.Run ile sarıp async sanmak. Yazının başındaki deneyde GetStock'u Task.Run(() => GetStock(isbn)) ile sarmıştık ve sonuç 8-9 saniyeydi. Dışarıdan Task döner, await edilebilir, ama içeride bir pool thread'i yine bir saniye kilitlenir. Buna sahte async denir. Bir masaüstü uygulamasında UI thread'ini kurtarmak için işe yarar; bir web sunucusunda ise hiçbir şey kazandırmaz, üstüne bir thread geçişi ekler.

Async'i yarı yolda bırakmak. Async bir method'u çağırıp sonucunu .Result ya da .Wait() ile almak, async'in bütün kazancını geri verir: çağıran thread yine kilitlenir. Daha kötüsü, bazı ortamlarda programı sonsuza kadar kilitler. Doğru yol "baştan sona async"tir: async bir method'u çağıran method da async olur ve await eder. Bu zincirin neden koparılmaması gerektiğini deadlock yazısında ayrıntılı göreceğiz.

CPU-bound işi async ile çözmeye çalışmak. Ağır bir hesap için ihtiyacın olan şey async değil, ya işi UI thread'inden almak ya da birden çok çekirdeğe dağıtmaktır. İkisi farklı araçlar ister.

Küçük Bir Deneme

★☆☆ Yazının başındaki programda requestCount'u önce 10'a, sonra 1000'e çıkar. Async tarafın süresi değişiyor mu? Blocking taraf 1000 sorguda ne kadar sürüyor? Sayıların senin makinende farklı çıkacağını unutma.

★★☆ Aynı programda using satırının hemen altına ThreadPool.SetMinThreads(100, 100); ekle ve tekrar çalıştır. Blocking taraf neden hızlandı? Bu ayarın bir web sunucusunda neden gerçek bir çözüm olmadığını, thread başına maliyeti düşünerek açıkla.

★★★ "Gerçek I/O" bölümündeki kodu File.ReadAllLines ile senkron yaz ve iki versiyonun çıktısını karşılaştır. Sonra kendine şunu sor: bir web sunucusunda saniyede bin istek bu dosyayı okusaydı, hangi versiyon kaç thread'i meşgul ederdi?

Async'in neden var olduğunu gördük: beklerken thread'i boşa harcamamak. Bir sonraki yazıda bir adım geri çekilip thread'in kendisine bakacağız: Thread class'ı, thread pool'un nasıl çalıştığı ve .NET'in bu soruna on yıl boyunca bulduğu çözümler, yani APM, EAP ve TPL'den async/await'e uzanan kısa bir tarih.