Jest to drugi wpis z serii o modelu wykonawczym w Darcie i powiązanej z nim asynchroniczności. Aby wyciągnąć maksimum wiedzy z tego wpisu, niezbędna jest wiedza z poprzedniego, który tłumaczy działanie Event Loop, Futures i programowania z użyciem async/await. Znajdziesz go tutaj.

W poprzednim wpisie do skutku wałkowany był temat tego, że Dart jest językiem jednowątkowym. Na poziomie procesora jest w stanie wykonywać tylko jedną operację w danym czasie. Nie rozdwoi się. Tylko co w takim razie zrobić, gdy mamy do wykonania bardzo ciężką operację, a nie chcemy zawiesić na czas obliczeń całej aplikacji?

Jest na to sposób! Nie jest to co prawda pełna wielowątkowość, ale dostarczony mechanizm umożliwia równoległe wykonywanie wielu operacji na raz. Daje nam to szansę na zapewnienie responsywności UI, a przy tym prowadzenia ciężkich obliczeń na tyłach.

Isolate

Uruchomiona aplikacja w Darcie operuje w obrębie pojedynczego wątku, a raczej jak to się przyjęło w nomenklaturze tego języka - izolatu (Isolate). Sama nazwa nie jest jednak siłą przypadku, gdyż niesie za sobą ważną informację. Izolaty są od siebie odizolowane i nie współdzielą pamięci.

Mamy możliwość tworzenia i startowania niezależnych izolatów, ale nie mogą one w żaden sposób współdzielić ze sobą nawet części wydzielonej pamięci. Ograniczenie technologiczne. Nie chodzi tutaj nawet o dwa niezależne izolaty. Rodzic, który wystartował nowego izolata również nie jest w stanie operować na jego pamięci. Absolutna izolacja.

Każdy jeden izolat ma własną pamięć, własnego Event Loopa z kolejkami itd. Powinny być postrzegane jako mini osobne aplikacje, gdzie każda sobie rzepkę skrobie.

W porządku, tylko jak w takim razie sterować takim nowo utworzonym izolatem? Jeśli mogę go tylko wystartować i nic więcej to po co mi on? Nawet jeśli wykona swoją ciężką pracę, to jak powie o tym głównemu wątkowi? Poprzez zadeklarowane porty, którymi wysyłane są wiadomości (messages) w obie strony.

Tworzenie izolata

Przejdźmy od razu do praktyki i stwórzmy program z dodatkowym izolatem.

import "dart:async";
import "dart:isolate";

Future<void> main() async {
  final receivePort = ReceivePort();

  Isolate isolate = await Isolate.spawn(
    isolateEntry,
    receivePort.sendPort,
  );

  receivePort.listen((dynamic message) {
    print('Isolate says: $message');
  });
}

void isolateEntry(SendPort sendPort) {
  Timer.periodic(
    Duration(seconds: 1), 
    (_) => sendPort.send("Hi"),
  );
}

Na samym początku tworzony jest obiekt typu ReceivePort. Umożliwi nam to komunikację między izolatami. Następnie tworzony jest nowy izolat poprzez asynchroniczny konstruktor Isolate.spawn do którego podajemy dwa argumenty.

  1. isolateEntry - funkcja startowa. Mówimy izolatowi jaka funkcja ma być jego “mainem”. Uruchamiając aplikację w Darcie, gdy wstaje pierwszy izolat, szuka on właśnie funkcji o nazwie main. Tutaj mamy już dowolność jak ją nazwiemy, ale funkcja ta musi być albo statyczna, albo tzw. top-level. Nie możesz przekazać tutaj ani metody obiektu, ani żadnej funkcji, która jest zdefiniowana wewnątrz innego bloku kodu jak np. funkcja zadeklarowana w funkcji.
  2. receivePort.sendPort - obiekt typu SendPort, który otrzymujemy po utworzeniu nowego ReceivePort. Jest to port przez który wysyłane są wiadomości od dziecka do rodzica. Wysłana wiadomość trafia bezpośrednio do powiązanego z nią ReceivePort.

Ostatnią rzeczą jaką robi main jest rozpoczęcie nasłuchiwania na wszystkie przychodzące wiadomości i ich printowanie na konsolę.

Co robi natomiast sam izolat? Uruchamia timer, który co sekundę wysyła wiadomość Hi na otrzymany port. Na początek wystarczy. Uruchomienie aplikacji powoduje niekończący się spam ze strony izolatu.

Isolate says: Hi
Isolate says: Hi
Isolate says: Hi
Isolate says: Hi
...

Poniżej zamieszczony jest przykład izolata, który jako punkt wejściowy otrzymuje funkcję zdefiniowaną w obrębie innej funkcji. Uruchomienie takiego programu zakończy się błędem, gdyż jedyny poprawny punkt wejścia to funkcja statyczna, lub top-level. Pamiętaj o tym!

Future<void> main() async {
  final receivePort = ReceivePort();

  void isolateEntry(SendPort port) => print("Hi");

  // Error!
  Isolate isolate = await Isolate.spawn(
    isolateEntry,
    receivePort.sendPort,
  );
}

Niezależna pamięć

Zróbmy szybki test dla upewnienia się, że izolaty są od siebie rzeczywiście w pełni odizolowane. Zdefiniujemy globalną flagę i zmienimy jej wartość wewnątrz izolata. Zmiana ta nie powinna wpłynąć w żaden sposób na to, co dzieje się w głównym wątku. Oba izolaty mają przecież osobną pamięć, więc flaga mimo swojej globalności będzie miała inny adres w pamięci w każdym z izolatów.

import "dart:async";
import "dart:isolate";

// Define dummy flag
bool flag = true;

Future<void> main() async {
  final receivePort = ReceivePort();

  Isolate isolate = await Isolate.spawn(
    isolateEntry,
    receivePort.sendPort,
  );

  receivePort.listen((dynamic message) {
    // Print flag value within main isolate
    print('main flag: $flag');
    print('Isolate says: $message');
  });
}

void isolateEntry(SendPort sendPort) {
  // Change the flag value within isolate
  flag = false;

  Timer.periodic(
    Duration(seconds: 1),
    (_) {
      // Print flag value within new isolate
      print('isolate flag: $flag');
      sendPort.send("Hi");
    },
  );
}
isolate flag: false
main flag: true
Isolate says: Hi

isolate flag: false
main flag: true
Isolate says: Hi

...

Sukces! Główny wątek widzi inną wartość flagi niż izolat którego stworzył. Dowód naukowy przedstawiony, można się rozejść kontynuować.

Obustronna komunikacja

Komunikacja od dziecka do rodzica okazała się prosta, wystarczyło jedynie użyć ReceivePort. A jak to ma się w drugą stronę? Jak od rodzica wysłać wiadomość do dziecka? Na przykład w odpowiedzi na otrzymaną wiadomość? Kolejnym ReceivePortem!

import "dart:async";
import "dart:isolate";

Future<void> main() async {
  final receivePort = ReceivePort();

  Isolate isolate = await Isolate.spawn(
    isolateEntry,
    receivePort.sendPort,
  );

  // Define send port of child isolate
  SendPort isolatePort;
  receivePort.listen((dynamic message) {
    if (message is SendPort) {
      isolatePort = message;
      return;
    }

    print('Isolate says: $message');

    if (isolatePort != null) {
      // Reply with message after small delay
      Future.delayed(
        Duration(milliseconds: 500),
        () => isolatePort.send("Hello"),
      );
    }
  });
}

void isolateEntry(SendPort sendPort) {
  // Create own ReceivePort and send it to the parent immediately
  final receivePort = ReceivePort();
  receivePort.listen((dynamic message) {
    print('Parent says: $message');
  });

  sendPort.send(receivePort.sendPort);

  Timer.periodic(
    Duration(seconds: 1),
    (_) => sendPort.send("Hi"),
  );
}

Na poziomie funkcji main zdefiniowaliśmy nową zmienną isolatePort typu SendPort do której przypiszemy port z izolata jak tylko nam go wyśle. Wysyłka jest realizowana przez ten sam port komunikacyjny którym przychodzą wiadomości tekstowe. W momencie otrzymania obiektu sprawdzamy jego typ, aby ustalić co dokładnie wysłał nam izolat. Ustawiamy wartość portu, lub printujemy wiadomość i wysyłamy z małym opóźnieniem odpowiedź zwrotną.

Z kolei isolateEntry tworzy własny ReceivePort, nasłuchuje na nim, a następnie wysyła go od razu do swojego rodzica. Ma to miejsce jeszcze przed pierwszym wysłaniem napisu Hi.

Wyszło całkiem sporo kodu jak na tak prosty program, który jedyne co robi to ping-ponga z wiadomościami. Pokazuje jednak najważniejsze aspekty pracy z izolatami, czyli jak wymieniać się informacjami i wzajemnie wpływać na siebie. Czy pozostało coś jeszcze? O tak! Samoistne zakończenie programu i posprzątanie śmieci.

Zamykanie izolata

Gdy zaczniesz już korzystać z izolatów we własnej aplikacji, proszę pamiętaj żeby je zabić gdy nie są już potrzebne. Szkoda zasobów maszyny żeby wisiały bez końca, a przy odpowiednich okolicznościach mocno wpłyną na wydajność głównego wątku.

Izolat udostępnia sugestywną metodę kill(), która go uśmierci na zawołanie. Kiedy ją zawołać? W momencie gdy izolat wykonał swoją pracę i nie będziesz go więcej potrzebował. Brutalne, ale tak działa ich świat. W przykładzie poniżej niszczę wszystko i zamykam strumień danych po dokładnie trzech wiadomościach. Zamykanie strumienia receivePort jest opcjonalne, ale bez tego aplikacja zatrzyma się w limbo i trzeba ją będzie nadal ubijać ręcznie.

import "dart:async";
import "dart:isolate";

Future<void> main() async {
  final receivePort = ReceivePort();

  Isolate isolate = await Isolate.spawn(
    isolateEntry,
    receivePort.sendPort,
  );

  SendPort isolatePort;
  int count = 0;
  receivePort.listen((dynamic message) {
    if (message is SendPort) {
      isolatePort = message;
      return;
    }

    print('Isolate says: $message');

    if (isolatePort != null) {
      Future.delayed(
        Duration(milliseconds: 500),
        () => isolatePort.send("Hello"),
      );
    }

    // Kill isolate with fire!
    if (++count == 3) {
      isolate.kill(priority: Isolate.immediate);
      isolate = null;
      receivePort.close();
    }
  });
}

void isolateEntry(SendPort sendPort) {
  final receivePort = ReceivePort();
  receivePort.listen((dynamic message) {
    print('Parent says: $message');
  });

  sendPort.send(receivePort.sendPort);

  Timer.periodic(
    Duration(seconds: 1),
    (_) => sendPort.send("Hi"),
  );
}

Flutter vs Isolate

Praca z izolatami nie należy do najprzyjemniejszych i wymaga od programisty dużej ilości kodu. Tworzenie izolata, portów, nasłuchiwanie, sprzątanie. Kawał roboty, zwłaszcza gdy najczęściej chcemy po prostu wykonać pewien fragment kodu w taki sposób, żeby nie zawiesił naszego UI i zwrócił wynik jak skończy.

Flutter jak to często bywa dostarcza gotowe rozwiązanie w postaci funkcji compute, która:

  1. Startuje nowego izolata
  2. Uruchamia w jego obrębie wskazaną funkcję z zadanymi parametrami
  3. Gdy przetwarzanie dobiegnie końca zwracany jest wynik (asynchronicznie)
  4. Izolat ulega zniszczeniu, wszystko jest posprzątane

Jak wygląda jej użycie? Stwórzmy do tego prostą aplikację Flutterową, żeby zademonstrować jaka jest różnica między aplikacją nieresponsywną (bez izolata), a responsywną (z izolatem).

Sortownik

Sortownik to banalna aplikacja do sortowania liczb. Nie wykorzysta ona jednak żadnego znanego i wydajnego algorytmu sortowania, a zamiast tego stworzymy najmniej wydajne rozwiązanie jakie widział świat. Wszystko po to, aby zasymulować sytuację w której mamy do wykonania zadanie mocno obciążające moc obliczeniową urządzenia.

UI

Interfejs jest toporny do granic możliwości. Żadnych styli, kolorów, nic. Wyświetlamy zaledwie dwie listy: wejściową i posortowaną oraz dwa przyciski, gdzie jeden odpowiedzialny jest za przesortowanie, a drugi aktualizuje po stuknięciu licznik o 1.

sortownik.png

Aktualny algorytm sortowania jest optymalny - robi robotę, szybciej się nie da. W następnym kroku wymienimy go na autorskie rozwiązanie typu “w końcu się musi udać”.

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final List<int> unsortedList = [
    1, 3, 2, 4, 3, 5, 7, 4, 5, 6, 12,
  ];
  bool sorting = false;
  int counter = 0;
  List<int> sortedList;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('$unsortedList'),
              SizedBox(height: 24),
              if (sorting)
                Text('Sorting ...')
              else
                Text('Sorted: $sortedList'),
              SizedBox(height: 24),
              RaisedButton(
                onPressed: () => sortList(),
                child: Text('Sort'),
              ),
              RaisedButton(
                onPressed: () => increaseCounter(),
                child: Text('Counter: $counter'),
              ),
            ],
          ),
        ),
      ),
    );
  }

  void increaseCounter() {
    setState(() {
      counter++;
    });
  }

  Future<void> sortList() async {
    // Update UI to reflect the sort has started
    setState(() {
      sorting = true;
    });

    setState(() {
      sortedList = sortNumbers(unsortedList);
    });

    // Update UI to reflect the sort has finished
    setState(() {
      sorting = false;
    });
  }
}

List<int> sortNumbers(List<int> numbers) {
  return [...numbers]..sort();
}

Algorytm sortujący

W obecnym stanie aplikacja jest w 100% responsywna i zapewnia 60 klatek na sekundę. Nie jest to specjalne osiągnięcie patrząc na jej możliwości, ale za chwilę będzie już tylko gorzej.

Pora wymienić domyślne sortowanie na własny mechanizm:

List<int> sortNumbers(List<int> numbers) {
  final easy = [...numbers]..sort();
  final sorted = [...numbers]..shuffle();

  while (!listEquals(easy, sorted)) {
    sorted.shuffle();
  }

  return sorted;
}

Algorytm jest fantastyczny w swojej prostocie. Na początku sortuje otrzymaną listę i zapisuje ją w zmiennej easy. Następnie tasuje oryginalną listę poprzez losowe ustawienie elementów. Ostatni krok to powtarzanie losowego tasowania, aż trafi na wariant posortowany. Genialne. Czasochłonne, ale genialne.

Po uruchomieniu zaktualizowanej aplikacji i stuknięciu w przycisk Sort następuje całkowite zamrożenie UI do czasu uzyskania wyniku. Stukanie w przycisk Counter nie reaguje, a dla użytkownika wygląda to tak, jakby aplikacja się kompletnie zawiesiła. A przecież tak nie jest! Robimy ważne obliczenia i jak tylko skończymy to UI wróci do normy.

Jeśli interfejs zawiesił się na dobre - skróc listę unsortedList o element, albo dwa i zrestartuj aplikację. U mnie taka długość jest optymalna żeby czekać średnio kilka sekund na posortowanie, ale co konfiguracja to inne opóźnienie.

Zła wiadomość jest taka, że istnieje duże prawdopodobieństwo na to, że UI nigdy nie wróci do normy. Użytkownik może nie wytrzymać i usunie w między czasie aplikację.

Event Loop

Spróbujmy uruchomić funkcję asynchronicznie, na Event Loopie!

Future(() {
  setState(() {
    sortedList = sortNumbers(unsortedList);
  });
});

Zła wiadomość #2. Pamiętasz, że jeden wątek to jedna instrukcja w danym czasie? To, że uruchomimy ten kod przez Event Loop nic nam nie da, bo to wciąż ten sam wątek, a zadanie jest związane ściśle z mocą procesorą.

compute

Skoro jeden wątek to jedna instrukcja, pora uruchomić pomocnika w postaci izolata. Nie będziemy go jednak tworzyć ręcznie, bo opisany compute sprawdzi się tutaj doskonale. Nie potrzebujemy przecież stałej komunikacji w obie strony. Wystarczy, że funkcja uruchomi się z zadanymi parametrami i dostarczy wynik gdy będzie już gotowy.

final sorted = await compute(
  sortNumbers, unsortedList,
);
setState(() {
  sortedList = sorted;
});

Pełny kod funkcji sortList:

Future<void> sortList() async {
  // Update UI to reflect the sort has started
  setState(() {
    sorting = true;
  });

  final sorted = await compute(
    sortNumbers, unsortedList,
  );
  setState(() {
    sortedList = sorted;
  });

  // Update UI to reflect the sort has finished
  setState(() {
    sorting = false;
  });
}

Uruchamiana jest tutaj funkcja sortNumbers z argumentem unsortedList, która będzie działać na osobnym izolacie. Zwracany jest obiekt typu Future<List<int», więc czekamy na jego wynik na Event Loopie głównego wątku. Różnica między poprzednim rozwiązaniem, a obecnym, polega na przeprowedzaniu sortowania w osobnym izolacie. Dzięki temu główny wątek odpowiedzialny m.in. za renderowanie UI pozostaje w pełni responsywny i użytkownik może w dowolnym momencie zwiększyć licznik stukając w przycisk.

Podsumowanie

Dart pomimo jednowątkowości udostępnia mechanizm do tworzenia dodatkowych wątków w postaci izolatów. Nie dzielą one co prawda między sobą pamięci, ale jest to świetny sposób na wykonywanie ciężkich operacji, które w przeciwnym wypadku mogłyby wpłynąć na wydajność całej aplikacji.

Kiedy warto skorzystać z izolata zamiast kodu asynchronicznego i Event Loopa? Zawsze, gdy wykonywana jest mocno obciążająca operacja taka jak skomplikowane wyliczenia, czy wykonywanie manipulacji na zdjęciach lub video. Każda aplikacja jest inna i ma własne scenariusze, dlatego trudno jednoznacznie powiedzieć którego rozwiązania użyć i kiedy.

Korzystaj ze standardowego kodu asynchronicznego tak długo jak nie odczuwasz spowolnionego działania aplikacji. Póki jest szybka i responsywna nie ma potrzeby aby urchamiać dodatkowy izolat. Zwłaszcza, że ewentualne przerobienie kodu na compute() nie jest pracochłonne.

Jest to drugi wpis z serii dotyczącej przetwarzanie instrukcji w aplikacjach Flutterowych. W planach jest jeszcze jeden, traktujący o strumieniach i jemu pochodnych. Do następnego!

Kod pełnej aplikacji Sortownik znajdziesz na GitHubie.