Yazılarımız

Veri Akademi

C# ASYNC/AWAİT NEDİR? DOĞRU ASENKRON TASARIM VE DEADLOCK RİSKLERİ

Bir uygulama “dondu” hissi veriyorsa, çoğu zaman sorun performanstan değil, yanlış asenkron tasarımdan kaynaklanır. C#’ta async/await, işi büyüleyici biçimde kolaylaştırır; ancak aynı zamanda hatalı kullanıldığında deadlock, gizli gecikmeler ve tahmin edilemeyen hatalar üretebilir.

Bu yazıda “async/await nedir?” sorusunu yalnızca tanım seviyesinde bırakmayacağız. Hangi işin asenkron yapılması gerektiğini, Task tabanlı API tasarımını, UI/ASP.NET gibi ortamlarda SynchronizationContext etkisini ve deadlock risklerini pratik örneklerle ele alacağız.

Hedefimiz net: Kodunuzun hem okunabilir, hem de sürdürülebilir olmasını sağlarken, “await ekledim ama sorun bitmedi” tuzağına düşmemeniz. Sonunda, doğru yerde doğru kalıpları kullanarak asenkron akışı yönetebileceksiniz.


Async/await temeli: Ne çözer, ne çözmez?

Async/await, C# dilinin asenkron akışı yönetmek için sunduğu bir söz dizimi kolaylığıdır. En basit haliyle, bir işlemi beklerken (await) thread’i bloklamadan, kontrolü çağırana geri vermenizi sağlar. Bu sayede UI uygulamalarında arayüz donmaz; sunucu uygulamalarında ise thread’ler gereksiz yere beklemez.

Burada kritik nokta şu: async/await “her şeyi hızlandıran” bir sihir değildir. Özellikle CPU yoğun (CPU-bound) işler için async/await tek başına performans artırmaz; asıl fayda I/O beklemelerinde (ağ, dosya, veritabanı) ortaya çıkar. Doğru problem tipini seçmek, asenkron yaklaşımın temelidir.

I/O-bound ve CPU-bound ayrımı

I/O-bound işlerde süreyi çoğunlukla bekleme belirler: HTTP çağrısı, disk erişimi, veritabanı sorgusu gibi. Bu tür işlerde await, thread’i serbest bırakır. CPU-bound işlerde ise işlemci gerçekten çalışır; burada paralelleştirme ve algoritma optimizasyonu daha belirleyicidir.

Async bir metot ne zaman gerçekten asenkron olur?

Bir metodun async olarak işaretlenmesi tek başına asenkron davranış sağlamaz. İçeride await edilen şeyin gerçek anlamda asenkron olması gerekir (ör. HttpClient.GetAsync). Aksi halde “async görünümlü senkron” kod elde edersiniz.

Bir geliştiricinin IDE üzerinde Task zinciri ve bekleme noktalarını inceleyerek akışı düzenlemesi

Task tabanlı düşünme: Akış, sonuç ve hatalar

C# ekosisteminde modern asenkron yaklaşımın merkezinde Task vardır. Task, “şu an devam eden veya tamamlanacak bir iş” fikrini taşır. async/await ise Task’leri daha okunur bir akışla kullanmanıza yardım eder.

Task ve Task<T> farkı

Task, geriye değer döndürmeyen işi temsil eder. Task<T> ise tamamlandığında bir sonuç döndürür. Bu ayrım, API tasarımınızın netliğini artırır: İş sonuç üretiyorsa Task<T> kullanmak, çağıranın niyetini açık eder.

Exception akışı: await ile hatalar nasıl davranır?

await edilen Task hata üretirse, exception çağrı noktasında fırlatılır. Bu, try/catch bloklarının “doğal” görünmesini sağlar. Ancak Task’leri await etmeden bırakmak (fire-and-forget) hata kaybına yol açabilir. Bu yüzden çoğu senaryoda “await et ve yönet” yaklaşımı güvenlidir.

Doğru kullanım kalıpları: Async all the way

Asenkron kodun en temel kuralı, zincirin bir noktasında senkrona dönüp bloklamamaktır. “Async all the way” dediğimiz şey, çağrı zincirinin uçtan uca async olmasıdır. Özellikle .Result ve .Wait() çağrıları, beklenmedik deadlock’ların ve thread tıkanmalarının başlıca sebebidir.

Basit ve gerçekçi bir HTTP örneği

Aşağıdaki örnek, I/O-bound bir işte async/await kullanımını gösterir. Önemli olan, çağıranın da await etmesi ve bloklamamasıdır.

using System.Net.Http;
using System.Threading.Tasks;

public class UserService
{
    private readonly HttpClient _httpClient;

    public UserService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<string> GetUserJsonAsync(int userId)
    {
        // I/O beklemesi: thread bloklanmaz
        var url = $"https://api.example.com/users/{userId}";
        var json = await _httpClient.GetStringAsync(url);
        return json;
    }
}

// kullanım
// var json = await userService.GetUserJsonAsync(42);

Async metot imzası tasarımı

Genel kural: Asenkron metotlar Task veya Task<T> döndürmelidir. Geriye void dönen async metotlar çoğunlukla tehlikelidir; istisnalar ise genellikle event handler gibi framework tarafından çağrılan noktalardır.

Deadlock riski: SynchronizationContext, .Result ve yanlış bekleme

Deadlock, birden fazla işin birbirini beklemesi nedeniyle ilerleyememe durumudur. C#’ta en sık görülen senaryo, UI thread’i veya belirli bir istek bağlamı (ör. klasik ASP.NET) üzerinde SynchronizationContext ile birlikte senkron bekleme yapılmasıdır.

Tipik örnek: UI thread’inde bir buton tıklamasında asenkron metodu çağırıp .Result ile sonucu almak. async metot, devamını UI thread’ine geri dönerek çalıştırmak ister; ama UI thread’i zaten .Result ile bloklanmıştır. Sonuç: deadlock.

Deadlock üreten örnek ve neden oluştuğu

// UI thread'i üzerinde çalıştığını varsayalım (WPF/WinForms)
public string LoadData()
{
    // .Result, thread'i bloklar
    return LoadDataAsync().Result;
}

public async Task<string> LoadDataAsync()
{
    // Buradaki await, continuation'ı varsayılan olarak aynı bağlama döndürmek ister
    await Task.Delay(200);
    return "ok";
}

Çözüm 1: Async zinciri korumak

En sağlıklı çözüm, senkron beklemeyi kaldırmak ve çağrı zincirini async yapmak:

  • UI katmanında event handler’ı async yapıp await etmek
  • Servis katmanında Task döndürüp “bloklamadan” yukarı taşımak
  • En altta gerçek I/O çağrılarını asenkron API’lerle yapmak

Çözüm 2: ConfigureAwait(false) ne zaman işe yarar?

Kitaplık (library) kodlarında, devamın belirli bir bağlama dönmesi gerekmiyorsa ConfigureAwait(false) kullanmak, deadlock riskini azaltır ve gereksiz bağlam geçişlerini önleyebilir. Ancak UI güncellemesi gibi “mutlaka UI thread’ine dön” gerektiren durumlarda dikkatli olunmalıdır.

Örneğin, alt seviyedeki veri çekme metodu bağlama bağımlı değilse şu yaklaşım tercih edilebilir:

public async Task<string> FetchAsync()
{
    await Task.Delay(200).ConfigureAwait(false);
    return "data";
}
UI akışında senkron beklemenin iş parçacığı kuyruğunu tıkayarak zincirleme gecikmeler üretmesi

Performans ve ölçek: Thread havuzu, bekleme ve yanlış paralellik

Asenkron yaklaşımın en büyük kazancı, bekleme sırasında thread’i boşa çıkarmasıdır. Sunucu tarafında bu, aynı donanımda daha fazla isteği karşılayabilmek anlamına gelebilir. Ancak her iş için “Task.Run” kullanmak, thread havuzunu gereksiz yere şişirerek tam tersine performans düşüşü yaratabilir.

Ne zaman Task.Run, ne zaman gerçek async?

Gerçek I/O çağrıları zaten asenkron API’lerle yapılmalı. CPU-bound bir işi UI thread’inden ayırmak için Task.Run düşünülebilir; fakat bu, “asenkron I/O” ile karıştırılmamalıdır. Task.Run, işi thread havuzuna atar; I/O beklemesi sırasında thread hâlâ o işi taşıyor olabilir.

Task.WhenAll ile doğru şekilde eşzamanlı bekleme

Birden fazla bağımsız I/O çağrısını ardışık yapmak yerine, aynı anda başlatıp birlikte beklemek toplam süreyi azaltabilir. Burada önemli olan, hepsini başlatıp WhenAll ile await etmektir:

public async Task<string[]> LoadMultipleAsync(HttpClient httpClient)
{
    var t1 = httpClient.GetStringAsync("https://api.example.com/a");
    var t2 = httpClient.GetStringAsync("https://api.example.com/b");
    var t3 = httpClient.GetStringAsync("https://api.example.com/c");

    // Hepsini aynı anda bekler
    var results = await Task.WhenAll(t1, t2, t3);
    return results;
}

İptal, timeout ve hata yönetimi: Üretim ortamı gerçekleri

Üretim ortamında asenkron kodun kalitesi, yalnızca “çalışıyor” olmasıyla ölçülmez. İptal edilebilirlik, zaman aşımı stratejisi ve hataların doğru şekilde sınıflandırılması gerekir. Aksi halde uzun süren çağrılar birikir, kaynaklar kilitli kalır ve sistem sağlığı bozulur.

CancellationToken ile kontrollü iptal

Uzun süren I/O çağrılarına iptal desteği eklemek, özellikle web isteklerinde istemci bağlantısı koptuğunda kaynakların boşa harcanmasını önler. Token’ı parametre olarak taşımak iyi bir pratiktir.

Timeout yaklaşımı: Tek yerde mi, katmanlarda mı?

Timeout’lar çoğunlukla sınır noktalarında yönetilir: HTTP istemcisi, veritabanı sürücüsü veya dış servis çağrıları. Katman katman ayrı timeout koymak yerine, sistemin genel davranışını tasarlayıp tek bir stratejiye bağlamak daha öngörülebilirdir.

Exception sınıflandırması ve yeniden deneme

Her hata yeniden denemeyi hak etmez. Ağ dalgalanmaları veya geçici servis kesintileri için kontrollü retry uygulanabilir; fakat doğrulama hataları veya iş kuralı ihlalleri için retry anlamsızdır. Burada hata türlerini ayırmak ve log/telemetriyi doğru kurmak önemlidir.

Bir serviste iptal, zaman aşımı ve hata izleme metriklerinin birlikte ele alınarak akışın kontrol altına alınması

Async void, event handler’lar ve saklı tuzaklar

async void, çoğu durumda kaçınılması gereken bir imzadır. Çünkü çağıran taraf bekleyemez, exception’ları doğal şekilde yakalayamaz ve kontrol akışı belirsizleşir. Buna rağmen UI event handler’ları gibi bazı framework giriş noktalarında async void kaçınılmaz olabilir; burada dikkatli davranmak gerekir.

Event handler dışında async void neden tehlikeli?

Bir metot “void” döndürdüğünde, işin ne zaman bittiğini bilmek zorlaşır. Ayrıca hata çıktığında, uygulama seviyesinde beklenmedik çöküşler görülebilir. Bu yüzden çoğu yardımcı metot Task döndürmelidir.

Katmanlı mimaride asenkron tasarım: Servis, repository, UI

Asenkron tasarım, en çok katmanlı mimarilerde değer üretir; çünkü her katmanda “bloklamadan” akış taşınır. Servis katmanı, repository katmanı ve UI katmanı arasında Task akışı tutarlı olursa, hem test edilebilirlik artar hem de performans daha öngörülebilir olur.

Repository katmanında async: Gerçek kazanç nerede?

Veritabanı sürücüsü veya ORM asenkron API sunuyorsa, repository katmanı bunu kullanmalıdır. Ancak “sırf async olsun” diye senkron çağrıyı Task.Run ile sarmak çoğunlukla doğru değildir; bu, thread havuzunu tüketebilir.

UI katmanında await ve geri dönüş bağlamı

UI güncellemesi gerektiğinde continuation’ın UI thread’ine dönmesi gerekir; bu yüzden UI katmanı, ConfigureAwait(false) yerine doğrudan await ile çalışmayı tercih edebilir. Alt katmanlar ise bağlama bağımlı olmadıkça ConfigureAwait(false) ile daha esnek hale getirilebilir.

En iyi pratikler: Kontrol listesi

Asenkron kod yazarken her seferinde sıfırdan düşünmek yerine, küçük bir kontrol listesiyle riskleri erken yakalayabilirsiniz:

  1. I/O-bound işlerde gerçek asenkron API kullanılıyor mu?
  2. Çağrı zincirinde .Result/.Wait gibi senkron bloklama var mı?
  3. Kitaplık kodlarında bağlama dönmeyi gerektirmeyen noktalarda ConfigureAwait(false) düşünülmüş mü?
  4. İptal (CancellationToken) ve timeout stratejisi net mi?
  5. Async void yalnızca zorunlu framework giriş noktalarında mı kullanılıyor?

Bu maddeler, hem deadlock risklerini hem de “yavaş yavaş biriken” ölçek problemlerini azaltır. Özellikle üretim ortamında, küçük bir bloklama çağrısı beklenmedik tıkanmalara dönüşebilir.


Sonuç: Doğru asenkron tasarım, doğru yerde başlar

C# async/await, doğru uygulandığında kodu sadeleştirir ve sistemin kaynak kullanımını iyileştirir. Ancak asıl kazanım, tasarım disiplini ile gelir: Task akışını zincir halinde taşımak, bloklamadan kaçınmak ve bağlam davranışını bilmek.

Eğer ekibinizde asenkron tasarım standartlarını oturtmak, deadlock vakalarını azaltmak ve modern C# pratiklerini kurumsal bir çerçevede ele almak istiyorsanız, ilgili eğitim içeriğine buradan göz atabilirsiniz: C# Eğitimi.

Bu yaklaşımları düzenli şekilde uygularsanız, “await yazdım ama yine donuyor” sorusu yerini daha öngörülebilir, test edilebilir ve sürdürülebilir bir mimariye bırakır.

 VERİ AKADEMİ