.Net (C#) ile Asenkron Programlama Nasıl Yapılmalı?

Merhabalar.. Aylar sonra yine birlikteyiz 🙂 Kısa bir süre önce; bir .Net Core MVC projesinde, generic bir repository içerisindeki metodlarda aspect attribute’leri kullanarak cache’leme yapabilmek için bir dizi denemeler yapmıştım. (karışık bir cümle oldu ama bunun ile ilgili de bir yazı yazacağım 😛 ) Repository’de async (asenkron) metodlar kullanmaktaydım, ve bu metodlar üzerinden dönen verileri aspect’ler vasıtasıyla cache’lemeye çalışırken bazı sorunlarla karşılaştım. Bu sorunlar sayesinde, asenkron programlama ile ilgili temel eksikliklerim olduğunu farkettim..

Neticesinde bu konuyla ilgili bir takım okumalar-araştırmalar yaptım. Bu yazıda sizlere, yaptığım okumalar neticesinde öğrendiğim her şeyi ayrıntılı bir şekilde anlatmaya çalışacağım.

Öncelikle; senkron (Synchronous) ve asenkron (Asynchronous) programlama derken ne kastediyoruz bunu açıklayayım.

Senkron kelimesi TDK arşivlerinde eş zamanlı olarak açıklanmaktadır. Programlamada da bu kelimeyi; belli bir zaman diliminde tamamlanan işlemlerin, birbirini takip edecek şekilde başlayıp bitmesi şeklinde açıklayabiliriz. Bu noktayı biraz daha açacak olursak; işlemler gerçekleşirken, sıra ile geçekleşir ve gerçekleşmekte olan bir işlem tamamlandığında bir sonraki işleme geçilir.

Olayı biraz daha somutlaştıralım:

        static void Main(string[] args)
        {
            Console.WriteLine("İşlemler başlıyor");

            BirinciIslem();

            IkinciIslem();

            Console.WriteLine("İşlemler tamamlandı.");
        }

        public static void BirinciIslem()
        {
            Console.WriteLine("Birinci işlem başladı");

            Thread.Sleep(2000);
            Console.WriteLine("Birinci işlemde 2 sn bekleme uygulandı.");

            Console.WriteLine("Birinci işlem bitti");
        }

        public static void IkinciIslem()
        {
            Console.WriteLine("Ikinci işlem başladı");

            Console.WriteLine("Ikinci işlem bitti");
        }

Kodları çalıştırdığımızda karşımıza çıkan sonuç şu şekilde olacaktır:

Görselde de görüldüğü üzere; IkinciIslem() metodunun başlayabilmesi için sırasıyla Console.WriteLine(“İşlemler başlıyor”); kodunun çalışması, sonrasında da BirinciIslem() metodunun başlayıp bitmesi gerekmektedir. Örnekte yapılan işlemler tek bir iş parçacığı (Thread) üzerinde gerçekleştirilmektedir.

Şimdi örneğimizi biraz değiştirelim ve aşağıdaki gibi düzenleyip yeniden çalıştıralım.

        static void Main(string[] args)
        {
            Console.WriteLine("İşlemler başlıyor");

            Task.Run(() => BirinciIslem());

            IkinciIslem();

            Console.WriteLine("İşlemler tamamlandı.");
        }


        public static void BirinciIslem()
        {
            Console.WriteLine("Birinci işlem başladı");

            Thread.Sleep(2000);
            Console.WriteLine("Birinci işlemde 2 sn bekleme uygulandı.");

            Console.WriteLine("Birinci işlem bitti");
        }

        public static void IkinciIslem()
        {
            Console.WriteLine("Ikinci işlem başladı");

            Console.WriteLine("Ikinci işlem bitti");
        }

Ekran çıktısı şu şekilde olacaktır:

Bu kez IkinciIslem(); BirinciIslem() bitmesini beklemeden yoluna devam etmiş görünüyor. Bunun sebebi ise; BirinciIslem() metodunun asenkron bir şekilde çalıştırılmış olmasıdır.

Peki, bir işlemi asenkron bir şekilde çalıştırırsak neler oluyor?? Size bunu örnek program üzerinde yapılan işlemleri sırasıyla anlatarak açıklayacağım.

Kodlarını yazdığımız yukarıdaki program derlenip çalıştırıldığında; CPU, bu iş için bir iş parcacığı (Thread) tahsis eder ve bu Thread içerisinde Main() metod çağırılır. Main() metodun çağrılmasıyla birlikte aynı Thread içerisinde sırasıyla şu işlemler gerçekleşir:

  1. Console.WriteLine(“İşlemler başlıyor”); çalıştırılır ve ekranda “İşlemler başlıyor” çıktısı görünür, sonrasında

  2. Task.Run(() => BirinciIslem()); çalıştırılır. Programımızın asenkron çalıştığı nokta tam da burasıdır.

    • Task.Run() metodunun çağrılmasıyla birlikte; BirinciIslem() metodunu çağıracak olan yeni bir görev (Task) kuyruğa eklenir.

    • Eğer, CPU‘nun yazdığımız program için ayırdığı iş parçacığı havuzunda (Thread Pool) boşta Thread var ise; kuyruğa eklenen görev hemen boştaki Thread üzerinde çalışmaya başlar.

    • Eğer, Threadpool‘da boş bir Thread yok ise, işi biten Thread‘ler üzerinde kuyruktaki Task‘ler çalıştırılmaya başlanır.

    • Böylelikle; BirinciIslem() metodu, Main() metodunun çalıştığı Thread‘den ayrılmış olur ve yoluna asenkron bir şekilde devam eder.

  3. IkinciIslem(); metodu çalıştırılır.

  4. Console.WriteLine(“İşlemler tamamlandı.”); çalıştırılır

  5. Console.ReadLine(); çalıştırılır.

burdan sonra Main() metodunu çağıran Thread‘deki işlemler bittiği için artık bu Thread boşa çıkmıştır. Artık kuyrukta bekleyen Task‘i çalıştırmaya başlayabilir. (ki ekran çıktısına bakacak olursak, Main() metodunu çağıran Thread boşa çıkınca hemen BirinciIslem() metodunu çağıracak olan Task devreye alınmış ve BirinciIslem() metodu çağırılmış görünüyor.)

Evet, asenkron programlama yaptık, yapıyoruz .. Ama bunun bize ne faydası var? diye soracak olursanız yine kısaca şöyle özetleyebilirim:

  • Kodlarımızı gereksiz yere bloklamamış oluruz.

    • Bloklamak derken neyi kastediyoruz bunu da açıklayayım: ilk örnek kodumuzda; BirinciIslem() ve IkinciIslem() metodları, birbiriyle bağlantısı olmayan metodlardı. Fakat, IkinciIslem() metodunun (ve devamının…) çalışabilmesi için BirinciIslem() metodunun tamamlanmasını beklememiz gerekiyordu. İşte biz bu gereksiz bekleme süresince Main() metodu çalıştıran Thread‘imizi; sonraki işlemleri yapamaz hale getirmiş yani bloklamış oluyoruz.

  • CPU‘yu daha verimli bir şekilde kullanmış oluruz.

  • İşlemler birbirini beklemek zorunda kalmayacağı için yanıt sürelerimiz kısalacaktır. Bu da yazdığımız programın daha performanslı çalışması anlamına gelir.

Peki, asenkron programlamanın faydalarından yararlanabilmek için asenkron çalışmasını istediğimiz her işlemi Task.Run() içerisinde çalıştırırsak ne olur? (güzel soru 😛 )

Ne demiştik.. Task.Run() metodu ile birlikte; yeni bir Thread açılır, ve işlem bu Thread üzerinde çalışmaya başlar.. Yeni bir Thread açılıp, yapılacak işlemin bu Thread‘e taşınmasının elbette bir maliyeti vardır. Fakat asıl önemli olan nokta şudur ki; sonuç olarak elimizdeki Thread sayısınında belli bir limiti olacaktır. Eğer elimizdeki Thread‘leri fütursuzca kullanırsak, bir süre sonra yine performans sorunları yaşamaya başlayacağız. Ayrıca, farklı Thread‘lerde çalışan kodlarda yapılacak hata ayıklama, bakım v.b. işlemler daha da karmaşık bir hal alabilir.

Demekki biz; hem aynı Thread üzerinde kalıp hem de kodlarımızı bloklamadan (yani asenkron olarak) çalıştırabilirsek, yukarıda bahsettiğimiz sorunları minimize ederek hayatımıza devam edebiliriz.

işte tam bu noktada Microsoft, asenkron programlamayı daha yönetilebilir kılmak için bizlere Task asynchronous programming modelini sunmuştur.

Şimdi yukarıdaki örnekte asenkron olarak çalıştırdığımız BirinciIslem() metodunu async ve await anahtar kelimelerini kullanarak asenkron çalışabilen bir metoda çevirip bunu örnek içerisinde çağıralım.

        static void Main(string[] args)
        {
            Console.WriteLine("İşlemler başlıyor");

            BirinciIslem();

            IkinciIslem();

            Console.WriteLine("İşlemler tamamlandı.");
        }

        public async static Task BirinciIslem()
        {
            Console.WriteLine("Birinci işlem başladı");

            await Task.Delay(2000);

            Console.WriteLine("Birinci işlemde 2 sn bekleme uygulandı.");

            Console.WriteLine("Birinci işlem bitti");
        }

        public static void IkinciIslem()
        {
            Console.WriteLine("Ikinci işlem başladı");

            Console.WriteLine("Ikinci işlem bitti");
        }

Herhangi bir metodu asenkron çalışır hale getirmek için async anahtar kelimesini kullanmalıyız. (yazının bundan sonraki kısmına asenkron yerine async yazarak devam edeceğim) Async bir metodun tamamlanmasını beklememiz gerektiği durumlarda ise; bu async metodu, önüne await koyarak çağırmalıyız. Böylece metodun tamamlanması beklenecek, ve eğer sonuç döndüren bir metot ise bu şekilde döndürülen sonuç elde edilmiş olacaktır. (await anahtar kelimesi; sadece async olarak kodlanan metotların içerisinde kullanılabilir.)

Şimdi örneğimizi son haliyle tekrar çalıştıralım ve ekran çıktısını inceleyelim

Bu sefer Task.Run() kullanmak yerine BirinciIslem() metodunu async bir metod haline getirdik, ve BirinciIslem() içerisinde uyguladığımız bekletmeyi Thread.Sleep() yerine Task.Delay() kullanarak gerçekleştirdik.

Bu örnekte de sırasıyla; async metot olan BirinciIslem(), sonrasında da async olmayan IkinciIslem() başlatılıyor. BirinciIslem() metodu async çalıştığı için; IkinciIslem() metodu BirinciIslem()‘in tamamlanmasını beklemeden çalışıyor ve sonunda “İşlemler Tamamlandı.” çıktısını alıyoruz. Main() metodunda yapılacak işlemler tamamlanıyor, fakat bu esnada BirinciIslem() metodu halen çalışmaya devam ediyor.

Her iki örnekte de BirinciIslem() metodunu (farklı yöntemlerle) asenkron olarak çalıştırdık. Fakat önceki örnekteki BirinciIslem() metodu “İşlemler tamamlandı.” yazısından sonra çalışmaya başladı. Önceki örnekte meydana gelen bu gecikmenin sebebi; yeni bir Thread açılıp, işlemlerin yeni Thread‘e taşınması için gerçekleşen işlemlerden kaynaklanmaktadır. (önceki örnekteki IkinciIslem() metodunda Thread.Sleep() ile bekletme uygulayarak bunun sağlamasını yapabilirsiniz.)

Son örnekte async hale çevirdiğimiz BirinciIslem() metodunda uyguladığımız bekletme işlemini Thread.Sleep() yerine await Task.Delay() kullanarak gerçekleştirdik. Çünkü;

  • Thread.Sleep() metodu senkron olarak çalışır ve çalıştığı Thread‘i verilen süre kadar bekletir.

  • Eğer Thread.Sleep() kullanmış olsaydık; async hale getirdiğimiz BirinciIslem() metodu, halen aynı Main() metod ile Thread üzerinde çalıştığı için bekletme işlemi tüm programa uygulanmış olacaktı.

  • Fakat biz bekletme işlemini sadece BirinciIslem() metodu içerisinde yapmak istediğimiz için yine async bir metod olan Task.Delay() kullandık.

  • await anahtar kelimesini de bu metodun önüne koyarak, yapılan bekletme işlemi tamamlanana kadar BirinciIslem() metodunun kilitlenmesini; yani async olan Task.Delay() metodunun çalışmasının tamamlanmasını sağladık.

async/await kullanarak aynı Thread içerisinde de asenkron işlemler yapabileceğimiz görmekteyiz. Elimizdeni Thread‘leri harcamamak için asenkron çalışacak her işlemde async/await mi kullanmalıyız? Tabii ki hayır 🙂 Peki; hangi durumlarda async/await, hangi durumlarda Task.Run() kullanmalıyız? “Task asynchoronus programming” modelinin bu konuda bize tavsiyesi nedir? Biraz bunlardan bahsedelim.

Yazdığımız uygulamalar genelde I/O-Bound ve CPU-Bound olmak üzere iki türlü işlem gerçekleştirmektedir.

  • I/O-Bound: Bu tür işlemlerde; işi asıl yürüten birim CPU tarafından işlemin atandığı sürücü-donanım elemanıdır. Mesela, bir sunucudan veri çekmek istendiğinde ilgili işlem network kartına atanır ve asıl işler network kartı üzerinde gerçekleşir. Bu süre içerisinde CPU sadece işlemin tamamlanmasını beklemektedir. Yani bu süre zarfında CPU tarafından yapılan etkin bir işlem yoktur. Dolayısıyla da CPU üzerinde her hangi bir yük oluşturmaz.

  • CPU-Bound: Bu tür işlemler tamamen CPU tarafından gerçekleştirilir. Örnek olarak; yapılan hesaplama (toplama, çıkartma v.b.) işlemlerini verebiliriz.

Eğer yapacağınız işlem; I/O-Bound türündeyse, bu işlemi async/await kullanarak asenkron bir şekilde gerçekleştirmelisiniz. Bu işlem süresince CPU herhangi bir yük altında kalmadığı için; ilgili Thread üzerindeki diğer işlemler çalıştırılmaya devam edebilir.

Fakat yapılacak işlem; CPU-Bound türündeyse, bu işlemi Task.Run() kullanarak gerçekleştirmeliyiz. Sonuçta yapılacak işlem CPU tarafından gerçekleştirilecektir. Yani bu yükten kaçışımız yok, dolayısıyla bu işlemi ayrı bir Thread‘de gerçekleştirmek CPU‘nun daha verimli bir şekilde kullanılmasını sağlayacaktır.

Async metotların donüş tipi Task / Task<T> veya ValueTask / ValueTask<T> olmak zorundadır. Eğer async bir metot tamamlandığında herhangi bir değer döndürmemiz gerekmiyorsa (yani –senkron olarak çalıştığı durumda- void bir metot ise) dönüş tipini Task veya ValueTask olarak belirleyebiliriz. Fakat, işlem tamamlandığında bir değer döndürmemiz gerekiyorsa dönüş tipini Task<T> veya ValueTask<T> olarak belirlemeliyiz. (Örneğin, async bir metot int değeri dönecekse Task<int> / ValueTask<int> şeklinde olmalı)

Task ve ValueTask arasındaki fark ise; birinin class (Task) diğerinin ise struct (ValueTask) olmasıdır. Eğer sıkı bir döngü içerisinde async metot kullanmanız gerekiyorsa; bu metotun dönüşünü ValueTask olarak kodlamak performans açısından daha yararlı olacaktır. (bknz: Class ile Struct arasındaki Farklar )

Son olarak; WhenAny() ve WhenAll() metodlarından söz edelim ve async çalışacak olan birden fazla işlemi nasıl bir arada yönetebiliriz bununlu ilgili bir örnek yapalım. (ve yazıyı bitirelim. çünkü bitmiyor.. )

Task sınıfı içerisinde bulunan WhenAny() metodu; içerisine parametre olarak geçilen Task’lerden tamamlanmış olan herhangi bir Task‘i size geri döner. WhenAll() metodu ise; parametre olan geçilen Task‘ler içerisinde tamamlanmış olan tüm Task‘leri liste olarak (IEnumerable<Task> tipinde) size geri döner.

Şimdi Microsoft dokümanlarında verilen ve gerçek hayata uygun olan kahvaltı hazırlama örneği ile yazıyı noktalayalım.

Örnekte hazırlayacağımız kahvaltıda şunları yapacağız;

  • 1 fincan kahve doldurulacak

  • Tava ısıtılıp 2 adet yumurta pişirilecek

  • 3 dilim pastırma kızartılacak

  • 2 parça ekmek kızartılacak, ve bu ekmeklere yağ ve reçel sürülecek

  • 1 bardak portakal suyu doldurulacak

Bu işlemleri hangi sırayla yaptığımızın pek bir önemi yok. Fakat gerçek hayatta da senkron bir şekilde yapmaya kalkarsak; yani

  • önce tavada yumurtanın pişmesini beklersek,

  • yumurta piştikten sonra pastırmaları kızartırsak,

  • pastırmalar kızardıktan sonra ekmekleri kızartırsak

ekmekler kızardığında daha tadına bile bakamadığımız yumurta ve pastırmamız buz gibi olacak ve kahvaltımızın hiç bir keyfi kalmayacaktır. Eğer zamanı en verimli şekilde kullanıp, yumurta ve pastırmamızı soğutmadan yiyerek daha keyifli bir kahvaltı yapmak istiyorsak bazı işlemleri asenkron olarak yapmamız gerekmektedir 🙂

Microsoft, bu durumla ilgili olarak bizlere şöyle bir çözüm sunmaktadır;

        static async Task Main(string[] args)
        {
            Coffee cup = PourCoffee();
            Console.WriteLine("-- coffee is ready");

            var eggsTask = FryEggsAsync(2);
            var baconTask = FryBaconAsync(3);
            var toastTask = MakeToastWithButterAndJamAsync(2);

            var breakfastTasks = new List<Task> { eggsTask, baconTask, toastTask };
            while (breakfastTasks.Count > 0)
            {
                Task finishedTask = await Task.WhenAny(breakfastTasks);
                if (finishedTask == eggsTask)
                {
                    Console.WriteLine("-- eggs are ready");
                }
                else if (finishedTask == baconTask)
                {
                    Console.WriteLine("-- bacon is ready");
                }
                else if (finishedTask == toastTask)
                {
                    Console.WriteLine("-- toast is ready");
                }
                breakfastTasks.Remove(finishedTask);
            }

            Juice oj = PourOJ();
            Console.WriteLine("-- oj is ready");
            Console.WriteLine("---- Breakfast is ready!");
        }

        static async Task<Toast> MakeToastWithButterAndJamAsync(int number)
        {
            var toast = await ToastBreadAsync(number);
            ApplyButter(toast);
            ApplyJam(toast);

            return toast;
        }

        private static Juice PourOJ()
        {
            Console.WriteLine("# OJ: Pouring orange juice");
            return new Juice();
        }

        private static void ApplyJam(Toast toast) =>
            Console.WriteLine("# Jam: Putting jam on the toast");

        private static void ApplyButter(Toast toast) =>
            Console.WriteLine("# Jam: Putting butter on the toast");

        private static async Task<Toast> ToastBreadAsync(int slices)
        {
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("# Toast: Putting a slice of bread in the toaster");
            }
            Console.WriteLine("# Toast: Start toasting...");
            await Task.Delay(3000);
            Console.WriteLine("# Toast: Remove toast from toaster");

            return new Toast();
        }

        private static async Task<Bacon> FryBaconAsync(int slices)
        {
            Console.WriteLine($"# Bacon: putting {slices} slices of bacon in the pan");
            Console.WriteLine("# Bacon: cooking first side of bacon...");
            await Task.Delay(3000);
            for (int slice = 0; slice < slices; slice++)
            {
                Console.WriteLine("# Bacon: flipping a slice of bacon");
            }
            Console.WriteLine("# Bacon: cooking the second side of bacon...");
            await Task.Delay(3000);
            Console.WriteLine("# Bacon: Put bacon on plate");

            return new Bacon();
        }

        private static async Task<Egg> FryEggsAsync(int howMany)
        {
            Console.WriteLine("# Egg: Warming the egg pan...");
            await Task.Delay(3000);
            Console.WriteLine($"# Egg: cracking {howMany} eggs");
            Console.WriteLine("# Egg: cooking the eggs ...");
            await Task.Delay(3000);
            Console.WriteLine("# Egg: Put eggs on plate");

            return new Egg();
        }

        private static Coffee PourCoffee()
        {
            Console.WriteLine("# Coffee: Pouring coffee");
            return new Coffee();
        }

Örneği çalıştırdığımızda sonuç şu şekilde olacaktır

Kahvaltı hazırlama örneğinde; FryEggsAsync() -yumurta pişirme- , FryBaconAsync() -pastırma kızartma-, ToastBreadAsync() -ekmek kızartma- ve MakeToastWithButterAndJamAsync() -kızaran ekmeklere yağ ve reçel sürme- metotları async olarak çalıştırılmıştır. Çünkü bu metotlarda; bazı işlemlerin tamamlanması için beklememiz gereken süreler mevcuttur. Örneğin; yumurta pişireceğimiz tavayı ocağa koyduktan sonra o tavanın ısınması için belli bir süre geçmesi gerekmektedir, ve bu süre zarfında tavanın ısınmasıyla ilgili bizim üzerimize düşen başka bir görev yoktur. Çünkü tavayı ısıtma işini yapan asıl birim ocaktır. Biz de bu süreyi, boş boş durmak yerine yapılacak başka işlerde değerlendirebiliriz.

PourCoffe() -kahve doldurma-, PourOJ() -portakal suyu doldurma-, ApplyButter() -ekmeğe yağ sürme- ve ApplyJam() -ekmeğe reçel sürme- metotları senkron kodlanmıştır. Çünkü bu işlemler bizzat bizim tarafımızdan gerçekleştirilmektedir. Yani bu işlemleri yaparken herhangi birşeyin tamamlanmasını beklemiyoruz. Örneğin; ApplyButter() metodu zaten ekmek kızardıktan sonra çalıştırılıyor. (Biz de elimize yağı alıp ekmeğe sürüyoruz.) Bu arada başka tamamlanmasını beklememiz gereken bir işlem yok.

Tüm asenkron metotlar; birer Task nesnesine atanıp breakfastTasks isimli Task listesine ekleniyor. Listede var olan Task sayısının 0’dan büyük olma koşulu ile bir while döngüsü başlatılıyor. Döngü her döndüğünde WhenAny() metodu kullanarak tamamlanmış olan herhangi bir Task için ekrana “… is ready” çıktısı basıldıktan sonra ilgili Task listeden çıkarılıyor. Listede hiç Task kalmadığında da döngü sonlanıyor..

Ve bu yazı da burda sona eriyor 🙂 Önemli olduğunu düşündüğüm her ayrıntıyı (konuyu çokta uzatmadan) açık bir şekilde ifade etmeye çalıştım.

Faydalı olması dileğiyle..

Kaynaklar:

https://docs.microsoft.com/tr-tr/dotnet/csharp/programming-guide/concepts/async/ https://docs.microsoft.com/tr-tr/dotnet/csharp/programming-guide/concepts/async/task-asynchronous-programming-model https://docs.microsoft.com/en-us/dotnet/standard/async-in-depth https://docs.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/ https://docs.microsoft.com/en-us/dotnet/csharp/async

Last updated