15 Mart 2021 Pazartesi

Asenkron Programlama Notlarım

       S.A. Arkadaşlar,

       Uzun süredir yazmayı düşündüğüm ve notlar topladığım bir yazım olacak. Beni heyecanlandıran bir konu ve umarım siz okuyanlar için de güzel bir içerik olur. Uygar Manduz'un şirket içi yaptığı eğitim sonrası notlarım iyice olgunlaştı ve son düzenlemelerle birlikte artık yayınlamam gerektiğine iyice ikna oldum. Önceden uyarayım, burada yazılan her bir konu bir yazı olabilecek uzunluktadır. Konuyu derli toplu olması açısından genel bir çerçevede notlarımızı paylaşmak istedik. Öyleyse başlayalım...

       Yazıyı yazmaya başlamadan önce kendi aldığım notlarla birlikte onlarca makaleden toplamış olduğum notlar mevcuttur. Bazı noktalarda eklemeler düzenlemeler yapsam da asıllarını ayrıca paylaşacağım. 

      Bu konunun tarihçesi ve gelişimine bakıldığında çok daha karmaşık olduğu anlaşılır, fakat özellikle C# 5 ile gelen yeni özellikler vesilesiyle kullanım oldukça kolaylaşmıştır. Yapılan bu soyutlama sonrası kullanım kolaylaşsa da derinlemesine öğrenim de bununla birlikte maalesef azaldı. 

      Öncelikle konunun daha iyi anlaşılması için paralel programlamada amaç performansı geliştirmek değil, uygulamanın aynı anda birden (aynı an desek de çok kısa sürelerde geçişler yaparak belirli bir işi beklemeyi azaltmak) fazla işi yapabilmesidir. Bununla birlikte performans artışı da olursa bu da ayrıca güzel bir kazanım olacaktır.

    .net ortamında paralel programlama denilince akla 3 terim gelmelidir. Bunlar:

  •  Thread: Gerçek thread'i temsil eder.
  •  Thread Pool: Thread'lerin bulunduğu havuzdur.
  •  Task: Yapılması gereken işlemleri temsil eder.

    Bir task'i oluşturmanın ise yine 3 yolu vardır. Bunlar da aşağıdaki gibidir:

//Bir obje oluşturma ile
var task = new Task<int>(...);
//Task.Factory.StartNew ile
var task = Task.Factory.StartNew<int>(...);
//Task.Run metodu ile
var task = Task.Run<int>(...);

    Diğer bir konuya geçmeden önce Task ile benzer bir yapıda olan fakat .netcore ile gelen TaskValue yapısı da mevcuttur. Bazen async metodların içerisinde sync metodlar da kullanmak zorunda kalabiliriz. Bu tarz durumlarda her defasında task tekrar tekrar üretilmesin diye performans artışı açısından kullanılması tavsiye edilmektedir. Belirli avantajları olsa da hatırı sayılır dezavantajları da vardır. [Dahası]     

      Yukarıdaki yöntemler kullanılıp çıktılar alındığında tahmin edeceğiniz üzere çıktıları farklı olacaktır. Bunları sıralıyla yapmaya ihtiyacımız var ise aşağıdaki yöntemlerden bazılarıyla kullanılabilir, fakat bunu yaparken dikkat etmeniz gereken bir nokta ise buna gerçekten ihtiyacınızın olup olmamasının iyi belirlenmesidir.

Wait -> task1.Wait();
WaitAll -> Task.WaitAll(task2,task3) -> ikisi de tamamlanmalı
WaitAny -> Task.WaitAny(task4, task5);-> en az biri tamamlanmalı - linq'deki Any ile benzer mantık.

      Bazen de bir kısım işlemler tamamlandıktan sonra ardından başka işlemler ile devam etmesini isteriz. Bu tarz işlemler için de continue ile başlayan bazı ifadeler ise aşağıdaki gibidir. await ifadesinin bunun yerine kullanılması daha güzel bir yöntem olacaktır. [Dahası]

ContinueWith 
ContinueWhenAny -> Herhangi bir thread tamamlandığında
ContinueWhenAll -> Tüm thread'lar tamamlandığında

     Task'ı durdurmak için ise cancellation oluşturmak ve bunun üzerinden ilerlemek doğru olacaktır. Yılalr önce yazılmış, fakat güncelliğini koruyan bu yazıya göz atabilirsiniz.

var ts = new CancellationTokenSource();
CancellationToken ct = ts.Token;
//codes
ts.Cancel();

    Atomatic Operation: Araya hiçbir şekilde işlem almayan operasyonlardır. Yapılan bir atama işlemi buna örnektir. value +=5 ise bir atomatic işlem değildir. Çünkü burada önce toplama işlemi yapılıp atama sonra yapılmaktadır. Çok basit gibi görünen bu işlem bile thread'ler söz konusu olduğunda karmaşıklaşıyor. Bunun nedeni bir işlem yapılırken diğer işlem araya başka bir thread ve başka bir işlemin girebileceğidir. Örnekle genişletecek olursak thread1 value değişkenin 5 değerini ekledi ama o sırada thread2 value değişkeniyle ilgili başka bir işlem yapacak olursa thread1 tekrar işlem yapacak olduğunda value değişkenini bıraktığı gibi bulamayacaktır ve bu da bir yanlışa sebebiyet verebilir. Buna da race condition denir.

    Critical Section: Yukarıda bahsettiğimiz problemlerin oluşmaması için value değişkenin bir erişim varken diğer thread'lerin beklemesidir.

     Task'leri eş zamanlı çalıştırmanın bazı yöntemleri aşağıdaki gibidir.

  • Lock/Monitoring: Dummy proje oluşturup scope içerisinde işlem yapar.
  • SpinLock: Çekirdeğin başka bir işlem yapmasını önler.
  • ReaderWriterLock: Okuma yapılırken bekletilmez, sadece yazma işlemi yaparken bekletilir.
  • Interlocked: Bekletme yapmadan primative tipler üzerinde işlemler yapılır.
  • Mutex: Farklı işlemler arasında çalışabilir.

    Yukarıda bahsettiğimiz atomaic olmayan bir işlemde bile böyle tehlikeler varken daha karmaşık işlemlerde tabii ki zorluklar olacaktır. Koleksiyon kütüphanesi ile çalışırken de farklı kaynaklardan erişim konusundaki sıkıntıları çözmek için bazı geliştirmeler mevcut. Bunlardan bazıları:

  • ConcurrentBag: Çanta gibi düşünebilir. Sıralama yapmaksızın ekleme yapar.
  • ConcurentDictionary: Dictionary mantığı ile aynı ama sıralamaya sadık kalır.
  • ConcurentQueue ve ConcurrentStack yine benzer yapılardandır. 

    Farklı task'ler içerisinde işlemleri birbirinden ayırmak ve bunların senkron olarak doğru çalışabilmesi için aşağıdaki kavramlara göz atmakta fayda var.

    Başka bir konu da paralel metod ve yapılar kullanma ihtiyacıdır. C# kodlarken denk geldiğiniz bir yapı da aşağıdaki kod olabilir.

Parallel.For(0, 20, (i) =>
{
      Console.WriteLine(i);
});

    Peki task oluşturup onları kullanarak bunu ilerletemez miyim? Tabii ki yapılabilir. Task oluşturmak ve bunları da yönetmenin belirli maliyetleri vardır. Yukarıdaki benzer yapılar optimize edilmiş ve rahatlıkla kullanılabileceğimiz yapılardır. 

     Ayrıca her döngüyü de paralel yapmak doğru bir davranmış olmayabilir. Yukarıda da belirttiğimiz gibi bunun da belirli bir maliyeti var. Özellikle çok kısa sürecek işlemler için bu tarz işlemlerden uzak durmak daha doğru olacaktır. Yoksa attığınız taş vurduğunuz kuşa değmeyecektir. 

    Tüm bunları anlattık da async - await yapısına geçebiliriz artık. Yukarıda bahsettiğimiz birçok yapı ile istediğimiz işleri rahatlıkla yapabiliriz, fakat burada hem kodu yazmak hem de karmaşıklığı azaltmak adına async - await yapısını kullanmak çok daha elverişli olacaktır. Yukarıda da belirttiğimiz gibi ContinueWith ile benzer mantıkta çalışmaktadır. Kullanım kolaylıkları dışında temelde birkaç farkı daha vardır. Task sınıfındaki wait metodu thread'i kitlerken, await ise duraklama moduna başka bir thread üzerinden yürüttüğü için o arada diğer işlemler devam edebilmektedir. UI kullanılan uygulamalarda bu net bir şekilde gözükmektedir. [Dahası]

    C# async programlama için 3 farklı pattern vardır. İlk ikisi artık modası geçmiş olarak kabul edilmektedir. Sonuncusu ve aslında şuan hepimizin bildiği Task (async - await) yapısı önerilmektedir. Bunlar aşağıdaki gibidir: [Dahası]

  • Asynchronous programming model (APM): legacy
  • Event-based asynchronous pattern (EAP): legacy
  • Task-based asynchronous pattern (TAP): RECOMMENDED

    Concurrent - Paralel -Async: [Dahası] Bu 3 kavram birbirine benzese de hatta birbirine çok karıştılsa da temelde birbirinden farklı özellikleri içerisinde barındırıyor. Bir örnek üzerinden açıklayacak olursak; bir form uygulamasından sync bir butona tıklar ve diğer tüm işlemler beklemeye geçer. Concurrent ise bir butona tıklar ve gezme işlemini yapmaya devam eder. Paralel olan ise aynı zamanda farklı thread'lerde iş yapabilir. Async ise bir işi yaparken diğer bir işin sonucu beklemez. Özetle;

  • Sync: Bir iş yapar, sonra diğerlerini yapar.
  • Concurrent: Aynı anda birden çok iş yapar.
  • Parallel: Aynı anda bir kopya üzerinden birden çok iş yapar.
  • Async: Başka bir işe başlamak için diğer işin bitmesini beklemez.

     Bazı best practice'ler: [Buradan]

  • Avoid async void method unless you use it for an event handler - Task veya Task<T> kullanılmalıdır - Tek istisna delegete kullanımıdır.
  • Avoid mixing async and sync methods together - Async ve sync metodlarını birlikte kullanmaktan kaçınılmalı.
  • Return Task, not return await - Sadece bir tane async method çağırılacaksa async-await kullanıp ekstra maliyete sebebiyet vermemelisin.
  • What if we have to block the async code? - Bir işi bekleteceksen Task.Wait() yerine Task.GetAwaiter().GetResult() kullan, problemi anlamak daha kolay olacaktır. Belki burada farklı olarak await kullanmak en doğrusu olabilir.
  • Use Task.WaitAll() to run multiple tasks - Birbirinden bağımsızları task'leri bu şekilde, bağımlıları ise Task.WaitAny() ile çağırabilirsin.
  • Use ConfigureAwait(false) to avoid deadlock – (.net core 3.1'den itibaren varsayılan olarak false geliyor) [Dahası]   

Bazı güzel içerik önerileri:

  • David Fowler: Microsoft'a danışmanlık veren çok deneyimli bir yazılımcının github notları
  • https://blog.stephencleary.com: Bu konuda gördüğüm en dolgun web sitesi olabilir. Kendisi MVP olup bu konularla ilgili inanılmaz derecede çok yazısı var. Hatta sadece bu konuyla ilgili kitabı var. Bloglarının altında da inanılmaz değerli yorum ve cevaplar var. 
  • Microsoft Offical: Resmi siteyi de atlamak olmaz. Burada da gayet güzel anlatım mevcut.
  • http://www.albahari.com/threading: Baştan sona anlatım bulunan çok değerli bir site.
  • İlker Erhalim Medium: Yazının temel yapısını oluşturan yazı serisini buradan okuyabilirsiniz. Türkçe olarak gördüğüm en iyi içerik diyebiliriz.
     Ne yapıyorsak en iyisini yapmak dileğiyle. Hoşça kalın.


Hiç yorum yok:

Yorum Gönder