Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #17

ASP.NET Core'da Async/Await ve CancellationToken: İptali Veritabanına Kadar Taşımak

Mert Özen 4 Kas 2026 8 dk 2 görüntülenme
ASP.NET Core'da Async/Await ve CancellationToken: İptali Veritabanına Kadar Taşımak

await sırasında thread'in havuza döndüğünü loglarla görüyor, bloke eden kodun thread pool starvation'a etkisini ölçüyoruz. CancellationToken'ı action'dan EF Core'a taşıyor, .Result ve async void tuzaklarını konuşuyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.

Önce Tahmin Et

Elinde on saniye süren bir satış raporu endpoint'i var. Bir kullanıcı raporu istiyor, iki saniye bekliyor, sıkılıp sekmeyi kapatıyor. Sunucuda rapor hazırlanmaya devam eder mi? Kodda hiçbir değişiklik yapmazsan cevap ne olur, action'a tek bir parametre eklersen ne değişir? Aklında bir cevap tut; aynı deneyi birazdan gerçekten yapıp loglara bakacağız.

Thread Pool: Garson Sayısı Sınırlı

Kestrel gelen her isteği işlemek için thread pool'dan bir thread alır. Thread pool, uygulama boyunca yaşayan ve yeniden kullanılan thread'lerden oluşan bir havuzdur. Havuz büyüyebilir, ama ani bir yükte yeni thread'leri yavaş yavaş ekler; bir anda yüzlerce thread açmaz.

Bir restoran düşün. Garson sayısı sınırlı. Garson siparişi mutfağa iletip yemek pişene kadar mutfak kapısında dikilirse, o sırada başka hiçbir masaya bakamaz. Akıllı garson siparişi bırakır, diğer masalara geçer, yemek hazır olunca kim müsaitse o tabağı götürür. Senkron kod kapıda bekleyen garson, async kod diğer masalara geçen garson.

API'lerin vaktinin büyük kısmı beklemekle geçer: veritabanı cevabı, başka bir servisin HTTP yanıtı, disk. Bu bekleme sırasında thread'in boşta durması israftır. await tam olarak bunu çözer: "bu iş bitene kadar beni bırak, bitince kaldığım yerden devam ettir" der. Async kod tek bir isteği hızlandırmaz; sunucunun aynı anda daha çok isteği aynı thread sayısıyla taşımasını sağlar.

await Sırasında Thread Nereye Gider?

Bunu kendi gözümüzle görelim. Küçük bir teşhis endpoint'i yazalım:

[HttpGet("threads")]
public async Task<IActionResult> Threads(int request)
{
    _logger.LogInformation("Request {Request} before await: thread {ThreadId}",
        request, Environment.CurrentManagedThreadId);
    await Task.Delay(500);
    _logger.LogInformation("Request {Request} after await: thread {ThreadId}",
        request, Environment.CurrentManagedThreadId);
    return Ok();
}

Dört isteği aynı anda attığımızda .NET 6'da log şöyle çıktı:

Request 2 before await: thread 13
Request 1 before await: thread 16
Request 3 before await: thread 9
Request 4 before await: thread 10
Request 3 after await: thread 9
Request 4 after await: thread 14
Request 2 after await: thread 13
Request 1 after await: thread 10

1 numaralı istek thread 16'da başladı, thread 10'da devam etti. Thread 10 ise az önce 4 numaralı isteğe hizmet ediyordu. await noktasında thread havuza geri döndü, bekleme bitince havuzdan kim boştaysa işi o devraldı. Sende numaralar farklı çıkacak; önemli olan, aynı isteğin await'in iki tarafında farklı thread'lerde çalışabilmesi.

Bloke Etmenin Bedeli

Aynı bir saniyelik beklemeyi iki farklı şekilde yazalım:

[HttpGet("blocking")]
public IActionResult Blocking()
{
    // thread'i bir saniye boyunca bloke eder
    Task.Delay(1000).Wait();
    return Ok();
}

[HttpGet("non-blocking")]
public async Task<IActionResult> NonBlocking()
{
    // thread'i bırakır, bir saniye sonra devam eder
    await Task.Delay(1000);
    return Ok();
}

Her iki endpoint'e de 200'er eşzamanlı istek attık. 10 çekirdekli makinemde .NET 6 ile non-blocking istekleri yaklaşık 1,2 saniyede bitti; blocking istekleri ise birkaç denemede 5,7 ile 5,9 saniye arasında sürdü. Her istek yalnızca bir saniye bekliyor, ama bloke eden sürümde thread'ler kapıda dikildiği için havuz yetişemiyor, istekler sıraya giriyor. Buna thread pool starvation denir. Gerçek bir sistemde bu tablo, CPU boştayken yanıt sürelerinin tavana vurması şeklinde görünür ve teşhisi zordur.

CancellationToken: İstemci Gittiğinde Haber Almak

Şimdi baştaki soruya dönelim. Rapor endpoint'inin iki sürümü:

[HttpGet("sales")]
public async Task<IActionResult> Sales(CancellationToken cancellationToken)
{
    for (int step = 1; step <= 10; step++)
    {
        // her adım bir saniyelik ağır bir sorguyu temsil ediyor
        await Task.Delay(1000, cancellationToken);
        _logger.LogInformation("Sales report step {Step}/10 done", step);
    }

    return Ok(new { status = "ready" });
}

[HttpGet("sales-no-token")]
public async Task<IActionResult> SalesNoToken()
{
    for (int step = 1; step <= 10; step++)
    {
        await Task.Delay(1000);
        _logger.LogInformation("Sales report (no token) step {Step}/10 done", step);
    }

    return Ok(new { status = "ready" });
}

Sekmeyi kapatan kullanıcıyı curl --max-time 2 ile taklit ediyoruz; curl iki saniye sonra bağlantıyı koparıyor:

curl --max-time 2 http://localhost:5000/api/reports/sales-no-token
curl --max-time 2 http://localhost:5000/api/reports/sales

Token'sız sürümün logu (arası kısaltıldı):

info: ProductCatalog.Controllers.ReportsController[0]
      Sales report (no token) step 1/10 done
info: ProductCatalog.Controllers.ReportsController[0]
      Sales report (no token) step 2/10 done
...
info: ProductCatalog.Controllers.ReportsController[0]
      Sales report (no token) step 10/10 done

İstemci iki saniye sonra gitti, sunucu sekiz saniye daha kimsenin okumayacağı bir raporu hazırladı. Gerçek hayatta bu, veritabanında sekiz saniye boyunca açık kalan ağır sorgular demek. Token'lı sürümde ise .NET 6'da şunu gördük:

info: ProductCatalog.Controllers.ReportsController[0]
      Sales report step 1/10 done
fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
      An unhandled exception has occurred while executing the request.
      System.Threading.Tasks.TaskCanceledException: A task was canceled.

Bağlantı koptuğu anda token iptal edildi, bekleyen Task.Delay bir TaskCanceledException fırlattı ve iş ikinci adımda durdu. Action'a eklediğin CancellationToken parametresini MVC otomatik olarak HttpContext.RequestAborted'a bağlar; ne bir attribute ne de bir kayıt gerekir. Minimal API handler'larında da aynı şekilde parametre olarak alabilirsin.

Log'daki fail satırı ise yanıltıcı: bu bir hata değil, istemcinin vazgeçmesi. .NET 6 bunu ayırt etmiyor; .NET 8 ile framework bu durumu kendisi tanıyıp sessizce geçiyor, bunu ilerleyen sürüm yazılarında göreceğiz. .NET 6'da bu gürültüyü global hata yönetimi yazısında kendi middleware'imizle temizleyeceğiz.

Token'ı Katman Katman Taşımak

Token'ın gerçekten işe yaraması için zincirin sonuna, yani veritabanı çağrısına kadar ulaşması gerekir. Önce service'in sözleşmesi:

public interface IProductService
{
    Task<List<ProductDto>> GetAllAsync(CancellationToken cancellationToken = default);
    Task<ProductDto?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
    Task<ProductDto?> CreateAsync(CreateProductRequest request, CancellationToken cancellationToken = default);
    Task<bool> DeleteAsync(int id, CancellationToken cancellationToken = default);
}

= default sayesinde token'ı olmayan çağıranlar (bir test, bir console aracı) parametreyi atlayabilir. Service tarafında EF Core'un async method'larının hepsi token kabul eder:

public async Task<List<ProductDto>> GetAllAsync(CancellationToken cancellationToken = default)
{
    return await _db.Products
        .AsNoTracking()
        .OrderBy(p => p.Id)
        .Select(ToDto)
        .ToListAsync(cancellationToken);
}

public async Task<bool> DeleteAsync(int id, CancellationToken cancellationToken = default)
{
    Product? product = await _db.Products.FindAsync(new object[] { id }, cancellationToken);
    if (product is null)
    {
        return false;
    }

    _db.Products.Remove(product);
    await _db.SaveChangesAsync(cancellationToken);
    return true;
}

FindAsync'in imzasına dikkat: key değerleri params object[] olduğu için token'ı vermek istediğinde key'i açıkça bir array'e koyman gerekir. FindAsync(id, cancellationToken) yazarsan kod derlenir, çünkü derleyici token'ı ikinci bir key değeri sanar. EF Core 6'da hata çalışırken çıkar:

ArgumentException: Entity type 'Product' is defined with a single key property, but 2 values were passed to the 'Find' method.

EF Core 7 ve sonrası sondaki token'ı tanıyıp doğru yorumluyor; ama .NET 6 ile EF Core 6 kullanıyorsan array'li yazım şart. Controller tarafı ise sadece token'ı alıp iletir:

[HttpGet]
public async Task<ActionResult<List<ProductDto>>> GetAll(CancellationToken cancellationToken)
{
    return await _productService.GetAllAsync(cancellationToken);
}

Yazma işlemlerinde token

Okuma sorgularında token vermek neredeyse her zaman doğrudur. Yazmada iki yaklaşım var. Birincisi token'ı SaveChangesAsync'e de vermek: istemci gittiyse yazma hiç başlamaz ya da transaction geri alınır, veritabanı tutarlı kalır, ama istemci "kaydettim mi, kaydetmedim mi" bilmeden ayrılmış olur. İkincisi yazma adımında token vermemek: işlem başladıysa her durumda tamamlanır, bedeli de istemcinin hiç görmeyeceği bir işi bitirmek. Sipariş, ödeme gibi "yarıda kalırsa kafa karıştırır" işlemlerde ikinci yol daha güvenli; çoğu basit CRUD'da birincisi yeterli. Hangisini seçersen seç, SaveChangesAsync tek bir transaction'da çalıştığı için yarım kayıt oluşmaz.

.Result, .Wait() ve Diğer Senkron Tuzaklar

Eski projelerde sık görülen bir kalıp:

[HttpGet]
public ActionResult<List<ProductDto>> GetAll()
{
    // YAPMA: async kodu senkron bekliyor
    return _productService.GetAllAsync().Result;
}

Bu kod çalışır. Klasik ASP.NET ya da masaüstü uygulamalarında bu kalıp deadlock'a yol açardı, çünkü orada await sonrası devam etmek için beklenen bir SynchronizationContext vardı. ASP.NET Core'da böyle bir context yok, o yüzden kilitlenme görmezsin. Bu, kodun doğru olduğu anlamına gelmiyor: .Result thread'i bekleme süresince bloke eder ve az önce ölçtüğümüz starvation tablosunu birebir üretir. .Wait() ve .GetAwaiter().GetResult() da aynı kapıya çıkar.

Kural basit: async zincir baştan sona async olmalı. Bir yerde await kullanmaya başladıysan, onu çağıran method da async Task olur, onu çağıran da. Action'a kadar bu böyle gider; ASP.NET Core Task dönen action'ları zaten bekleyebilir.

Sık Düşülen Tuzaklar

async void yazmak. async void bir method'u kimse bekleyemez; içinde fırlayan exception yakalanamaz ve process'i çökertebilir. Event handler dışında her zaman async Task kullan.

Controller'da Task.Run ile "async yapmak". await Task.Run(() => _service.GetAll()) işi sadece havuzdaki başka bir thread'e taşır; bir thread'i serbest bırakırken diğerini meşgul edersin. Kazanç yok, sadece ek maliyet var. Gerçek async, I/O'yu async yapan API'lerden (ToListAsync, HttpClient.GetAsync) gelir.

Token'ı yarı yolda bırakmak. Action token'ı alıyor, service'e iletiyor, ama service ToListAsync()'i parametresiz çağırıyorsa zincirin en pahalı halkası iptalden haberdar olmaz.

FindAsync(id, cancellationToken) yazmak. Yukarıda konuştuğumuz gibi bu derlenir, ama EF Core 6'da çalışırken key sayısı uyuşmadığı için patlar; doğrusu FindAsync(new object[] { id }, cancellationToken).

Her yere ConfigureAwait(false) serpiştirmek. ASP.NET Core uygulama kodunda SynchronizationContext olmadığı için bir etkisi yok. Birden fazla ortamda çalışacak bir library yazıyorsan orada anlamlı.

İptali hata gibi loglamak. OperationCanceledException istemcinin vazgeçtiğini söyler; alarm üreten bir fail log'u olarak kalması gereksiz gürültüdür.

Küçük Bir Deneme

★☆☆ Threads endpoint'ine aynı anda on istek at ve logda hangi isteklerin await'ten sonra thread değiştirdiğini say.

★★☆ blocking ve non-blocking endpoint'lerine 500'er eşzamanlı istek atıp süreleri karşılaştır. Sonra Program.cs'in başına ThreadPool.SetMinThreads(500, 500); ekleyip ölçümü tekrarla; neden fark kapandığını ve bunun neden gerçek bir çözüm olmadığını düşün.

★★★ CreateAsync'e ürünü kaydettikten sonra üç saniye süren bir "stok servisine bildir" adımı ekle (Task.Delay ile taklit et). İstemci bu üç saniyenin ortasında giderse ürün kaydedilmiş olarak mı kalıyor? Token'ı nerelere verirsen davranışın nasıl değiştiğini dene.

İptal edilen istekler loglarda fail olarak görünmeye devam ediyor ve service'ten "bulunamadı" ya da "isim zaten var" bilgisini hâlâ null ile taşıyoruz. Bir sonraki yazıda bunların hepsini tek bir merkezde toplayacağız: UseExceptionHandler, kendi exception middleware'imiz ve domain exception'larını doğru status code'lu ProblemDetails yanıtlarına çeviren global bir hata yönetimi kuracağız.