Asenkron & Multithread Programlama

Asenkron metotlar çok daha fazla isteği karşılayabilir.

Cihat Solak
Doğuş Teknoloji
9 min readAug 20, 2022

--

Asenkron & Multi-thread Programlama

Asenkron Programlama (Asynchronous Programming)

Bir ya da birden fazla thread ile uygulanabilen, thread’in bloklanmadığı (non-blocking) programlama yöntemidir. Bu yöntem tek bir thread kullanımıyla yapıldığı gibi birden fazla thread kullanımıyla da yapılabilir. 👨🏽‍💻 Dolayısıyla zorunlu olarak bir adet thread veya birden fazla thread ile asenkron programlama yapılır! diyemeyiz. 🚦Non-blocking ifadesi ise şunu anlatır: Asenkron metoda istek yapmak için kullanılan thread bloklanmayacak ve o thread (metoda istek yapılan) farklı işlerle de ilgilenip yaşamına devam edebilecektir.

Multi-thread Programlama

Bir işin/işlerin birden fazla thread tarafından gerçekleştirilmesidir. Örneğin, 5 adet .csv uzantılı metin dosyasını senkron okuyacağımızı düşünelim. Her bir .csv uzantılı dosyayı okumak ortalama 50 saniye sürse, işlemin tamamlanması kaba hesap, 50 saniye x 5 dosya = 250 saniye⌛sürecektir. Ancak multi-thread programlama yardımıyla 5 adet .csv uzantılı dosyayı 5 farklı thread kullanarak okursanız ortalama 50 saniye içerisinde tüm işlem tamamlanacaktır. (Süreler üç aşağı beş yukarı şeklinde verilmiştir.) Konuyu bu şekilde açıklayınca multi-thread programlama her koşulda performanslıdır, aman! ben her kodu multi-thread şekilde yazayım diyorsan, demediğini varsayıyorum. Çünkü altın kural 💎 neydi? Bir şeyin güzel olması senin senaryona olan uygunluğu ile doğru orantılıdır. İlerleyelim, ileri de daha güzel şeyler var.

Synchronous vs Asynchronous

Asenkron programlama ile multi-thread programlama aynı şey değildir. Multi-thread işlerin birden fazla thread tarafından eş zamanlı olarak gerçekleştirilmesiyken, Asenkron programlama ana thread’i bloklamayan (non-blocking) programlamadır.

Windows Application — UI Thread

Gerçek hayatta thread kelimesinin üç farklı şekilde isimlendirildiğini görebiliriz. Bunlar UI Thread, Main Thread, Primary Thread. Genellikle UI Thread isimlendirmesi herhangi bir ara yüze sahip (Android App 📱, Windows App vb.) uygulamalarda kullanılırken, API projelerinde ise ara yüz bulunmadığından Main Thread veya Primary Thread isimlendirmesi kullanıldığını görürüz.

Thread konusunu ele alırken günlük hayatta herkesin haşır neşir olduğu anti virüs programlarından bahsedelim. Programı ilk çalıştırdığımız anda bir thread ile açılış yapılır. Bu tür uygulamalarda UI Thread’in yapmış olduğu iş kullanıcıdan gelen isteklere yön vermektir. Örneğin bir tıklama işlemi gerçekleştirdiğimde buna yön veren thread UI Thread’dir. Görselde görünen avast anti virüs programında virus scans 🧪 özelliğini kullandığımızda program arka tarafta virüs tarama işlemini gerçekleştiriyor. Dikkat edilmesi gereken nokta, virüs taraması gerçekleştirildiği sırada ben uygulamanın diğer fonksiyonlarını kullanabiliyorum. Anlaşılacağı üzere UI thread’in bloklanmadığı, işlemin arka tarafta asenkron ya da birden fazla thread kullanmışsa multi-thread yapıda gerçekleştiği yöntem şeklinde sınıflandırıyoruz.

Asenkron programlama da odaklanılacak nokta, asenkron işin bir ya da birden fazla thread tarafından yapılması değil de ana thread’in bloklanmamasıdır. Çünkü asenkron metoda çağrı yapıldığı zaman metot kendi içerisinde işlem yaparken biz farklı işler yapabilmeliyiz.

Worker threads

Örneğin, virüs programlarında tarama işlemi zaman alacak bir iş olduğundan dolayı ana thread üzerinde yapılamaz. Thread pool dediğimiz thread havuzundan thread alınarak ana thread’i bloklamadan tarama işlemi gerçekleştirilir. Bundan ötürü main thread dışında ihtiyaç duyulan diğer threadlere worker thread denir. Anlaşılacağı üzere bir adet main thread’imiz var bir de ihtiyaç halinde kullanacağımız thread’leri barındıran thread pool’umuz 🏊 var.

Elimdeki İmkanları Gerçekten Kullanıyor Muyum?

Asenkron ve multi-thread programlama işlemcinin performanslı kullanılmasında önemli rol oynamaktadır. Eğer işlemcinizin tüm gücünü kullanmak istiyorsanız asenkron/multi-thread programlama yapmalısınız. Örneğin 8 çekirdek (octa-core) veya 16 çekirdek işlemciye sahip olduğunuzu fakat senkron programlama yaptığınızı düşünürsek, yüksek ihtimalle işlemcinizin sadece %5–7'ini kullandığınızı ifade etmektesiniz. Üzücü.. 🥲 Ferrari’ye 🚗 sahip olabilirsin fakat tüm gücünü kullanmadığın sürece Ferrari’ye sahip olmanın bir mantığı kalmıyor.

Peki, Böyle Bir Uygulama Senkron Olsaydı?

Bu senaryo da sadece UI Thread’e sahip olunacak, thread pool’umuz olmayacaktı. Virüs taraması yapmak istediğimizde bu işlem UI Thread üzerinde gerçekleştirileceği için program bloklanacak ve tarama bitene kadar kullanılamaz (Ara yüz üzerinde herhangi bir işlem yapılamaz) halde olacaktı. Çünkü kullanıcı etkileşimiyle ilgilenmesi gereken UI Thread o sırada tarama işlemiyle ilgilendiği için işlem bitene kadar ara yüz üzerinde herhangi bir fonksiyon kullanılmaz durumda olacaktı. 10 numara 5 yıldız 🎇 senkron örneği.

NET Application

Örnek senaryo çizelim. LTUNESDMZP1 ve LTUNESDMZP2 sunucumuzun thread pool’unda 8 adet thread’imiz var. Bu threadler gelen requestleri karşılayıp haliyle response dönecek. Her iki sunucuda da şartların eşit olduğunu varsayalım.

LTUNESDMZP1 & LTUNESDMZP2

LTUNESDMZP1 ve LTUNESDMZP2 sunucularına farklı zamanlarda (5–10 saniye aralıklarla) 8 adet istek gelirse her iki sunucuda ortalama aynı sürede response dönecektir. Farklı zamanlarda dediğimiz için bir request’in işlenip response dönülmesi ortalama 5–10 saniye sürüyorsa her iki sunucuda ortalama aynı süre zarfında cevap dönecektir. Burada kritik nokta isteklerin farklı zamanlarda ve belirli aralıklarla 🚨 geldiğini düşünmemizdir.

SORU 1- LTUNESDMZP1 ve LTUNESDMZP2 sunucularına aynı anda 8 adet istek gelirse?

LTUNESDMZP2 sunucusu senkron ve 8 adet thread’e sahip olduğundan dolayı 8 thread’in her biri bir request alır ve bu request’den response dönünceye kadar bu thread request’e bağlanır. Bundan ötürü thread farklı bir iş yapamaz. ❌ LTUNESDMZP1 sunucusu ise asenkron programlanmış uygulamaya sahip ve 8 adet thread’e sahip olduğundan dolayı sıkıntısız şekilde request’leri işler.

SORU 2- LTUNESDMZP1 ve LTUNESDMZP2 sunucularına aynı anda 10 adet istek gelirse?

LTUNESDMZP2 sunucusu senkron olduğundan dolayı aynı anda gelen 10 isteğin 8'ini karşılayacak fakat 9'uncu geldiğinde thread’lerden herhangi biri response dönene kadar başka bir işlem yapamayacaktır. 9. ve 10. istekler kuyruğa girecek diğer thread’lerin işleri bittiğinde işlem göreceklerdir. Senkron kodda bir thread bir request’i aldığında eğer request’i işlemek 20 saniye sürüyorsa 20 saniye boyunca başka herhangi bir işle thread bloklandığı için ilgilenemez.

LTUNESDMZP1 sunucusu asenkron olduğundan dolayı aynı anda gelen 10 requestin 8 ini işleme alır. Burada thread request’i aldıktan sonra örneğin veri tabanına insert edilecekse veri tabanına veri gönderildikten sonra bu thread hemen 9. requesti alıyor. YANİ REQUESTDEKİ İŞLEM BİTMEDEN BAŞKA BİR REQUESTİ ALABİLİR. 🚀 Thread’lerin herhangi bir bloklanmaya maruz kalması söz konusu değil. Thread kendisine 3–4 tane request geldiyse her gelen request’i başlatan isteğin cevabını beklemeden başka bir request alabilir.

SORU 3- İstekler Nasıl Sıralanıyor?

IIS Manager

Threadlerin doluluğundan ötürü işlenemeyen requestler varsa sunucunun kuyruk mekanizmasında depolanır. Bu durumda senkron koda sahip LTUNESDMZP2 sunucusu asenkron koda sahip LTUNESDMZP1 sunucusundan daha hızlı kuyruğu dolduracaktır. Yani LTUNESDMZP1 sunucusunun kuyruğu LTUNESDMZP2'ye göre daha boş olacaktır. Çünkü senkron kodda her bir request/response işlemi bittiğinde kuyruktan yeni bir iş alınırken asenkron kodda istek daha cevaplanmadan yeni bir request alına bilinir. Bu da asenkron koda sahip LTUNESDMZP1 sunucusunun kuyruğunun daha az şişeceğine işarettir.

Örneğin herhangi bir T anında 1000 adet request gönderildiğini düşünürsek LTUNESDMZP2 sunucusunda 700 istek kuyrukta beklerken LTUNESDMZP1 sunucusunda 200 adet istek kuyrukta bekleyecektir. 🚧 (rakamlar tamamen varsayımdır.) Anlaşılacağı üzere asenkron kodun bulunduran LTUNESDMZP1 sunucusu aynı anda çok daha fazla request’i işleyebilecek haldedir.

Bir diğer örnek ise LTUNESDMZP1 sunucusu 1000 adet request’i yarım saatte döndüğünü varsayarsak belki de LTUNESDMZP2 sunucusu 1000 adet isteği 2–3 saatte dönecektir. Çünkü LTUNESDMZP1 sunucusunda thread’ler bloklanmadığından dolayı bir request alındıktan hemen sonra diğer bir request işleme alınıyor. LTUNESDMZP1 sunucusunda thread’ler arı 🐝🐝gibi çalışıyorken LTUNESDMZP2 sunucusunda thread request’i işleme soktuktan sonra response beklemekte ve bu bekleme anında farklı bir requesti işlememe almamaktadır. Bu durum şunun göstergesi olabilir. Şartlar eşit olmasına rağmen kodlamaya bağlı olarak LTUNESDMZP2 sunucusu sistem kaynaklarını senkron programlamayla beraber kullanmıyor.

Gerçek Hayat Senaryosu

Örneğin kız/erkek arkadaşımızla bir restoran’a gittik. Gittiğimiz restoranda 10 adet garson 1 tane mutfakla ilgilenen şef/aşçı’mız olsun. Bu sırada restorana 10 adet müşteri aynı anda geldi. 10 garson 10 adet müşterinin siparişini aldı ve şef’e ilettiler. Şef gelen siparişlere göre kimi siparişi 5 dakika da kimisini ise 40 dakika da yapabilir. Sonuçta ne sipariş verildiğine bağlı değil mi? Şef bu siparişleri hazırlarken garsonlar 💁‍♂️ hiçbir iş yapmadan şefin siparişlerini hazırlamasını bekliyorlar. Şef yemeği hazırladığında garson ilgili siparişi şeften alıp müşteriye götürüyor. Sinir bozucu bir senaryo değil mi? Aslında garson şefin yanında siparişi teslim almayı bekleyeceğine farklı müşterilerin siparişlerini alıp şefe iletebilir, masaları silebilir, küllük getirebilir vb. vb. Daha sonra bir sipariş hazır olduğunda şef’in bildirimiyle ilgili siparişi götürür. Aslında buraya kadar anlattığımız senkron kodlamaydı. Bunun şimdi olması gereken versiyonu olan asenkron kodlamaya göre senaryomuzu tekrar ele alalım. 10 garson ve 10 adet müşterim var. Garsonlar tüm müşterilerden siparişleri aldı ve şefe iletti. Arkadan 7 müşteri daha geldiğinde garsonlar boş durmadan direkt yeni gelen 7 müşterinin de siparişlerini alıyorlar. 7 garson yeni gelen 7 müşterinin siparişini alırken kalan 3 garson masaların temizlenmesi gibi farklı işlere bakıyorlar. Yani sürekli çalışıyorlar. İlk başta anlattığımız senkron senaryoda yeni bir müşteri geldiğinde garsonlar 💁‍♂️ şefin siparişi hazırlamasını beklediği için gelen müşteriler boş boş bekleyeceklerdir. Ne zaman şef herhangi bir müşterinin siparişini hazırlar da herhangi bir garson bu siparişi teslim edip boşa çıkar? o zaman 1 müşterinin siparişi daha alacaktı. İşte tüm mesele bu!

Task — Reference Type

Asenkron programlamanın temelini Task sınıfı (reference type) oluşturur. Amacı bir söz yani bir görevi yerine getirmekle ilgili taahhüttür.

Task<T> / async & await

Örneğin senkron metot yazımında yukarıda görüldüğü üzere dönüş tipi string iken asenkron metot yazımında geri dönüş tipi Task<string>’dir. Task<string> dönüş tipi taahhüt demektir. Yani çağrıldığı yerde o an olmasa da string değer döneceğine söz 🤞 verir. Senkron metot ise thread’i bloklayarak verinin o anda gelmesini bekler.

Asenkron programlamanın bitirim ikilisi olarak bahsedebileceğimiz async/await anahtar kelimeleri vardır. Bu ikili asenkron metotlar yazmamıza/çağırmamıza yardımcı olan ve birbirini tamamlayan anahtar 🔑 kelimelerdir.

Asenkron metotların yapacağı işle ilgili zorunlu olarak ekstra bir thread 🧵 kullanmasına gerek yoktur. Bazı asenkron metotlar, örneğin dosya okuma veya http client üzerinden GET isteğinde thread devreye girmez. Dosya işlemlerini IO Driver dediğimiz dosya okuma/yazma ile ilgili sürücü hallediyor. (ReadToEndAsync). Öyle ki benim main thread’im ReadToEndAsync ile okuma işlemini başlattığı anda yine serbeste çıkıyor. Çünkü main thread işlemini IO Driver ve işletim sistemine devrediyor. Restoran örneğinde olduğu gibi garson siparişi alıp şef’e devrettikten sonra garson boşta kalıyor mu? Kalıyor. Çünkü siparişi şef’e devretmiş oluyor. Şef ilgili siparişi hazırlıyor. Buradaki okuma işlemi de sistem tarafından yapıldığı için ekstra thread kullanmadan veriye ulaşıyoruz.

Bunun yanı sıra her asenkron metot thread kullanmayacak diye kural da tanımlayamayız. Yapılan işleme bağlı olarak thread kullanımı değişir. Örneğin dosyaya yazma işleminde veya HTTP GET isteğinde thread kullanmaz. Bu gibi işlemler 📁 IO Driver ve Ağ kartı 🌏 üzerinden gerçekleştirilir.

UI/Primary/Main thread rastgele bir metotun kaç thread ile çalışacağıyla ilgilenmez. Önemli olan söz (Task<object>) almasıdır. StreamReader.cs gists’inin içerisinde de await streamReaderTask; ile verilmiş söz yerine getirilmektedir.

Asenkron metotlar ile threadleri daha aktif ve efektif bir şekilde kullanabiliriz. Bu nedenle MVC / API projelerinde mümkün ve uygun olduğunca asenkron metotlar kullanıp Task tipi dönmeye özen göstermeliyiz. ❗❗❗

ValueTask (Value Type)

Üst başlıkta bahsini geçirdiğimiz Task class olduğu için referans tiptir. ValueTask’in tipi ise struct olduğu için değer tiptir. Bilindiği üzere C#’da Reference Type (String, Class) & Value Type (Integer, Double, Struct) olmak üzere 2 tane tip vardır. Value type’lar belleğin stack bölgesinde tutulurken Reference type’lar belleğin heap bölgesinde tutulur.

Stack & Heap

Heap bölgesinde tutulan verilerin silinmesi için çöp toplayıcısı 🗑️ dediğimizi Garbage Collector isminde bir yapı devreye girer ve belirli aralıklarla referansı olmayanları temizler. Stack bölgesinde tutulan veriler ise herhangi bir scope’dan çıktığı anda bellekten otomatik temizlenir. Haliyle heap’de tutulan veriler daha maliyetlidir. 💸💸

Her metotta geriye referans tipli Task<T> dönmek senaryoya göre değişkenlik gösterecektir fakat maliyetli olabilir. Çünkü bellekte yeni bir alanın ayrılması gerekecektir. Buna ithafen yoğun iş gerektirmeyen asenkron metottan veri dönüleceği zaman belleğin stack bölgesinde tutulması ve daha fazla performans elde edebilmek için ValueTask<T> struct (yapı) hazırlanmıştır. ✅

Yapılacak işlemin milisaniyeler içerisinde sonuçlanacağını düşünüyorsak ve bunu await anahtar kelimesiyle birlikte asenkron metottan çağrım yapacaksak ValueTask<T> kullanmayı düşünebiliriz. Geriye Task<T> sınıfı dönülmeyeceğinden dolayı belleği performanslı şekilde kullanmış olacağız. Anlaşılacağı üzere ValueTask’ın amacı, belleği daha performanslı kullanıp gereksiz şekilde Task dönülmesini önlemektir.

Wir Sehen Uns 😎

--

--