Dart Dilinde Asenkron Operasyonlar - I

Öncelikle hedefimizi ortaya koyalım..

Bu yazı serisi ile amacımız; başta Dart'ın event queue ve microtask kuyruğunu nasıl idare ettiğini derinlemesine öğrenerek, uygulama yazarken yapacağımız asenkron operasyonlar için daha iyi asenkron programlama kodu yazabilmek.

Akabinde devam eden bölümlerde de asenkron operasyonlar yapmak konusundaki kasımızı geliştirmeye devam edeceğiz.

Hadi başlayalım!..

Dart, tek iş parçacıklı (single-threaded) bir programlama dilidir. Bunun anlamı tüm uygulama kodlarının aynı iş parçacığında çalışıyor olmasıdır. Yani bir input/output (I/O) ya da HTTP isteği gibi zaman alan ve belli bir süre sonra sonuç döndüren işlemler tamamlanana kadar uygulama donar ve kullanıcının etkileşimlerine izin vermez.

Peki bu pratikte böyle midir? Elbette değil..

Bu özellik genellikle yanlış anlaşılır. Çünkü Dart her ne kadar tek iş parçacıklı (single-threaded) bir programlama dili olsa da, async ve await anahtar kelimelerini kullanan Future nesnesini kullanarak asenkron operasyonlar yapmanıza olanak tanır.

Temel de böyle olsa da bu konudaki tek yardımcınız Future olmayacak. Konseptin içerisindeki kavramları incelemeye devam edelim..

Öncelikle thread/iş parçacığı kavramına bir göz atalım.

Thread nedir?

Bir Dart kodu işletilmeye başlandığı zaman Dart runtime (çalışma zamanı)'da yalıtılmış bir thread (isolated thread process) oluşturur. Oluşturulan bu thread için FIFO (first-in, first-out) şeklinde çalışan iki queue/kuyruk oluşturulur ve bu kuyruklardan biri microtasks/mikro görevler için, diğeri events/olaylar için kullanılır. Yani bir Dart uygulamasının iki sıralı(two queues) tek bir olay döngüsü (single event loop) vardır.

Özetle; 2 kuyruk/queues(event queue, microtask queue) ve 1 event loop :)

Olay döngüsü/Event Loop, Dart'ın diğer kod çalıştırılmazken işlenecek mikro görevleri/microtasks ve olayları/events tekrar tekrar kontrol ettiği sonsuz bir döngü gibidir.

İki Kuyruk

Events ve Event Queue

Event ya da event queue; database işlemleri gibi I/O çalışmalarında, fare olayları/mouse events, taps, clicks, keypress gibi kullanıcı etkileşim eventlarında, çizim olayları/drawing events, zamanlayıcılar/timers, Dart isolate'leri arasındaki mesajlar, bir akışa/stream veri eklendikçe dinleyiciler/listeners olaylar/events aracılığıyla bilgilendirilmesi gibi tüm dış olayları içerir.

Daha da basite indirgersek event queue, Dart'ın kod yürütmeyi yönetmek için kullandığı olay döngüsü/event loop'unu gerçekleştirmesi için gerekli olan tüm eventların bir sıralamasıdır.

Bu durumu şöyle görsel olarak ifade edebiliriz.

Event Loop

Event Loop Example

Event Loop and Main

Yukarıdaki şekilde gösterildiği gibi, bir Dart uygulaması, main isolate'i uygulamanın main() fonksiyonunu yürütmeye başlar. main()'den çıktıktan sonra, main isolate'in iş parçacığı/threadi, uygulamanın olay kuyruğundaki tüm öğeleri birer birer işlemeye başlar.

Bu durumu mobil uygulamalar için genelleyecek olursak uygulamanın ömrü boyunca başlayacak, yukarıda bahsettiğimiz pek çok event'ı gerçekleştirecek, duracak ama bu olayların ne zaman veya hangi sıra ile gerçekleşeceğini bilemeyecek. Çünkü, kullanıcının ne zaman, hangi sıra ile, hangi eventları işleyeceğini hiç bir zaman bilemeyiz.. İşte tam da bu sebepten dolayı bunların hepsini hiçbir zaman engellemeyen Dart'ın sahip olduğu single thread ele alır ve bir olay döngüsü/event loop çalıştrır.

Peki, bu kadar belirsizlik içerisinde olay döngüsü/event loop yürütme sırasına nasıl karar verir?

Aslında bunun cevabını yukarıda Thread'i anlatırken vermiş olduk. "..FIFO (first-in, first-out) şeklinde çalışan iki queue/kuyruk oluşturulur..".

Event loop en eski olayı alır (first-in), işler, bitirir (first-out), sonrakine geçer, alır, işler.. Ta ki event boşalana kadar böyle devam eder. Bu sırada da uygulamanın çalıştığı süre boyunca siz hala uygulama ekranında bir şeyler yapıyor, yeni eventları queue'a yolluyor olursunuz. Olay döngüsü/event loop da, bu olayları birer birer işlemeye devam eder.

Bütün olaylar bitti!.. Amiyane tabirle, kullacımız artık uygulamamızı mıncıklamıyor. Şimdi neler olacak?

Bu durumda thread'imiz gelecek eventları beklemeye başlar. Yeni bir event, event loop'una düştüğünde, süreci tekrar baştan başlatmış olur.

Dart ya da Flutter kullanırken asynchronous operasyonlar için kullandığınız futures, streams, async, await gibi tüm işlemlerin hepsi bu basit döngü üzerinde ve çevresinde inşa edilmiştir.

Microtasks

Thread'i anlatırken Dart'ın çalışma zamanında oluşturduğu isolated thread için iki queue/kuyruk oluşturduğunu ve bu kuyruklardan birinin microtasks/mikro görevler için olduğunu belirtmiştik. Zaten diğeri de event'lar oluşturuyordu.

Peki iki ayrı kuyruk oluşturan microtasks ve event'ların çalışma önceliği nasıldır?

Event'ları anlatırken kendi içlerindeki çalışma önceliğini detaylı şekilde irdelemiştik..

Microtask'larda bu çalışma nasıl gerçekleşiyor? Daha da önemlisi microtask'lar ve event'lar arasında bu öncelik nasıldır?

Öncelikle ikinci sorumuzu cevaplayarak başlayalım. Tüm zamanlanmış microtask'lar, event'lardan önce yürütülür. Microtasks kalmadığında ise, olay kuyruğunda/event loop'ta bekleyen event'lar işlenir.

İlk sorumuza gelince..

Önelikle bir schedule task/zamanlanmış görev nasıl oluşturulur? bunu öğrenmemiz gerekiyor. Zira event'lar için bizim özel bir şey yapmamız gerekmiyordu. Kullanıcı çeşitli etkileşimlerle pek çok event oluşturuyor ve event loop içerisinde sistem tarafından bunun yönetilmesi sağlanıyordu.

Flutter İş Sırası

Bir schedule task/zamanlanmış görev oluşturmak

Bir schedule task/zamanlanmış görev oluşturmak için Dart'ta iki yöntem kullanabiliyoruz. İki yöntemde Dart'ın dart:async kütüphanesi tarafından sağlanıyor.

  1. Bir Future sınıfı kullanarak bir zamanlanmış görev oluşturabilirsiz. Future'u kullandığınızda event loop'una yeni bir öğe daha eklemiş olursunuz.
  2. Bir diğer yöntem ise, asenkron olarak çalıştırabileceğiniz top-level bir fonksiyon olan scheduleMicrotask() fonksiyonudur. Bu fonksiyon aracılığıyla kaydedilen callback'ler her zaman sırayla yürütülür ve diğer asenkron olaylardan (timers gibi) önce çalışması garanti edilir. Bakın burası çokomelli!

Hadi bu çokomelli tarafı gözlerimizle görelim..

import 'dart:async';

main() {
    print("#1 ------");
    print("#2 ------");
    print("#3 ------");
    Future.delayed(const Duration(seconds: 5),
        ()=> print("#4 ------ delayed"));
    print("#5 ------");
  scheduleMicrotask(() => print('#6 ------ scheduleMicrotask'));
}

Ne dersiniz çıktı nasıl olacak?

Öncelikle 1, 2, 3 ve 5. maddeleri oluşturan print fonksiyonları çalışacak. Ardından diğer tüm asenkron olaylardan önce scheduleMicrotask() fonksiyonu çalışacak ve 6. madde ekrana yazılacak. Future.delayed ile beklettiğimiz 4. madde ise scheduleMicrotask() fonksiyonundan hemen sonra çalışacak ve aşağıdaki görüntüyü oluşturacaktır.

#1 ------
#2 ------
#3 ------
#5 ------
#6 ------ scheduleMicrotask
#4 ------ delayed

Ortalığı biraz daha karıştıralım..

import 'dart:async';

main() {
    print("#1 ------");
    print("#2 ------");
    print("#3 ------");
    Future.delayed(const Duration(seconds: 5),
        ()=> { 
      print("#4 ------ delayed"),
      scheduleMicrotask(() => print('#8 ------ scheduleMicrotask')),
      Future.delayed(const Duration(seconds: 5),()=>{
        print("#9 ------")
      })
    });
  
  
    print("#5 ------");
   scheduleMicrotask(() => print('#6 ------ scheduleMicrotask'));
   scheduleMicrotask(() => print('#7 ------ scheduleMicrotask'));
}

İşte yeni sıralamamız!..

#1 ------
#2 ------
#3 ------
#5 ------
#6 ------ scheduleMicrotask
#7 ------ scheduleMicrotask
#4 ------ delayed
#8 ------ scheduleMicrotask
#9 ------

Hadi son bir headshot yapalım!..

import 'dart:async';

main() {
    print("#1 ------");
    print("#2 ------");
    print("#3 ------");
    Future.delayed(const Duration(seconds: 5),
        ()=> { 
      print("#4 ------ delayed"),
      scheduleMicrotask(() => print('#8 ------ scheduleMicrotask')),
      Future.delayed(const Duration(seconds: 5),()=>{
        print("#9 ------")
      })
    });
  
  Future(() => print("#10 ------"))
      .then((_) => print("#11 ------"))
      .then((_) {
        print("#12 ------");
        scheduleMicrotask(() => print("#13 ------ scheduleMicrotask"));
      });
  
  print("#5 ------");
  scheduleMicrotask(() => print('#6 ------ scheduleMicrotask'));
  scheduleMicrotask(() => print('#7 ------ scheduleMicrotask'));
}

Sonuç için tahminleri alalım..

??

Yukarıda da belirttiğimiz gibi scheduleMicrotask() fonksiyonu aracılığı ile kaydettiğimizi tüm callback'ler her zaman sırayla yürütüldü ve diğer asenkron olaylardan (Future ya da Future.delayed) önce çalışması garanti edilmiş oldu.

Örneğin bir kullanıcının kimliğinin artık doğrulanmadığını algılamak ve kullanıcıyı oturum açmaya yönlendirmek amacı ile microtask kullanmak çok doğru bir use case olmaz mıydı? Ne dersiniz?

Daha düzgün bir şekilde ifade edecek olursak çalışma sıralamamız aşadağıki gibi oluşmuş oldu.

  1. main() fonksiyonunun içerisi çalışır.
  2. Microtask queue (scheduleMicrotask()) içerisindeki tasklar çalışır.
  3. Event queue (Future() veya Future.delayed()) içerisindeki tasklar çalışır.

main() fonksiyonunun içerisindeki tüm çağrıların senkron olarak yürütüldüğünü unutmayın.

scheduleMicrotask() fonksiyonunu Future.microtask constructor/yapıcısı ile de aşağıdaki gibi kullanabilirsiniz.

import 'dart:async';

main() {
    print("#1 ------");
    print("#2 ------");
    print("#3 ------");
    Future.delayed(const Duration(seconds: 5),
        ()=> { 
      print("#4 ------ delayed"),
      Future<void>.microtask(() {
          print("#8 ------ scheduleMicrotask");
      }),
      Future.delayed(const Duration(seconds: 5),()=>{
        print("#9 ------")
      })
    });
  
  Future(() => print("#10 ------"))
      .then((_) => print("#11 ------"))
      .then((_) {
        print("#12 ------");
        Future<void>.microtask(() {
            print("#13 ------ scheduleMicrotask");
        });
      });
  
    print("#5 ------");
  Future<void>.microtask(() {
      print("#6 ------ scheduleMicrotask");
  });
  Future<void>.microtask(() {
      print("#7 ------ scheduleMicrotask");
  });
}

Bu şekilde kullanıldığında da sonuç değişmeyecektir.

Future<void>.microtask

Şeklindeki yazım şeklini Future.microtask şeklinde de kullanabilirsiniz.

Unutmayın!

main() fonksiyonu, microtask ve event kuyruğundaki tüm öğeler, Dart uygulamasının main isolate'inde çalışır.

Bir görev planladığınızda/schedule task;

  1. Mümkünse Future() veya Future.delayed() kullanın.
  2. Görev sırasını belirlemek için Future'un then() veya whenComplete() metotlarını kullanın. (Bu metotların detaylarına daha sonra değineceğiz.)
  3. Event loop içerisindeki event'ları çok bekletmemek için microtask kuyruğunu mümkün oldukça her zaman kısa tutun.
  4. Her iki olay döngüsünde de (event loop ve microtask) yoğun işlem gerektiren görevlerden kaçının.
  5. Yoğun işlem gerektiren görevleri gerçekleştirmek için ek isolate'ler veya worker'lar oluşturun. (Bu konunun detaylarına daha sonra değineceğiz.)

Göz atmak isterseniz..

Eğlence devam edecek..