Software · Oct 9, 2023 · 10 dk okuma
Asenkron Programlama: .NET'te async/await Gerçekte Ne Yapıyor?
async/await yeni bir thread başlatmaz ve kodu otomatik olarak hızlandırmaz. .NET'te state machine, Task, Thread Pool ve I/O completion mekanizmalarının nasıl birlikte çalıştığını ve yanlış kullanımın neden deadlock veya performans sorunları yarattığını öğrenin.

C#'ta async/await, bir metodu başka bir thread üzerinde çalıştıran mekanizma değildir. Compiler, async metodu gerektiğinde duraklayıp daha sonra devam edebilen bir state machine'e dönüştürür. Bir await sırasında beklenen işlem henüz tamamlanmamışsa metodun yürütülmesi askıya alınabilir; o sırada çalışan thread başka işler için kullanılabilir. Asenkron programlamanın temel kazancı, özellikle I/O beklerken thread işgal etmemektir.
Bu ayrım .NET uygulamalarının kapasitesini doğrudan etkiler. Bir HTTP isteğinin veritabanından yanıt beklerken thread tutmasıyla thread'i serbest bırakması, tek bir istekte benzer süreler üretebilir. Ancak yüzlerce eşzamanlı istek altında sonuç değişir: Thread Pool baskısı, gecikme dağılımı ve sistemin taşıyabileceği yük farklılaşır. async/await anlayışı bu yüzden syntax bilgisinden çok çalışma zamanı davranışını anlamayı gerektirir.
Asenkron çalışmak başka bir thread kullanmak değildir
Synchronous ve asynchronous kavramları, bir işlemin sonucunun program akışına nasıl bağlandığını anlatır. Synchronous çağrıda çağıran kod, metodun tamamlanmasını bekleyerek ilerler. Asynchronous çağrıda ise işlem tamamlanmadan kontrol çağırana dönebilir; sonuç daha sonra bir Task üzerinden gözlemlenir.
Blocking ve non-blocking farklı bir ayrımdır. Blocking durumda thread, beklediği sonuç gelene kadar ilerleyemez. Non-blocking yaklaşımda bekleyen işlem tamamlanmamış olsa bile thread başka işlere dönebilir.
Bu iki ayrımı birbirine karıştırmak kolaydır. Asynchronous bir operasyonu .Result ile beklemek mümkündür; bu durumda asynchronous API kullanılmış olsa da çağıran thread blocking hale gelir. Tersine, synchronous bir metodun hızlı tamamlanması thread açısından uzun süreli bir blocking maliyeti yaratmayabilir.
Örneğin aşağıdaki çağrı synchronous bekleme gerçekleştirir:
var response = httpClient.GetAsync(url).Result;GetAsync bir Task<HttpResponseMessage> döndürür. Fakat .Result, sonuç hazır değilse çağıran thread'i bekletir.
Asenkron kullanımda ise:
HttpResponseMessage response = await httpClient.GetAsync(url);HTTP işlemi tamamlanmamışsa metodun devamı daha sonraya bırakılır. Aradaki sürede mevcut thread'in bu isteğe bağlı kalması gerekmez.
Burada bir nüans var: await her zaman metodun askıya alınmasına neden olmaz. Beklenen Task zaten tamamlanmışsa yürütme aynı thread üzerinde synchronous biçimde devam edebilir. await bir thread değiştirme komutu değildir.
Thread Pool beklemeyi değil, çalışmayı yönetir
Thread, işletim sisteminin CPU üzerinde zamanlayabildiği yürütme birimidir. Bir process içinde birden fazla thread bulunabilir. Her thread'in kendi call stack'i vardır; process içindeki heap gibi kaynaklar ise thread'ler arasında paylaşılabilir.
Thread oluşturmak ve yönetmek maliyetlidir. Stack alanı, işletim sistemi kaynakları ve context switch maliyetleri nedeniyle her HTTP isteğine yeni bir thread açmak iyi bir ölçekleme stratejisi değildir.
.NET bu nedenle Thread Pool kullanır. Thread Pool, yeniden kullanılabilen worker thread'leri yönetir ve çalışma zamanı ihtiyaca göre havuzun kapasitesini ayarlar. Bu, bütün thread'lerin sürekli aktif olduğu veya havuzun sınırsız büyüdüğü anlamına gelmez.
ASP.NET Core'da bir istek geldiğinde uygulama kodu genellikle Thread Pool worker üzerinde çalışır. Kod bir veritabanı sorgusuna ulaştığında iki farklı davranış ortaya çıkabilir.
Synchronous sorguda worker, veritabanından yanıt gelene kadar bekleyebilir. İşlem yüzlerce milisaniye sürse bile thread bu süre boyunca başka uygulama işlerini yürütemez.
Gerçek asynchronous sorguda ise veritabanı sürücüsü desteklediği asynchronous I/O mekanizmasını kullanır. İşlem tamamlanmadığında await metodun devamını askıya alır. İlgili worker havuza dönerek başka işleri çalıştırabilir.
Veritabanından yanıt geldiğinde I/O completion mekanizması işlemin tamamlandığını bildirir. Bekleyen Task tamamlanır ve metodun devamı uygun yürütme ortamında zamanlanır. ASP.NET Core'un normal request akışında bu devam genellikle Thread Pool üzerinde çalışır.
Önemli ayrım şudur: Uzak sunucu yanıt üretirken uygulamanızda o isteğe ayrılmış bir thread'in sürekli beklemesi gerekmez.
Bu mekanizma işletim sistemine ve kullanılan I/O API'sine bağlı ayrıntılar içerir. Windows üzerinde IOCP gibi completion tabanlı mekanizmalar devreye girebilir. Fakat bütün asynchronous API'lerin aynı kernel mekanizmasını kullandığını varsaymak doğru değildir.
Thread Pool starvation burada kritik hale gelir. Havuzdaki worker'lar uzun blocking işlemlerde tutulursa çalışmaya hazır diğer işler thread bulmakta gecikebilir. .NET yeni worker ekleyerek durumu hafifletmeye çalışır, ancak bu tepki anlık değildir ve sınırsız kapasite sağlamaz.
Bu nedenle düşük CPU kullanımı, uygulamanın rahat çalıştığını kanıtlamaz. Thread'ler dış sistemlerden yanıt beklerken uygulama gecikmesi büyüyebilir.
Compiler async metodu state machine'e dönüştürür
Task, gelecekte tamamlanabilecek bir operasyonu temsil eden nesnedir. Task sonuç taşımayan işlemler için, Task<T> ise T türünde sonuç üreten işlemler için kullanılır. Task bir thread değildir; bir işlemin tamamlanma durumunu, sonucunu veya hatasını gözlemleme yoludur.
Şu metodu düşünelim:
public async Task<string> GetNameAsync(int id)
{
var customer = await repository.GetCustomerAsync(id);
return customer.Name;
}Compiler bu metodu kabaca bir state machine olarak düzenler. Oluşturulan yapı metodun hangi aşamada olduğunu, devam etmek için gereken yerel değişkenleri ve beklenen operasyonun durumunu saklar.
Bu dönüşümün önemli parçalarından biri MoveNext metodudur. State machine ilerlediğinde ilgili kod parçasını çalıştırır. Beklenen operasyon tamamlanmamışsa devamın daha sonra çağrılmasını sağlayacak mekanizmayı kurar.
Gerçek compiler çıktısı daha karmaşıktır. Kavramsal akış ise şöyledir:
- Metot çağrılır ve ilk
awaitnoktasına kadar normal biçimde çalışır. - Beklenen
Tasktamamlanmışsa yürütme devam eder. - Tamamlanmamışsa state machine gerekli durumu saklar ve continuation kaydeder.
- Çağırana tamamlanmayı temsil eden
Taskdöner. - Operasyon tamamlanınca continuation çalışır ve state machine kaldığı yerden ilerler.
Buradaki continuation, beklenen işlemden sonra yürütülecek kodun devamıdır. Her continuation yeni bir thread gerektirmez. Ayrıca her await için mutlaka ayrı bir heap allocation yapılacağı da söylenemez; compiler ve runtime çeşitli optimizasyonlar uygulayabilir.
await sırasında exception oluşursa hata genellikle ilgili Task üzerinden aktarılır. Çağıran taraf await ettiğinde exception yeniden fırlatılır. Bu davranış, asynchronous kodun sıradan try/catch bloklarıyla yönetilebilmesini sağlar.
try
{
var customer = await repository.GetCustomerAsync(id);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "Remote request failed");
}Elbette GetCustomerAsync gerçekten HTTP kullanmıyorsa bu exception türü uygun olmayabilir. Yakalanacak exception, çağrılan API'nin davranışına göre seçilmelidir.
SynchronizationContext devamın nerede çalışacağını etkiler
await yalnızca bekleme davranışını değil, continuation'ın nereye gönderileceğini de ilgilendirir.
WPF ve Windows Forms gibi UI framework'lerinde bir SynchronizationContext, devamın UI thread'ine dönmesini sağlayabilir. Bunun nedeni UI bileşenlerinin çoğuna yalnızca onları oluşturan thread üzerinden erişilebilmesidir.
ASP.NET Core'da ise normal request işleme sırasında böyle bir request-specific SynchronizationContext bulunmaz. Continuation genellikle Thread Pool üzerinde çalışır; aynı thread'e dönmesi garanti değildir.
ConfigureAwait(false), uygun durumlarda mevcut context'e geri dönme talebini devre dışı bırakmak için kullanılır:
var result = await client.GetAsync(url).ConfigureAwait(false);Bu ifade işlemi başka thread'e taşımayı garanti etmez. Ayrıca ASP.NET Core uygulamasındaki her await ifadesine otomatik olarak eklenmesi gereken bir performans anahtarı değildir.
Library kodunda context bağımlılığından kaçınmak için anlamlı olabilir. UI kodunda ise devamın UI thread'inde çalışması gerekiyorsa yanlış tercih olabilir.
Task.Run I/O işlemini daha asynchronous yapmaz
Asenkron programlamanın en yaygın yanlış açıklamalarından biri, await edilen kodun arka planda ayrı bir thread üzerinde çalıştığı iddiasıdır. Bu açıklama, asynchronous I/O ile Thread Pool'a iş göndermeyi aynı mekanizma gibi gösterir.
Task.Run, verilen işi Thread Pool üzerinde çalıştırılmak üzere zamanlar. Bu davranış CPU-bound işler için yararlı olabilir; ancak zaten asynchronous olan bir I/O operasyonunu sarmalamak çoğunlukla gereksizdir.
Şu kullanım buna örnektir:
var customer = await Task.Run(
() => repository.GetCustomerAsync(id));Burada Task.Run, repository çağrısının başlangıcını Thread Pool'a taşır. Repository zaten gerçek asynchronous I/O gerçekleştiriyorsa bekleme sırasında ek bir worker'a ihtiyaç yoktur. Bu sarmalama scheduling maliyeti ekler ve çoğu ASP.NET Core senaryosunda kapasite kazandırmaz.
Daha doğru kullanım şudur:
var customer = await repository.GetCustomerAsync(id);I/O-bound işlerde temel kaynak beklenen dış sistemdir: disk, network veya veritabanı. CPU-bound işlerde ise işlemci zamanı tüketilir. Büyük bir dosyanın hash değerini hesaplamak, yoğun görüntü işlemek veya karmaşık hesaplama yapmak CPU-bound işlere örnektir.
Bir UI uygulamasında uzun CPU-bound işlemi Task.Run ile worker thread'e taşımak arayüzün yanıt vermeye devam etmesini sağlayabilir:
var hash = await Task.Run(() => CalculateHash(data));Ancak bu işlem toplam CPU ihtiyacını azaltmaz. Sadece hesaplamayı UI thread'inden uzaklaştırır.
ASP.NET Core tarafında ise CPU-bound işi gelişigüzel Task.Run ile sarmalamak genellikle fayda sağlamaz. Request zaten Thread Pool üzerinde çalışırken başka bir worker'a iş aktarmak, CPU kapasitesini artırmaz.
Concurrency ve parallelism ayrımı da burada belirleyicidir. Concurrency, birden fazla işin ilerlemesinin zaman içinde iç içe geçebilmesidir. Parallelism ise birden fazla işlemin aynı anda gerçekten yürütülmesidir.
var firstTask = client.GetAsync(firstUrl);
var secondTask = client.GetAsync(secondUrl);
await Task.WhenAll(firstTask, secondTask);Bu kod iki HTTP operasyonunun bekleme sürelerini örtüştürebilir. İki ayrı uygulama thread'inin sürekli çalışması gerekmez. İşlemler concurrent olabilir; CPU üzerinde parallel hesaplama yapmaları şart değildir.
Buna karşılık Parallel.ForEach veya Parallel.ForEachAsync kullanımı, iş yükünün niteliğine ve yapılandırmaya göre farklı concurrency ve parallelism davranışları üretir. Parallel.ForEachAsync, asynchronous delegate'lerle çalışabilir ve eşzamanlılık sınırı uygular; fakat sınırsız I/O concurrency aracı değildir.
Bir HTTP endpoint'inde doğru karar nerede verilir?
Bir ASP.NET Core endpoint'inin müşteri kaydını veritabanından okuduğunu, ardından başka bir servisten risk bilgisi aldığını düşünelim. Gerçek çalışma nesneleri bir HTTP request, bir veritabanı connection'ı, bir HttpClient çağrısı ve response payload'ıdır.
app.MapGet("/customers/{id:int}", async (
int id,
AppDbContext db,
HttpClient http,
CancellationToken cancellationToken) =>
{
var customer = await db.Customers
.AsNoTracking()
.SingleOrDefaultAsync(
x => x.Id == id,
cancellationToken);
if (customer is null)
return Results.NotFound();
var risk = await http.GetFromJsonAsync<RiskResponse>(
$"risk/{customer.RiskId}",
cancellationToken);
return Results.Ok(new
{
customer.Id,
customer.Name,
Risk = risk
});
});Bu örnekte EF Core sorgusu ve HTTP çağrısı sıralıdır. Çünkü ikinci çağrı için gereken RiskId, önce veritabanından okunur. Task.WhenAll kullanmak bu bağımlılığı ortadan kaldıramaz.
İki bağımsız I/O operasyonu olsaydı önce ikisini başlatıp sonra Task.WhenAll ile beklemek toplam latency'yi azaltabilirdi. Sıralı iki bağımsız beklemenin toplam süresi yaklaşık sürelerinin toplamına yaklaşırken, concurrent yürütmede kritik bekleme süresi daha uzun operasyonun süresine yaklaşabilir. Bu ilişki connection pool beklemeleri, scheduling ve servis kapasitesi nedeniyle her zaman birebir gerçekleşmez.
Buradaki önemli kısıt bağlantı havuzlarıdır. Aynı anda daha fazla sorgu başlatmak, veritabanının daha fazla sorguyu aynı hızda bitireceği anlamına gelmez. Connection pool dolduğunda istekler bağlantı beklemeye başlayabilir.
Aynı DbContext instance'ı üzerinde birden fazla operasyonu eşzamanlı çalıştırmak da desteklenen kullanım değildir. EF Core DbContext, concurrent operasyonlar için thread-safe değildir. Bağımsız sorguları parallel başlatmak istiyorsanız ayrı context instance'ları ve veritabanı kapasitesi gerekir.
Bu nedenle Task.WhenAll bir performans garantisi değil, dependency yapısı uygun olduğunda kullanılabilecek concurrency aracıdır.
Cancellation da endpoint'in davranışının parçasıdır. HTTP istemcisi bağlantıyı kapattığında veya request iptal edildiğinde CancellationToken aşağı katmanlara iletilirse gereksiz işlemlerin durdurulması mümkün olabilir. İptal her API'de anlık değildir ve uzak sistemin işi kesinlikle durdurduğunu garanti etmez.
Benzer şekilde timeout, retry ve concurrency limitleri asynchronous mimarinin dışında düşünülemez. Yavaşlayan bir servise sınırsız sayıda concurrent request göndermek, thread'leri serbest bıraksa bile socket, connection ve uzak servis kapasitesini tüketebilir.
Bu noktada SemaphoreSlim gibi araçlarla eşzamanlı operasyon sayısını sınırlamak gerekebilir. Sınırın değeri rastgele seçilmemeli; downstream servis kapasitesi ve ölçülen latency dikkate alınmalıdır.
Async zinciri koruyun, fakat her işi async yapmayın
Deadlock, birden fazla yürütme akışının birbirinin ilerlemesini beklediği ve bu nedenle hiçbirinin devam edemediği durumdur. async/await kullanmak deadlock ihtimalini otomatik olarak ortadan kaldırmaz.
Özellikle UI uygulamalarında şu örnek tehlikelidir:
public void LoadButton_Click(object sender, EventArgs e)
{
var result = LoadDataAsync().Result;
label.Text = result;
}LoadDataAsync içindeki await, UI SynchronizationContext üzerinden devam etmeye çalışıyorsa continuation UI thread'ini bekler. UI thread'i ise .Result ile Task tamamlanmasını beklemektedir. İki taraf birbirini beklediğinde deadlock oluşabilir.
ASP.NET Core'da varsayılan request akışı aynı SynchronizationContext modelini kullanmadığı için bu spesifik deadlock mekanizması genellikle geçerli değildir. Buna rağmen .Result ve .Wait() kullanımı Thread Pool starvation oluşturabilir. Lock sıralaması, kaynak beklemeleri ve circular dependency nedeniyle asynchronous kodda başka deadlock türleri de mümkündür.
Temel tercih, asynchronous zinciri çağrı sınırına kadar korumaktır:
public async Task<string> LoadDataAsync()
{
return await repository.ReadAsync();
}Bir çağrı zincirinde asynchronous I/O varsa çağıran metodun da sonucu await edebilmesi genellikle doğru tasarımdır. Ancak bu yaklaşım her metoda async eklemek anlamına gelmez. Bekleme veya asynchronous kaynak yönetimi gerektirmeyen saf hesaplama metodları synchronous kalabilir.
Bir metod yalnızca başka bir Task döndüren metodu aktarıyorsa async/await katmanı her zaman gerekli değildir:
public Task<Customer> GetCustomerAsync(int id)
{
return repository.GetCustomerAsync(id);
}Yine de exception handling, using kapsamı, finally davranışı veya sonuç üzerinde ek işlem gerekiyorsa doğrudan Task döndürmek ile await etmek semantik olarak farklılaşabilir. Mikro optimizasyon uğruna bu farkı kaybetmemek gerekir.
Benim önerim, .NET server kodunda gerçek asynchronous I/O API'lerini doğrudan await etmek, bağımsız operasyonları ölçülü biçimde concurrent çalıştırmak ve blocking beklemelerden kaçınmaktır. Task.Run kullanımını ise UI responsiveness veya bilinçli CPU-bound scheduling ihtiyacıyla sınırlamak daha savunulabilir bir tercihtir.
Bu öneri, kullanılan API'nin gerçekten asynchronous çalıştığı varsayımına dayanır. Bir provider yalnızca synchronous işi Thread Pool üzerinde çalıştırarak asynchronous görünüm sunuyorsa thread tasarrufu beklenmemelidir. Aynı şekilde CPU-bound hesaplama baskınsa async/await işlemci maliyetini düşürmez; parallelism, worker kapasitesi veya ayrı background processing mimarisi gerekir.
Kararı syntax üzerinden değil, thread'in hangi anda neyi beklediği üzerinden verin. I/O completion bekleyen bir işin thread tutmasına gerek yoktur. CPU üzerinde hesap yapan işin ise bir yürütme kaynağına ihtiyacı vardır. .NET'te asynchronous tasarımın doğru sınırı bu fiziksel farktan çıkar.
yorumlar
0Henüz yorum yok. İlk notu sen bırak.
Yorum yazmak için giriş yap.
Gmail ile saniyeler içinde, ya da e-posta ile kaydol.