2.7. Dart: асинхронность

Во время работы над приложением вам может понадобится решать одну из следующих задач:

  1. Работа с базой данных
  2. Общение с бекендом
  3. Фоновая загрузка данных
  4. Работа с файловой системой
  5. Работа с нативной платформой (батарея телефона, GPS)
  6. Управление состоянием экранов и приложения

Всё перечисленное выше — задачи, которые не выйдет решить эффективно, если писать императивный синхронный код. А любая тяжёлая операция, например, парсинг объемного JSON, приведёт к зависаниям приложения.

В этом параграфе мы разберем инструменты в Dart, которые помогут вам решать такие задачи эффективно.

Но сперва давайте определимся с ключевыми тезисами:

  1. В Dart есть Isolates (изоляты) — это аналоги Threads (потоков).
  2. Весь код на Dart исполняется по умолчанию в рамках одного изолята.
  3. В Dart нет параллелизма в общепринятом понимании.
  4. Внутри каждого изолята есть Event Loop — механизм неблокирующего выполнения кода.
  5. В Dart есть настоящая асинхронность.
  6. В Dart есть способ писать реактивный код — Stream API.

Мы подразумеваем, что вы уже знакомы с такими понятиями как асинхронность, параллелизм и конкурентность. Так что на объяснении этих определений мы не будем останавливаться. А вот их реализацию в Dart мы разберём подробно.

Event Loop

Event Loop — это механизм обработки событий, который позволяет писать неблокирующий код. В этом блоке пойдёт много теории, но её стоит понять, потому что все инструменты, рассматриваемые в статье, строятся на приницпах работы Event Loop.

Его принцип работы можно представить в виде следующей схемы:

fluttern

Или, если кому-то удобнее читать код, то вот пример:

1bool programWorks = false;
2
3void main(){
4 startEventLoop();
5 // Ваша программа
6 // ...
7}
8
9void startEventLoop(){
10 programWorks = true;
11 while(programWorks){
12  eventLoopIteration();
13 }
14}
15
16void stopEventLoop(){
17  programWorks = false;
18 while(programWorks && !eventLoopQueueIsEmpty()){
19  eventLoopIteration();
20 }
21  disposeProgram();
22}

За итерацию EventLoop делает примерно следующее:

1void eventLoopIteration() {
2  if (microtaskQueue.isNotEmpty()) {
3    fetchFirstMicroTaskFromQueue();
4    executeThisMicroTask();
5    return;
6  }
7
8  if (eventQueue.isNotEmpty()) {
9    fetchFirstEventFromQueue();
10    handleThisEvent();
11  }
12}
  1. Программа запускается
  2. EventLoop разбирает всю очередь микрозадач (Microtask Queue) — в FIFO-порядке.
  3. Затем переходит к очереди событий (Event Queue) и обрабатывает следующее событие из очереди.
  4. Затем цикл переходит к началу — п.2
  5. Как только очередь событий опустеет, программа может завершать работу

Выше мы узнали два новых понятия: Microtask и Event. Напрямую при написании кода мы с ними не взаимодействуем, так что давайте разберёмся, какие наши действия порождают Event и Microtask.

Microtask

Это очень маленькие и дешёвые по ресурсам операции, которые должны быть выполнены асинхронно и как можно скорее.

Создать Microtask можно с помощью функции scheduleMicrotask(), а использовать при инициализации очень важного кода

1class MyVeryImportantManager{
2
3 void init(){
4  scheduleMicrotask((){
5   // important code
6  });
7 }
8}

Здесь складывается впечатление, что мы можем гибко настраивать приоритет для нужных нам операций. И это верно, публичное API Dart позволяет нам, но не стоит этим злоупотреблять. Если в коде вперемешкуFuture (о нём мы поговорим ниже), и Microtask, он менее безопасен:

  • Нет гарантии, что Future и Microtask всегда отработают в порядке, который вы ожидаете.
  • Весь код, отвечающий за отрисовку кадра — это Events из EventQueue. Длинная очередь Microtask заблокирует обработку EventQueue , а пользователь, в лучшем случае, увидит пропуски кадров, в худшем, зависший UI.

Например, в исходных кодах Flutter не так много применений scheduleMicrotask() , а используются они разработчиками для очень маленьких операций, в критичных для плавного UI местах.

Event

Это какое-то внешнее событие, которое попадает в Event Queue и обрабатывается Event Loop.

fluttern

Например:

  • операции ввода/вывода
  • жесты
  • рисование
  • таймеры
  • ...
  • Future

Напрямую добавлять новые события в Event Queue мы не можем, исключение — Future API. О них мы и поговорим ниже.

Future

Future — это отложенная операция, которая завершится с неким результатом через время.

Это может пригодиться во множестве кейсов: например, нам нужно отправить запрос на сервер, а пока он выполняется, мы хотим выводить время выполнения запроса. Мы попросту не сможем решить эту задачу без неблокирующего кода в языке.

1void serverRequest(){
2 print('Start loading');
3 var seconds = 0;
4 Timer? timer;
5  Future(getServerData).then((result){
6  timer?.cancel();
7  print(result);
8 });
9 // Код ниже будет выполняться уже после старта запроса
10 timer = Timer.periodic(Duration(seconds:1), (_) {
11  print('Seconds elapsed: ${seconds++}');
12 });
13}
14
15Future<String> getServerData() => //server request

Future представляет собой обычный класс с набором конструкторов.

Создать его можно с помощью публичного конструктора:

1Future((){
2 // Some async job
3});

А вот функция переданная в конструктор — это и есть событие, которое выполнит Event Loop и вернёт результат или ошибку в обработчик этого Future.

Несколько нюансов:

  • Future API может даже создать микро-задачу, через factory-конструктор Future.microtask() — результат выполнения можно будет обработать как и для любого другого Future.
  • Мы можем отложить выполнение кода внутри Future, на определённый срок с помощью Future.delayed(). Например, если нам нужно реализовать тротлинг операций — то есть выполнять операцию не чаще одного раза за X миллисекунд.

Состояния Future

Выше мы сказали следующее: «Future — это отложенная операция». Значит, мы можем выделить два состояния:

  1. Uncompleted — код внутри Future ещё не начал исполняться или исполняется.
  2. Completed — работа Future завершена с результатом или с ошибкой.

Обработка результата Future

Для этого нужно зарегистрировать then-коллбек. Он будет вызван сразу после выполнения функции внутри Future.

1void main() {
2  Future(() {
3    print('Future print');
4  }).then((_) => print('Print after future'));
5}

Обработка ошибки Future

Если во время работы Future возникнет ошибка, то Event Loop отправит ее в catchError-коллбек:

1void main() {
2  Future(() => print('Future print'))
3      .then((_) => print('Print after future'))
4      .catchError((error, stacktrace) => print(error));
5}

У then есть тоже опциональный onError-коллбек, его приоритет «выше», чем у catchError:

А еще есть метод onError() под капотом он вызывает catchError()

Отличается тем, что:

  1. Позволяет изменить структуру ошибки
  2. Гарантирует, что в результате ошибка будет такого же типа, что и изначальная

Это может пригодиться, например, при обработке ошибки сетевого запроса.

Давайте представим, что сервер в ответ на запрос присылает различные коды ошибок в теле ответа, помимо стандартных кодов HTTP:

1void main() {
2  Future(() => performNetworkRequest())
3      .onError((error, stackTrace){
4        if(error.body.code == 1){
5          //Еще один конструктор Future, немедленно отстрелит переданную ошибку через Event Loop
6          return Future.error(CodeOneError());
7        }
8                              
9        return error;
10      });
11}

Асинхронная работа

Выше мы рассмотрели принципы работы Event Loop, очереди событий и Future. Но все это не отвечает на вопрос:

👉 Если Event Loop — это обычный синхронный цикл, очередь событий — это FIFO структура, а Future — интерфейс для создания событий в очереди, то что во всём этом асинхронного?

Ответ — ничего.

Если обратиться к описанию работы Event Loop, можно вообще сделать вывод, что тяжелое событие может заблокировать работу EventLoop и приложение зависнет. Ничего из перечисленного выше не сделает ваш код асинхронным.

Но всё это важно для понимания того, что такое асинхронные функции, и их предназначения.

Далее будет непростая теория, так что давайте остановимся и определимся с терминологией самих разработчиков языка.

👉 Ключевые понятия:

  • Синхронная операция: Некая синхронная работа, которая блокирует исполнение последующего кода пока она не выполнится.
  • Синхронная функция: Функция, которая может выполнять только синхронные операции.
  • Асинхронная операция: Некая работа, которая может позволять другим операциям выполняться после ее старта и вплоть до завершения.
  • Асинхронная функция: Функция, которая выполняет как минимум одну асинхронную операцию и неограниченное кол-во синхронных операций.

Если с первыми двумя все ясно (синхронная операция — просто привычный всем код, синхронная функция — просто любая не асинхронная функция), то с оставшимися уже не все так очевидно.

Разберёмся подробнее.

Асинхронная операция

В блоке про Future мы уже приводили примеры кода, которые позволяют не блокировать исполнение кода.

Это не совсем так: как выполнится код Future внутри Event Loop зависит от того, какая функция была передана внутрь Future — синхронная или асинхронная.

Синхронные операции могут заблокировать выполнение дальнейшего кода, потому что Event Loop не пойдёт на новый цикл, пока не обработает такую операцию.

Получается, что Future позволяет делать мнимые асинхронные операции, порядок выполнения которых на деле зависит от функции, переданной в конструкторе Future.

Для окончательного понимания давайте рассмотрим следующий код:

1print('Before Future');
2Future(()=> print('Future')).then((_)=> print('Future then'));
3print('After Future');

Порядок вывода будет следующий:

👉 Before Future
AfterFuture
Future
Future then

Это верно, но на деле порядок вызовов будет следующим:

fluttern

Такой порядок будет верен всегда, так что:

  1. Future не является настоящей асинхронной операцией. И Future , и код внутри сами по себе — синхронные операции.
  2. Future не является легковесной реализацией параллелизма. Если бы это было правдой, мы не смогли бы гарантировать, что “After Future” выведется раньше “Future”.

Асинхронная функция

Повторим определение:

👉

  • Асинхронная операция: Некая работа, которая может позволять другим операциям выполняться после её старта и вплоть до завершения.
  • Асинхронная функция: Функция, которая выполняет как минимум одну асинхронную операцию и неограниченное кол-во синхронных операций.

Чтобы превратить синхронную функцию в асинхронную достаточно добавить ключевое слово async перед телом функции:

1void myAsyncFunction() async {
2 print('Some job');
3}

Поздравляю, теперь вызов myAsyncFunction() — асинхронная операция.

Пробуем:

Что-то не то, foo() же асинхронная, она не должна была блокировать вызов print('After operation')

На самом деле, функции помеченные async выполняются синхронно, до тех пор, пока не встретят первое применение ключевого слова await

Для полного понимания, давайте расширим понятие асинхронной функции:

👉 Это функция, выполнение которой может быть приостановлено с передачей управления назад в место вызова. Как только блокирующая операция выполнится, выполнение функции продолжится с места, где она была приостановлена.

Ключевое слово await как раз позволяет приостановить исполнение кода.

Несколько важных замечаний:

  1. await можно использовать только внутри функции, помеченной async.
  2. Мы можем дождаться только выполнения Future.
  3. Асинхронная функция может возвращать значения только типа Future.
  4. Возвращаемым значением асинхронной функции может быть void, но в таком случае мы не сможем дождаться ее выполнения

Давайте модифицируем предыдущий пример. Не торопитесь запускать, попробуйте сами предугадать результат:

Вот теперь действительно все сошлось:

  • асинхронная getAsyncJobValue() была вызвана;
  • код начал исполняться синхронно;
  • на 9-й строке await поставило на паузу выполнение getAsyncJobValue(), но не заблокировало выполнение main();
  • как только Future на 9-й строке завершил свою работу, код пошёл дальше.

Обработка ошибок

Осуществляется с помощью конструкции try/catch. Но есть нюанс:

Если результат асинхронной функции не ожидается, то в случае возникновения ошибки try/catch-блок её не отловит

Почему так — поговорим подробно в cледующих главах, а пока — пример.

Как видите, catch-блок функции badAsyncJob() не смог обработать исключение.

👉 Важно следить за ожиданием асинхронных операций, там где хотим отловить ошибки.

Таймеры

Таймер — это класс в Dart, который позволяет c помощью событий Event Loop выполнять какие-то операции через время.

Пример простейших таймеров:

1// Через секунду напечатает 'on Tick'
2Timer(Duration(seconds: 1), (){
3 print('on Tick');
4});
5
6// Каждую секунду будет печатать 'on Tick'
7Timer.periodic(Duration(seconds: 1), (timer){
8 print('on Tick');
9}); 

При разговоре про Future, мы упоминали про метод Future.delayed(), который делает то же самое. Но у него есть пара отличий от таймера:

  1. Timer возвращает объект Timer

  2. Future.delayed возвращает Future и позволяет использовать await

  3. Timer можно закрыть до выполнения кода внутри, с Future так не получится.

    1final timer = Timer(Duration(seconds: 1), (){
    2  print('on Tick');
    3});
    4timer.cancel();//Ничего не выведется
    
  4. Future.delayed использует Timer, см. документацию

Изоляты

Выше мы упомянули, что в Dart есть некие Isolates — аналоги потоков или нитей (Threads).

Разберёмся подробнее, что это такое. Итак, изолят (англ. Isolate) — это способ реализации псевдопараллелизма в Dart. У каждого изолята есть свой Event Loop и участок памяти, вы можете создавать сколько угодно изолятов.

Почему псевдопараллелизм и почему изолят — это не совсем поток, поговорим в дальнейших главах.

Сейчас же остановимся на их пользе:

👉 Тяжёлые операции могут наглухо заблокировать работу Dart-кода. С помощью изолята мы можем выполнить какую-то сложную операцию «асинхронно», а затем обработать результат в основном изоляте.

Есть два простых способа создать изолят:

  1. С помощью метода  Isolate.run
  2. С помощью функции compute

Разберём на примерах.

Пример №1: создаём изолят с помощью метода Isolate.run

Isolate.run за нас создаст изолят, выполнит код внутри коллбека, вернет результат и почистит изолят в конце.

1    int slowFib(int n) =>
2        n <= 1 ? 1 : slowFib(n - 1) + slowFib(n - 2);
3    Isolate.run(() => slowFib(40));
4    ```
5
6Важно подметить, что точкой входа в изолят может быть только функции верхнего уровня, статические функции или замыкания.
7
8С замыканиями стоит быть осторожными, они должны использовать только те объекты, доступ к которым может получить сам `Isolate`.
9
10### Пример №2: создаём изолят с помощью функции `compute`
11
12Функция [compute](https://api.flutter.dev/flutter/foundation/compute-constant.html) за нас создаст изолят, выполнит код внутри коллбека, вернет результат и почистит изолят в конце.
13
14```dart
15    Future<bool> isPrime(int value) {
16      return compute(_calculate, value);
17    }
18    bool _calculate(int value) {
19      if (value == 1) {
20        return false;
21      }
22      for (int i = 2; i < value; ++i) {
23        if (value % i == 0) {
24          return false;
25        }
26      }
27      return true;
28    }
29    ```
30
31Основные вещи про изоляты разобрали. Теперь проговорим про реактивное программирование.
32
33---
34
35Пока прервёмся и коротко напомним, что мы узнали:
36
371. Асинхронной функцию можно сделать с помощью ключевого слова `async`, но код будет выполняться асинхронно только после `await`
382. `Future` — не операция вовсе, а вот функция внутри него уже может быть синхронной или асинхронной.
393. `Event Loop` — это бесконечный цикл обработки событий, он позволяет достичь псевдопаралелизма.
404. Что такое `Isolate` и как им пользоваться.
41
42Советуем решить квиз ниже, чтобы закрепить знания. А в следующем параграфе мы продолжим разбираться с асинхронностью: углубимся в Stream API, которое добавляет в Dart реактивность.
Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

Отмечайте параграфы как прочитанные, чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф2.6. Dart: ООП
Следующий параграф2.8. Dart: Stream API и реактивное программирование