Bilgisayar Genel

.NET Async/Await: Deadlock, Performans ve Modern Teknikler

Tarafından yazılmıştır Halil Durmuş

.NET dünyasında async/await artık her projenin bir parçası. Ancak bu kadar yaygın olmasına rağmen hâlâ yanlış kullanıldığı durumlar var. Bu hatalar gereksiz karmaşa yaratabiliyor ve performans sorunlarına yol açabiliyor. Bu yazıda günlük geliştirme hayatında sık görülen senaryolara odaklanacağım. ConfigureAwait kullanımının etkilerini ele alacağım. Async deadlock örneklerini açıklayacağım. IO-bound ve CPU-bound ayrımını netleştireceğim. Async LINQ pattern’lerinden bahsedeceğim. Son olarak BackgroundService içinde asenkron iş yürütmeyi inceleyeceğim. Amacım tüm bu konulara dair pratik ve anlaşılır bir perspektif sunmak.

Async/Await’in Temeli: IO-bound ile CPU-bound Ayrımını Anlamak

Asenkron programlama denince birçok kişinin aklına hızlanma geliyor. Ancak gerçekte async kod çoğu zaman hız kazandırmaz. Asıl amaç, tıkanmayan thread’leri serbest bırakmaktır. IO-bound işlemler buna iyi bir örnektir. Örneğin ağ istekleri, dosya okumaları veya veritabanı çağrıları bekleme sırasında thread’i meşgul etmez. Bu nedenle await tam bu noktada devreye girer.

Diğer yandan CPU-bound işlemler farklıdır. Uzun hesaplamalar, image processing veya kriptografik operasyonlar tamamen CPU gücüne ihtiyaç duyar. Bu durumda async tek başına fayda sağlamaz. İşlemi Task.Run() ile havuza atmak gerekir. Böylece UI veya request thread’i boş kalır. Bu fark doğru anlaşılmazsa yanlış async kullanımı ortaya çıkar. Sonuç olarak gereksiz overhead ve hatalı mimari kararlar oluşur.

Async/Await ve Deadlock Gerçekleri: ConfigureAwait’in Rolü

En klasik deadlock örneği:

C#
var result = GetDataAsync().Result;
C#


veya

C#
var result = GetDataAsync().GetAwaiter().GetResult();
C#

Eğer çağrıyı yapan thread (özellikle UI thread veya ASP.NET’in eski SynchronizationContext’leri) senkron bekliyorsa ve async metot result dönerken bağlamına dönmeye çalışıyorsa deadlock oluşur. Bunu önlemenin en güçlü yolu ise hepimizin bildiği ama çoğu zaman “ihmal ettiği” küçük bir detaydır: ConfigureAwait(false).

Bir kütüphane, SDK veya utility sınıfı yazarken çoğunlukla çağıranın context’ine dönmeye ihtiyaç yoktur. Bu nedenle tüm await’lerinize şu eklemeyi yapmanız sizi birçok potansiyel sorunlardan kurtarır:

C#
await SomeIoOperation().ConfigureAwait(false);
C#

Böylece kodunuz UI veya request context’i gibi bir “tekil thread” geri dönme zorunluluğu olan ortamlara takılmaz. Modern .NET’te ASP.NET Core zaten SynchronizationContext kullanmadığı için deadlock riski azalmış olsa da, library geliştiricileri için ConfigureAwait hâlâ altın değerinde.

Async LINQ Pattern’leri: En Çok Yanlış Anlaşılan Konulardan Biri

LINQ doğası gereği senkron bir API’dir ve async pattern’lerle birebir çalışması çoğu zaman beklenildiği kadar kolay değildir. Örneğin, şu kullanım hatalıdır:

C#
var results = items.Select(async item => await DoWorkAsync(item)).ToList();
C#

Bu noktada results bir List<Task<T>> döner, işlerin tamamlanıp tamamlanmadığı belirsizdir. Doğrusu ise genellikle iki aşamalı bir pattern izlemektir:

C#
var tasks = items.Select(item => DoWorkAsync(item));
var results = await Task.WhenAll(tasks);
C#

Hem kontrol sizde olur hem de paralel yürütme avantajı kazanırsınız.
Aynı zamanda EF Core gibi async query destekleyen ortamlarda LINQ ifadelerinin tamamı asenkron olmayabilir. Sorgunun en son ToListAsync() veya FirstOrDefaultAsync() vb. çağrısına kadar deferred execution devam eder. Bu fark, özellikle performans odaklı API’lerde kritik bir noktadır.

BackgroundService ile Async İş Akışları

BackgroundService, .NET uygulamalarında arka plan işler için oldukça ideal bir yapı sunar. Ancak async yapılar burada da dikkat gerektirir. Döngü tabanlı bir servis:

C#
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        await ProcessJobsAsync(stoppingToken);
        await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
    }
}
C#

Burada dikkat edilmesi gereken iki kritik nokta vardır:
(1) CancellationToken’ın tüm beklemelerde ve IO-bound çağrılarında gerçekten kullanılması.
(2) Servisin kesinlikle fire-and-forget iş döndürmemesi; async void yerine async Task pattern’ine sıkı sıkıya bağlı kalması.

Ayrıca BackgroundService içerisindeki exception’ların swallow edilmesi çok yaygın bir hatadır. Servis çöker, sistem hiçbir şey söylemeden çalışmayı durdurur. Bu nedenle try/catch ile loglama veya global handler kullanımı bir zorunluluktur.

Gerçek Hayatta Async/Await Nasıl Tasarlanmalı?

Bir projede async mimari tasarlanırken kritik olan şey, async’i her yere enjekte etmek değil; ihtiyaç duyulan yere doğru şekilde yerleştirmektir. Servis metotları IO ağırlıklı ise async kullanmak doğru; ancak her fonksiyonu async yapmak, gereksiz Task objeleri üretmek ve thread havuzunu şişirmek, performansı artırmak yerine düşürebilir. Bu yüzden en iyi pratik her zaman hepimizin bildiği ama bazen uygulamaktan kaçındığı küçük bir prensiptir: Async all the way.

Yani bir metot async ise çağıran da async, onun çağıranı da async olmalıdır. Uçta bir UI veya Controller’a gelindiğinde “await” ile işlem sonlandırılır.

Sonuç: Async/Await Sadece Bir Söz Dizimi Değil, Bir Mimaridir

.NET’te async/await, daha hızlı bir uygulama yazmak için değil; daha esnek, ölçeklenebilir ve thread pool’u verimli kullanan bir mimari inşa etmek içindir. Gerçek dünyada kullanılan asenkron kodlar; ConfigureAwait kullanımından arka plan servislerinde exception yönetimine, async LINQ pattern’lerinden IO-bound vs CPU-bound farkına kadar birçok noktada özen gerektirir.

Doğru kullanıldığında async/await hem kodunuzu daha temiz hale getirir hem de yüksek trafik altında dahi uygulamanızın daha stabil çalışmasını sağlar. Yanlış kullanıldığında ise fark edilmesi en zor performans ve concurrency problemlerinin kaynağı olabilir.

Yazar hakkında

Halil Durmuş

1996 yılının Mart ayında Trabzon’da dünyaya geldim. Atatürk Üniversitesi, Bilgisayar Mühendisliği mezunuyum. Web sitemde ilgimi çeken konuları araştırarak yazılar paylaşıyorum.

Yorum Yap