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