[Из песочницы] Flutter. Асинхронность и параллельность

?v=1

Привет, Хабр! Представляю вашему вниманию перевод статьи «Futures — Isolates — Event Loop» автора Didier Boelens об асинхронности и многопоточности в Dart (и Flutter в частности).

TLDR: В целом, статья ориентирована на новичков и не изобилует откровениями. Если вы  знакомы с механизмами Event Loop, Future и async/await (например, в JavaScript), корутинами в Кotlin, скорее всего, вы не найдёте для себя много нового. Однако, реализация многопоточности в виде механизма Изолятов имеет особенности.

И о переводе терминов. В устной речи мы я говорим «стримы», «трэды», «фьючеры» и т.д., на печати же эти термины выглядят странно и коряво.

Вместе с тем, Stream — это Поток, и Thread — Поток. Контекст в данном случае не всегда спасает. Тот же Изолят, на мой вкус, по-русски звучит нормально, поэтому где-то использую написание кириллицей, а где-то оригинальное.

Кое-где в скобках после русских слов их английский оригинал, иногда наоборот.

В последнее время я часто получаю вопросы, относящиеся к понятиям Future, async, await, Isolate и параллельного выполнения кода.

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

Я решил воспользоваться возможностью и посвятить этим вопросам статью. Заодно попытаюсь избавиться от путаницы, связанной, в основном, с понятиями асинхронности и параллельности.

Для начала следует запомнить, что Dart — однопоточный, а Flutter использует Dart.


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

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

void myBigLoop(){
    for (int i = 0; i < 1000000; i++){
        _doSomethingSynchronously();
    }
}

В примере выше, исполнение myBigLoop() не будет прервано ни при каких условиях. Как следствие, если исполнение метода занимает какое-то время, приложение будет блокировано на то время, пока код выполняется.

Компонент Dart, который управляет очерёдностью исполнения инструкций, называется Event Loop.

Когда вы запускаете Flutter-приложение (и любое Dart-приложение), создается и запускается новый процесс — Thread, в терминах Дарта — Изолят (Isolate). Это единственный процесс, в котором будет выполняться ваше приложение.

Когда этот процесс создан, Дарт автоматически выполняет следующие действия:


  1. инициализирует две очереди (Queues) с именами MicroTask (микрозадания)
    и Event (событие), тип очередей FIFO (прим.: first in first out, т.е. сообщение,
    пришедшие раньше, будут раньше обработаны),
  2. исполняет метод main() и, по завершении этого метода
  3. запускает Event Loop (цикл событий)

В течение всего времени жизни основного процесса приложения, один единственный
невидимый вам процесс Event Loop будет управлять порядком исполнения вашего
кода, в зависимости от содержимого двух очередей: MicroTask и Event.

Event Loop представляет собой «бесконечный» цикл, частота которого
регулируется внутренними «часами». В случае, если не исполняется никакой другой
Dart-код, этот цикл во время каждого «тика» делает примерно следующее:

void eventLoop(){
    while (microTaskQueue.isNotEmpty){
        fetchFirstMicroTaskFromQueue();
        executeThisMicroTask();
        return;
    }

    if (eventQueue.isNotEmpty){
        fetchFirstEventFromQueue();
        executeThisEventRelatedCode();
    }
}

Как видно, очередь MicroTask имеет приоритет перед очередью Event. Так для чего же используются эти очереди?

Используется для очень коротких действий, которые должны быть выполнены асинхронно, сразу после завершения какой-либо инструкции перед тем, как передать управление обратно Event Loop.

В качестве примера очень короткого действия представьте освобождение ресурса сразу после его закрытия. Так как процесс закрытия может занять какое-то время, вы могли бы написать что-то наподобие:

MyResource myResource;

    ...

    void closeAndRelease() {
        scheduleMicroTask(_dispose);
        _close();
    }

    void _close(){
        // Код, который должен быть запущен синхронно
        // для закрытия ресурса
        ...
    }

    void _dispose(){
        // Код, который должен быть запущен
        // сразу после того, как _close()
        // завершится
    }

Вообще-то, это не то, что вам придется часто использовать. К примеру, во всем исходном коде Флаттер метод scheduleMicroTask() встречается всего 7 раз. Предпочтительно использовать очередь Event.

Используется для планирования операций, которые получают результат от:


  • внешних событий, таких как
    • операции ввода/вывода
    • жесты
    • рисование
    • таймеры
    • потоки
  • Future

Фактически, каждый раз при срабатывании внешнего события, соответствующий код ставится в очередь Event.

Как только оказывается, что очередь MicroTask пуста, Event Loop берёт первую задачу из очереди Event и исполняет её.

Интересно, что Futures также обрабатываются с помощью очереди Event.

Future представляет собой задачу, которая выполняется асинхронно и завершается (успешно или с ошибкой) когда-то в будущем.

Что происходит, когда вы создаёте экземпляр Future:


  • экземпляр создаётся и хранится во внутреннем массиве, управляемом Dart
  • код, который должен быть исполнен данным экземпляром Future, добавляется
    напрямую в очередь Event
  • возвращается экземпляр Future со статусом не завершено (incomplete)
  • если есть синхронный код для исполнения, он исполняется (но не код Future)

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

По завершении кода (успешно или с ошибкой) будет вызван метод then()
или catchError() экземпляра Future.

Чтобы проиллюстрировать, давайте посмотрим пример:

void main(){
    print('Before the Future');
    Future((){
        print('Running the Future');
    }).then((_){
        print('Future is complete');
    });
    print('After the Future');
}

Запустив код, вот что мы увидим в консоли:

Before the Future
After the Future
Running the Future
Future is complete

Подробнее о последовательности выполнения кода:


  1. print('Before the Future')
  2. добавить (){print('Running the Future');} в очередь Event
  3. print('After the Future')
  4. Event Loop выполняет код из пункта 2
  5. когда исполнение завершено, выполняется код из метода then()

Важно понимать следующее:


Код Future НЕ выполняется параллельно, он выполняется в последовательности, определяемой Event Loop

Когда вы помечаете объявление метода ключевым словом async, для Dart это значит, что:


  • результат выполнение метода — это Future
  • он синхронно выполняет код этого метода, пока не встретит первое ключевое слово await, после чего исполнение метода приостанавливается
  • оставшийся код будет запущен на исполнение как только Future, связанный с ключевым словом await будет завершён

Некоторые разработчики ошибочно полагают, что await приостанавливает исполнение всего потока до того, как завершиться [связанный с ним экземпляр Future], но это не так.
Они не понимают особенности работы Event Loop

Чтобы лучше проиллюстрировать это утверждение, возьмём пример и попробуем
разобраться, что будет результатом его исполнения

void main() async {
  methodA();
  await methodB();
  await methodC('main');
  methodD();
}

methodA(){
  print('A');
}

methodB() async {
  print('B start');
  await methodC('B');
  print('B end');
}

methodC(String from) async {
  print('C start from $from');

  Future((){                // <== Этот код будет исполнен когда-то в будущем
    print('C running Future from $from');
  }).then((_){
    print('C end of Future from $from');
  });

  print('C end from $from');  
}

methodD(){
  print('D');
}

Правильная последовательность вывода такова:

A
B start
C start from B
C end from B
B end
C start from main
C end from main
D
C running Future from B
C end of Future from B
C running Future from main
C end of Future from main

Теперь давайте примем, что methodC() из примера выше отвечает за отправку запроса к серверу и получение ответа может занять неопределённое время. Думаю, можно утверждать, что с учётом этого может быть непросто предсказать точную последовательность исполнения кода.

Если же мы хотим, чтобы methodD() был исполнен только в самом конце, можно переписать этот код так:

void main() async {
  methodA();
  await methodB();
  await methodC('main');
  methodD();  
}

methodA(){
  print('A');
}

methodB() async {
  print('B start');
  await methodC('B');
  print('B end');
}

methodC(String from) async {
  print('C start from $from');

  await Future((){                  // <== изменение здесь
    print('C running Future from $from');
  }).then((_){
    print('C end of Future from $from');
  });
  print('C end from $from');  
}

methodD(){
  print('D');
}

Вывод будет таким:

A
B start
C start from B
C running Future from B
C end of Future from B
C end from B
B end
C start from main
C running Future from main
C end of Future from main
C end from main
D

То, что мы добавили ключевое слово await перед Future в methodC() меняет поведение кода.

Так же важно помнить:


Асинхронный метод НЕ выполняется параллельно, он выполняется в последовательности, определяемой Event Loop

И последний пример. который я хотел разобрать. Что выведет в консоль method1и что — method2? Будет ли результат одинаковым?

void method1(){
  List myArray = ['a','b','c'];
  print('before loop');
  myArray.forEach((String value) async {
    await delayedPrint(value);
  });  
  print('end of loop');
}

void method2() async {
  List myArray = ['a','b','c'];
  print('before loop');
  for(int i=0; i delayedPrint(String value) async {
  await Future.delayed(Duration(seconds: 1));
  print('delayedPrint: $value');
}

Ответ: их поведение различается.

Суть в том, что method1 использует функцию forEach() для перебора массива. На каждой итерации он вызывает коллбэк, помеченный как async (так что это Future). Коллбэк исполняется до ключевого слова await, после чего помещает оставшийся код в очередь Event. В конце итерации, выполняется следующий оператор: print('end of loop'). По окончании Event Loop обрабатывает 3 коллбэка.

Что касается method2, всё запускается внутри одного блока кода и поэтому исполняется (в этом конкретном примере) последовательно — оператор за оператором.

Как видите, даже в таком простом примере кода, нужно держать в уме особенности
работы Event Loop

Итак, как же мы может запустить параллельное выполнение кода во Flutter? И возможно ли это вообще?

Да, благодаря понятию Isolate (Изолят).

Как было упомянуто ранее, понятие Isolate в Dart соответствует общепринятому Thread (поток).

Однако есть серьёзное отличие от привычной трактовки понятия Thread.


Изоляты во Flutter не разделяют память. Взаимодействие между разными Изолятами реализовано посредством сообщений.

Также у него свои собственные очереди (MicroTask и Event).

Код исполняется в Изоляте независимо от других Изолятов.

В этом и состоит модель параллельное выполнение кода в Dart.

Можно использовать несколько различных способов, в зависимости от желаемого результата.


1. Низкоуровневое решение

Этот подход не использует сторонних пакетов (библиотек) и полагается только на низкоуровневый API, предоставляемый Dart.


1.1. Шаг 1: создание и рукопожатие

Как я упомянул ранее, Изоляты не делят память и взаимодействуют посредством сообщений, таким образом, нам нужно найти способ организовать «общение» между «вызывающим» блоком кода и вновь созданным Изолятом.

Каждый Изолят предоставляет порт, который используется, чтобы передавать сообщения этому Изоляту. Этот порт называется SendPort (мне лично кажется, что это название вводит в заблуждение, поскольку этот порт предназначен для получения/прослушивания, но именно таково официальное именование).

Оба, «вызывающий» и «новый Изолят» должны знать порты друг друга, чтобы иметь возможность «общаться». Именно этот процесс рукопожатия показан в примере кода:

//
// Порт нового изолята
// этот порт будет использован для
// отправки сообщений этому изоляту
//
SendPort newIsolateSendPort;

//
// Экземпляр нового изолята
//
Isolate newIsolate;

//
// Метод, который запускает новый изолят
// и процесс рукопожатия
//
void callerCreateIsolate() async {
    //
    // Локальный временный ReceivePort для получения
    // SendPort нового изолята
    //
    ReceivePort receivePort = ReceivePort();

    //
    // Создание экземпляра изолята
    //
    newIsolate = await Isolate.spawn(
        callbackFunction,
        receivePort.sendPort,
    );

    //
    // Запрос порта для организации "общения"
    //
    newIsolateSendPort = await receivePort.first;
}

//
// Точка входа нового изолята
//
static void callbackFunction(SendPort callerSendPort){
    //
    // Создание экземпляра SendPort для получения сообщений
    // от "вызывающего"
    //
    ReceivePort newIsolateReceivePort = ReceivePort();

    //
    // Даём "вызывающему" ссылку на SendPort ЭТОГО изолята
    //
    callerSendPort.send(newIsolateReceivePort.sendPort);

    //
    // Дальнейшая работа
    //
}


Важное ограничение: входной точкой Изолята ДОЛЖНА БЫТЬ функция верхнего уровня или СТАТИЧЕСКИЙ метод класса


1.2. Шаг 2: отправка сообщения Изоляту

Теперь, когда у нас есть доступ к порту Изолята, мы может отправить ему сообщение:

//
// Метод, который отправляет сообщение новому изоляту
// и получает ответ
// 
// В этом примере рассматриваем "общение"
// посредством класса String (отправка и получение данных)
//
Future sendReceive(String messageToBeSent) async {
    //
    // Временный порт для получения ответа
    //
    ReceivePort port = ReceivePort();

    //
    // Отправляем сообщение изоляту, а также
    // говорим изоляту, какой порт использовать
    // для отправки ответа
    //
    newIsolateSendPort.send(
        CrossIsolatesMessage(
            sender: port.sendPort,
            message: messageToBeSent,
        )
    );

    //
    // Ждём ответ и возвращаем его
    //
    return port.first;
}

//
// Callback-функция для обработки входящего сообщения
//
static void callbackFunction(SendPort callerSendPort){
    //
    // Создаём экземпляр SendPort для получения сообщения
    // от вызывающего
    //
    ReceivePort newIsolateReceivePort = ReceivePort();

    //
    // Даём "вызывающему" ссылку на SendPort ЭТОГО изолята
    //
    callerSendPort.send(newIsolateReceivePort.sendPort);

    //
    // Функция изолята, которая слушает входящие сообщения,
    // обрабатывает и отправляет ответ
    //
    newIsolateReceivePort.listen((dynamic message){
        CrossIsolatesMessage incomingMessage = message as CrossIsolatesMessage;

        //
        // Обработка сообщения
        //
        String newMessage = "complemented string " + incomingMessage.message;

        //
        // Отправляем результат обработки
        //
        incomingMessage.sender.send(newMessage);
    });
}

//
// Вспомогательный класс
//
class CrossIsolatesMessage {
    final SendPort sender;
    final T message;

    CrossIsolatesMessage({
        @required this.sender,
        this.message,
    });
}


1.3. Шаг 3: удаление Изолята

Когда нам больше не нужен экземпляр Изолята, хорошо бы освободить ресурс:

void dispose(){
    newIsolate?.kill(priority: Isolate.immediate);
    newIsolate = null;
}


1.4. Замечание — Stream с одним слушателем

Вы должно быть заметили, что для «общения» Изолята и «вызывающего» мы используем стримы (Streams). Они имеют тип Single-Listener Stream (стрим с одним слушателем).


2. Одноразовое вычисление (One-shot computation)

В случае, когда вам нужно выполнить код в отдельном потоке и нет необходимости в коммуникации с Изолятом по завершении, в Dart есть вспомогательная функция compute, которая


  • порождает Изолят
  • исполняет коллбэк на этом Изоляте, передавая ему необходимые данные
  • возвращает значение — результат коллбэк-функции
  • убивает Изолят по завершении выполнения коллбэка.


Важное ограничение: коллбэк ДОЛЖЕН быть функцией верхнего уровня и НЕ МОЖЕТ быть замыканием или методом класса (даже статическим).


3. Очень важное ограничение

На момент написания статьи, важно отметить, что


Платформенные взаимодействия (Platform-Channel communication) возможны только в главном Изоляте (main isolate). Этот тот Изолят, который создается при запуске вашего приложения.

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

Пользователи оценивают качество приложения основываясь на разных факторах, таких как:


  • функциональность
  • внешний вид
  • удобство использования (user friendliness)

Ваше приложение может удовлетворять всем этим требованиям, но в случае подвисаний при выполнении операций (user experiences lags), велика вероятность столкнуться с недовольством пользователей.

Вот несколько вещей, которые вам следует постоянно учитывать при разработке:


  1. Если исполнение кода НЕ МОЖЕТ быть приостановлено, используйте обычный
    синхронный подход (один метод или несколько методов вызывающих один другой)
  2. Если части кода могут работать, НЕ ОКАЗЫВАЯ ВЛИЯНИЯ на плавность
    работы приложения, ваш выбор — Event Loop посредством механизма Futures
  3. Если тяжелые вычисления могут занять длительное время, что потенциально
    сказывается на плавности работы приложения, ваш выбор — Изоляты

Другими словами, желательно, насколько это возможно, использовать механизм Futures (напрямую или посредством async-методов) — код этих Futures будет исполнен как только у Event Loop появится «свободное» время. Это даст пользователям ощущение, что работа проводится параллельно (хотя мы-то знаем, что это не так).

Еще один фактор, который может помочь вам решить использовать Future или Изолят — это примерное время, необходимое для исполнения вашего кода.


  • пару миллисекунд => Future
  • сотни миллисекунд => Изолят

Вот несколько хороших кандидатов для использования в Изоляте:


  • декодирование JSON, результат запроса в сеть (HttpRequest), может занять
    достаточно времени => используйте compute
  • шифрование: может быть очень прожорливой операцией => Изолят
  • обработка изображений (кадрирование и т.п.) точно занимает время => Изолят
  • загрузка изображения из сети: почему бы не делегировать эту задачу Изоляту, который вернет изображение, когда оно будет полностью загружено.

Думаю, что понимание того, как функционирует Event Loop очень важно.

Так же важно не забывать, что Flutter (Dart) — однопоточный, поэтому, для того, чтобы удовлетворить пользователей, разработчики должны быть уверены, что приложение будет работать насколько возможно плавно. Futures и Изоляты — мощные инструменты, которые помогут достичь этой цели.

Удачи!

© Habrahabr.ru