C# Asenkron Hataları

C# ile asenkron operasyonlar çoğu zaman işimize oldukca yarıyor fakat bazı durumlarda asenkron kullanmanın yarardan çok zararı olabiliyor. Aşağıdaki bir kaç maddede dikkat etmeniz gereken bazı maddeleri özetledim.

1-)Await kullanmayı unutmak

Task döndüren asenkron bir methodu çağırdığınızda await kullanmazsanız programınız ilgili satırda beklemeden akışına devam edecektir. Bu durum çoğu zaman istenmeyen sonuçlara yol açabilir.

Console.WriteLine("Before");
Task.Delay(1000);
Console.WriteLine("After");

Bu kodu çalıştırdığımızda program Before yazdıktan sonra 1000ms beklemeden After yazacaktır çünkü 2. Satırdaki async method çağırısında await kullanılmamış. Eğer 2. Satırdaki asenkron operasyon bitmeden kod akışının devam etmesini istemiyorsak çağrının başına await ekleyerek bu durumun önüne geçebiliriz.

2-)Taskları ignore etmek

Bazı durumlarda asenkron method’un dönüşüne ihtiyacımız yoktur örneğin asenkron bir şekilde mail atan bir Task SendMailAsync() methodumuz var ve bu methodun dönüşündeki veriye ihtiyacımız olmadığını düşünüyoruz. Bu istenmeyen bir durumdur çünkü herhangi bir exception durumunda müdehale edemeyiz. Doğru olan ilgili methodun dönüşünü yakalayıp exception handling yapmak olacaktır.

Hatalı durum:

DoSomethingAsync();

Olması gereken:

var result = DoSomethingAsync();

Bu sayede result.Exception ile exception‘a erişebiliriz.

3-) async void methodlar

Bir method içerisinde asenkron bir method  çağırabilmek için methodu async olarak işaretlememiz gerekir.

public async void MyMethod()
{
	await DoSomethingAsync();
}

Bu kod parçacığındaki sorun şudur ki eğer başka bir yerde MyMethod methodunu çağırmamız gerekirse await kullanamayız çünkü MyMethod methodu geriye Task döndürmüyor. Bu yüzden eğer zorunda değilsek async void yerine async Task kullanmak daha iyidir.

Peki bu async void methodları ne zaman kullanacağız dediğinizi duyar gibiyim. .NET framework içerisinde bazı durumlar buna uygundur örnek vermek gerekirse event handler’lar.

Aşağıdaki örnekte bir winform veya wpf uygulamasına ait bir event handler görebilirsiniz.

public async void OnButton1Clicked(object sender, EventArgs args)
{
	await LoadDataAsync();
	// update UI
}

4-) Taskları .Result veya .Wait() ile bekletmek

Diğer bir çok kullanılan yöntem ise senkron methodlardan asenkron methodları çağırırken .Result veya .Wait() kullanımı;

public void SomeMethod()
{
	var customer = GetCustomerByIdAsync(123).Result;
}

İlk başta kullanışlı gelen bu yönteminde bazı sıkıntıları bulunmaktadır. Yukarıdaki kod örneğinde .Result diyerek beklettiğimiz bu method aynı senkron bir method gibi akışı bloklayacaktır. Diğer bir problem ise şudur ki çok fazla .Result ve .Wait() kullanırsak programımızın çeşitli deadlocklara sokabiliriz.

Evet sırf bir asenkron method kullanmamız için çok fazla kod değişikliği yapmaktan kaçınıyor olabiliriz ama olması gereken programımızı uçtan uca asenkron tasarlamamız gerektiğidir.

Sorun:

class CustomerHelper
{
    private readonly Customer customer;
    public CustomerHelper(Guid customerId, ICustomerRepository customerRepository)
    {
        customer = customerRepository.GetAsync(customerId).Result; // avoid!
    }
}

Çözüm:

class CustomerHelper
{
    public static async Task<CustomerHelper> CreateAsync(Guid customerId, ICustomerRepository customerRepository)
    {
        var customer = await customerRepository.GetAsync(customerId);
        return new CustomerHelper(customer)
    }

    private readonly Customer customer;
    private CustomerHelper(Customer customer)
    {
        this.customer = customer;
    }
}

5-) ForEach() ile async kullanımı

ForEach(), List<T> tipi için kullanabileceğimiz kullanışlı ve kolay bir linq methoddur. Parametre olarak Action<T> tipinde parametre alır ve ilgili actionu list’in her elemanı için çalıştırır. Fakat bu methoda asenkron bir action göndermek istediğimizde bazı sorunlar oluşabilmektedir.

customers.ForEach(c => SendEmailAsync(c));

Bu kodu incelediğimizde ilk başta hiç bir sorun yok gibi gözüküyor.

foreach(var c in customers)
{
	SendEmailAsync(c); // the return task is ignored
}

Evet bu iki kodda aynı işi yapıyor gibi gözüküyor ve gerçektende öyleler ama bir sorun var. SendEmailAsync(); methodu maili gönderiyor ve dönüşü bizim için önemsiz ya önemli olsaydı ya da  mail gönderme operasyonun bittiğinden emin olmamız gerekseydi ne yapmamız gerekirdi? Başına await ekleyip bekletmemiz gerekiyordu. Fakat yukarıdaki kısa foreach dönüş olarak void döndürüyor yani ilgili Task’e erişemiyoruz. Bu durumda await kullanamıyoruz. Eğer bu tarz bir operasyona ihtiyacımız varsa çözüm klasik bir foreach döngüsü.

foreach(var c in customers)
{
	var result = await SendEmailAsync(c);
}

6-) Aşırı paralleştirme

foreach(var o in orders)
{
	await ProcessOrderAsync(o);
}

Yukarıdaki asenkron çağırıyı hızlandırmak isteysedik napardık?

var tasks = orders.Select(o => ProcessOrderAsync(o)).ToList();
await Task.WhenAll(tasks);

Bu şekilde taskleri başlatıp ardından hepsini bekletebiliriz bu sayede bütün tasklerimiz asenkron şekilde çalışıyor bir sonraki satırdada hepsinin bittiğinden emin oluyoruz.

Evet bir sorun yok gibi gözüküyor ama ya order sayımız 10.000 olsaydı ne olurdu? Ya her order için bir mikroservisi çağıyor olsaydık ne olurdu? Yüksek ihtimal ilgili mikroservisi bir DoS saldırısı yapıyorcasına boğmaya başlayacaktık 😊 Bu ve bu tür nedenlerden ötürü aşırı paralleştirmeden kaçınmakta fayda var. Olması gereken ise çalışacak Task sayısını limitlemek.

Bazı örnek limit algoritmaları:

1-) ConcurrenctQueue

var maxThreads = 4;
var q = new ConcurrentQueue<string>(urls);
var tasks = new List<Task>();
for(int n = 0; n < maxThreads; n++)
{
    tasks.Add(Task.Run(async () => {
        while(q.TryDequeue(out string url)) 
        {
            var html = await client.GetStringAsync(url);
            Console.WriteLine($"retrieved {html.Length} characters from {url}");
        }
    }));
}
await Task.WhenAll(tasks);

2-) Parallel.ForEach

var options = new ParallelOptions() { MaxDegreeOfParallelism = maxThreads };
Parallel.ForEach(urls, options, url =>
    {
        var html = client.GetStringAsync(url).Result;
        Console.WriteLine($"retrieved {html.Length} characters from {url}");
	});

 

7-) async versiyonunu kullanmamak

.NET Framework içerisinde çoğu disk, network gibi bir çok operasyon için async methodlar bulunmaktadır. Bu methodların sync halleri yerine async hallerini kullanmak daha doğru olacaktır.

Örnek olarak fileStream.Read() yerine fileStream.ReadAsync(), context.SaveChanges yerine context.SaveChangesAsync() kullanabiliriz. Bu sayede elimizdeki thread kaynağını boş yere meşgul etmeyip i/o bağımlı uygulamalarda daha iyi bir performans elde edebiliriz.

8-) await’siz try catch kullanmak

Varsayalım ki giriş yapmış kullanıcılara mesaj göndermemiz gerekiyor ve bunun için bir method hazırladık.

public Task SendUserLoggedInMessage(Guid userId)
{
    var userLoggedInMessage = new UserLoggedInMessage() { UserId = userId };

    return messageSender.SendAsync("mytopic", userLoggedInMessage);
}

Bu methoddaki exceptionları loglamak istiyoruz ve try catch bloğu ekledik.

public Task SendUserLoggedInMessage(Guid userId)
{
    var userLoggedInMessage = new UserLoggedInMessage() { UserId = userId };

    try
    {
        return messageSender.SendAsync("mytopic", userLoggedInMessage);
    }

    catch (Exception ex)
    {
        logger.Error(ex, "Failed to send message");
        throw;
    }
}

Herşey güzel duruyor değil mi? Ama bir sorun var. Bu kod hiç bir zaman catch bloğuna girmeyecek çünkü SendAsync methodu asenkron olduğu için içerisinde meydana gelen exceptionları Task içinde döndüryor. Bu durumda uygulamamız gereken method aşağıda ki gibi olacaktır.

public async Task SendUserLoggedInMessage(Guid userId)
{
    var userLoggedInMessage = new UserLoggedInMessage() { UserId = userId };

    try
    {
        await messageSender.SendAsync("mytopic", userLoggedInMessage);
    }

    catch (Exception ex)
    {
        logger.Error(ex, "Failed to send message");
        throw;
    }
}