4.10. Продвинутая асинхронность: микротаски

В предыдущем параграфе мы рассмотрели контекст выполнения Zone. В этом — поговорим про микротаски.

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

Зачем и когда использовать микротаски

Мы уже коснулись этой концепции в параграфе про Future. Давайте коротко вспомним, о чём шла речь.

  • Микротаски играют ключевую, хотя и часто невидимую роль в асинхронной модели Dart. Чтобы написать отзывчивое приложение на Flutter и избежать сложновоспроизводимых проблем с производительностью, нужно разобраться в особенностях микротасок.

  • Вы уже знаете, что выполнение микротасок происходит в виде итерации по связанному списку. Если микротаска при выполнении запускает ещё одну микротаску, она попадает в текущую итерацию цикла, увеличивая время его выполнения.

  • Многие асинхронные инструменты в Dart используют микротаски под капотом.

Теперь давайте разберёмся, когда приходится прибегать к созданию именно микротаски.

Основное предназначение микротасок — выполнение очень коротких, атомарных асинхронных действий с минимально возможной задержкой сразу после завершения текущего блока синхронного кода, но до обработки следующего события (Future) из Event Queue.

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

Абстракции Future, Completer возвращают результаты работы с использованием микротасок. Это гарантирует, что код внутри then, catchError, whenComplete или код после await всегда будет выполнен асинхронно, в рамках микротаски, даже если complete() был вызван синхронно.

Это поддерживает предсказуемый порядок выполнения, соответствующий асинхронной природе Future. Исключением является Completer.sync(), который намеренно нарушает это правило и доставляет результат синхронно, что требует особой осторожности при использовании.

Рядовому разработчику микротаски действительно нужны не часто, только в случаях, когда возникает строгая необходимость выполнить действия до начала отрисовки следующего кадра. Однако разработчики часто сталкиваются с зависаниями UI, вызванными именно микротасками. Чаще всего это происходит из-за работы со Stream.

Stream и микротаски

Стандартные реализации StreamController по умолчанию также используют микротаски в методах add(), addError(), close(). С помощью этих методов доставляются данные и ошибки своим подписчикам, а также закрываются подписки. Поэтому код в методе listen является микротаской, и важно соблюдать правила работы внутри микротаски:

  • допустимо выполнение очень коротких операций;

  • нежелательно планирование новых микротасок, так как это увеличивает время синхронной обработки microtaskLoop.

Использование микротасок для доставки событий позволяет добавить несколько событий в Stream синхронно:

1final controller = StreamController<int>();
2controller.add(1); 
3controller.add(2);

Но их обработка слушателями onData произойдёт последовательно в фазе микротасок, уже после завершения текущего синхронного блока кода, но так же последовательно.

Stream — это не просто поток данных или событий, это последовательность результатов асинхронных операций. И в этом ключевое отличие от Future, ведь Future описывает результат одной асинхронной операции. Поэтому Stream также должен соблюдать гарантии доставки асинхронных событий.

Далее на двух примерах рассмотрим, как Stream c большим количеством микротасок влияет:

  • на Event Loop;

  • на анимации.

Пример 1: влияние на Event Loop

Создаётся подписка, которая при получении события просто обновляет счётчик. Таймер эмулирует поведение цикла отрисовки и каждые 16 мс пытается выполнить работу, в данном случае пишет в консоль. Затем синхронно начинаем добавление миллиона элементов в стрим.

Запустив код, вы увидите, что отрисовка началась только после завершения добавления всех элементов. При этом со значительной задержкой — в несколько секунд. Если раскомментировать операцию await Future.delayed(Duration.zero);, то проблема уйдёт и наш таймер начнёт писать в консоль своевременно.

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

Однако обратите внимание на цену этой «оптимизации»: теперь добавление всех элементов занимает значительно больше времени. Правильным решением для Dart будет просто не добавлять синхронно условные 10 млн элементов, а отправить List<int> в поток событий или обработать такое количество элементов на другом изоляте.

Напомним, что каждая итерация Event Loop состоит из выполнения microtaskLoop, связанного списка микротасок и Event Queue очереди Future. При этом важное отличие в том, что в процессе выполнения Event Queue новые Future планируются на следующую итерацию Event Loop, а микротаски, которые запланированы внутри других микротасок, — на текущую итерацию.

Приведённая в примере ошибка часто встречается при использовании оператора Stream.fromIterable. Туда могут передать файл или поток байтов из сети, что приведёт к созданию большого количества микротасок.

Пример 2: влияние на анимации

На экране непрерывно крутится анимация загрузки. Нажав кнопку Listen Stream.fromIterable, вы создадите подписку на поток элементов, который эмулирует поток байтов из сокета сетевого соединения.

Переключив свитчер вверху, вы активируете условие, при котором каждые 8 мс будет планироваться Future, тем самым разрывая microataskLoop на множество итераций. Общее время обработки элементов увеличится, но плавность анимации не пострадает.

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

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

Это поведение будет уже сложнее исправить, так как цикл оповещения слушателей находится во внутренней реализации StreamController. Единственный способ исправить это поведение — не допускать ситуаций, когда у одного стрима так много слушателей.

В данном примере мы используем экстремальные значения количества элементов и подписчиков, потому что DartPad исполняется на компьютере. Однако Flutter-приложения исполняются в том числе на мобильных устройствах, где не так много вычислительных ресурсов. Выполнение 20 тысяч небольших микротасок может занимать до 10 с.

Подводные камни

Использовать микротаски нужно с осторожностью:

  • Приоритет выполнения. Все микротаски выполняются до обработки следующего события из очереди Future и до отрисовки следующего кадра.

  • Блокировка UI. Длительное выполнение микротасок или их чрезмерное количество может привести к зависанию пользовательского интерфейса, поскольку Event Loop не сможет перейти к обработке событий отрисовки.

  • Цепные реакции. Микротаски, создающие другие микротаски, особенно опасны, так как все они выполняются в текущей итерации microtaskLoop.

  • Stream с осторожностью. При работе со Stream контролируйте количество событий и подписчиков, особенно при использовании Stream.fromIterable с большими коллекциями данных.

Несмотря на описанные сложности, это не должно быть причиной полного отказа от использования Stream. Потоки данных — мощный инструмент для реактивного программирования. Просто используйте их с пониманием внутренних механизмов работы, избегайте массивных синхронных операций в обработчиках событий и контролируйте количество одновременных подписчиков.

Для работы с большими объёмами данных рассмотрите следующие подходы:

  • разбиение обработки на блоки с использованием Future.delayed;

  • передача крупных коллекций целиком вместо поэлементной обработки;

  • применение изолятов для тяжёлых вычислений.


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

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

Мы рассмотрели ситуации, когда чрезмерное использование микротасок или неправильное обращение со Stream может привести к зависанию UI, и обсудили, как избежать подобных проблем. Вы узнали о важности контроля количества событий и подписчиков при работе со Stream, а также о том, какие подходы можно использовать для работы с большими объёмами данных.

В целом теперь у вас есть представление о том, какие бывают подводные камни в использовании микротасок и Stream в своих проектах. Даже если вы самостоятельно ни разу не планировали в коде выполнение микротаски, все асинхронные API в Dart используют их под капотом для доставки результата, и задача разработчика — контролировать их количество. Помните, что понимание внутренних механизмов работы этих инструментов — ключ к эффективному программированию на Dart и Flutter.

В следующем параграфе мы спустимся на уровень глубже: рассмотрим инструменты, которые помогут нам ещё точнее контролировать асинхронные операции.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

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

Предыдущий параграф4.9. Продвинутая асинхронность: Zone
Следующий параграф4.11. Инструменты асинхронного программирования