У каждого изолята своя куча и свой event loop, поэтому ни один объект никогда не разделяется как изменяемый; два изолята общаются только скопированными сообщениями через порты. ReceivePort работает как входящий ящик, а его sendPort служит адресом, который вы отдаёте другой стороне.
Нет общего состояния, нет и блокировок: один изолят не может добраться до объектов другого, поэтому в Dart-коде нет гонок данных (race condition) на объектах, а в языке нет synchronized или мьютексов
Внутри один поток: каждый изолят обрабатывает по одному событию за раз; настоящий параллелизм появляется, только когда работают несколько изолятов
Порты служат единственным каналом: ReceivePort реализует стрим, который вы слушаете, а SendPort.send копирует сообщение (неизменяемые значения вроде строк разделяются)
В одну сторону: для диалога каждая сторона создаёт свой ReceivePort и отправляет через границу соответствующий SendPort
FlutterSenior
Изоляты и настоящая многопоточность
Isolate.run, spawn, порты, копирование сообщений, пулы воркеров, TransferableTypedData
Закрыто вопросов
0 / 18
- 0Повторить
- 3Часто спрашивают
Вопросы
Не совсем: движок использует несколько потоков, но ваш Dart-код выполняется ровно в одном, а именно в корневом изоляте. Начиная с Flutter 3.29 на Android и iOS это главный platform-поток; raster-поток рисует на GPU, а запущенные изоляты работают в других потоках.
Platform / main: колбэки ОС и обработчики плагинов, а с 3.29 (Android, iOS; macOS и Windows начиная с 3.35) ещё и build, layout и ваш Dart-код, поэтому долгая Dart-задача может закончиться ANR
UI: на платформах, где он всё ещё отдельный, поток, который выполняет корневой изолят: build, layout, запись отрисовки, жесты, колбэки анимаций
Raster: превращает дерево слоёв в команды GPU на C++; блюры, saveLayer и слишком большие картинки стоят времени именно здесь, и изолят не поможет
IO и рабочие потоки: движок декодирует изображения и читает ассеты вне потока Dart
Ваши изоляты: работают в потоках из пула Dart VM, по-настоящему параллельно, без доступа к виджетам, BuildContext и рендерингу
Поэтому в DevTools высокий столбец UI значит, что медленный ваш Dart, а высокий столбец raster значит, что картинку слишком дорого рисовать.
compute и Isolate.run работают как одноразовые помощники: запустить воркер, выполнить одну функцию, вернуть один результат, завершиться; на нативных платформах compute во Flutter просто вызывает Isolate.run. Isolate.spawn представляет собой низкоуровневый примитив, который даёт долгоживущий изолят и общение через порты.
**compute(fn, message): только во Flutter; один аргумент на вход, одно значение на выход, а на вебе выполняется в главном потоке
Isolate.run(() => ...)**: чистый Dart; принимает замыкание, поэтому может захватить несколько значений и быть async; результат возвращается через Isolate.exit, поэтому не копируется
Ошибки: оба пробрасывают исключение воркера в await вызывающего кода
**Isolate.spawn(entry, message)**: возвращает хендл Isolate для kill, pause и addOnExitListener, а протокол на портах вы строите сами
**Берите spawn, когда**: воркер должен жить между многими запросами (тёплые кеши, открытая база данных), сообщать о прогрессе или поддерживать отмену; run и compute не дают хендла, поэтому их нельзя отменить
Нужен ли воркер вообще: выносите работу, только когда она длится дольше кадра (16,7 мс при 60 Гц, 8,3 мс при 120 Гц) на самом слабом поддерживаемом устройстве, замер в profile-сборке; иначе хватит async
Future(() => ...) ничего не уносит с UI-изолята: он лишь ставит колбэк в очередь того же event loop, поэтому jsonDecode и fromJson всё так же выполняются там одним длинным событием, и каждый кадр ждёт их окончания. Парсинг нужно запускать в Isolate.run.
Почему так происходит: Future(fn) вызывает fn чуть позже из таймера в текущем изоляте; async и await только ждут работу, которая идёт где-то ещё (I/O, таймеры), и никогда не дробят и не выносят ваш собственный код
Исправление: Isolate.run(() => _parse(body)) декодирует JSON в другом потоке; входная String разделяется, а не копируется, а результат возвращается через Isolate.exit без копирования
Выносите весь парсинг: fromJson по тысячам объектов часто стоит столько же, сколько jsonDecode, поэтому в воркер уходит и то и другое; _parse сделан static, чтобы замыкание захватило только body, а не this
Та же ловушка: Future.microtask, Future.delayed(Duration.zero) и scheduleMicrotask тоже выполняются в вызывающем изоляте; во Flutter compute(_parse, body) служит эквивалентом этого исправления
Как это поймать: в profile-сборке вкладка Performance в DevTools показывает один длинный UI-кадр, в CPU-профиле которого jsonDecode; выносить работу стоит, только когда она дольше кадра на самом слабом поддерживаемом устройстве
Он выведет 100, а затем 7. Каждый изолят получает собственную свежую копию всех top-level и статических переменных, поэтому воркер начинает с инициализатора 0, прибавляет 100 к своей копии, а counter главного изолята не меняется.
Статика начинается заново: синглтон в static final ещё раз вызывает конструктор в воркере, а лениво собираемый кеш на 30 МБ строится в каждом изоляте отдельно
Регистрации не переезжают: GetIt, настроенный Dio, Firebase.initializeApp живут только в корневом изоляте; воркер видит пустой локатор
Плагинам нужна настройка: фоновый изолят может работать с platform channels только после BackgroundIsolateBinaryMessenger.ensureInitialized(token), где RootIsolateToken.instance передан из корневого изолята
Захваченные значения копируются: замыкание для Isolate.run отправляется вместе со всем, что оно захватило, и изменения внутри касаются только этих копий
Правило: относитесь к воркеру как к чистой функции: всё нужное приходит в сообщении, всё, что он производит, возвращается результатом
Весь State. _raw на деле означает this._raw, поэтому замыкание захватывает this, и его отправка глубоко копирует State, его элемент, дерево виджетов и ScrollController, пока VM не дойдёт до нативного ресурса, который скопировать нельзя, и не бросит ошибку.
Читайте ошибку: в сообщении есть путь до неотправляемого объекта, например <- Instance of '_ReportPageState'; этот путь показывает, что замыкание утащило с собой
Исправление: запускайте изолят из top-level функции, которая получает простые данные: compute(parseRows, _raw) или хелпер вроде parseInBackground(String raw)
Локальной копии не всегда достаточно: VM может использовать один контекст для замыканий одной области видимости, так что setState(() => ...) в том же методе всё равно может захватить this
Что отправить нельзя: ReceivePort, Socket, открытые файлы, DynamicLibrary, Finalizer и объекты Finalizable, а также объекты dart:ui на нативной памяти, например Image и Picture
Что можно: всё остальное внутри одной группы изолятов, включая ваши классы и замыкания, но с глубоким копированием, так что идентичность теряется
ReceivePort является стримом с одной подпиской: first подписывается, берёт одно сообщение и отписывается, а это ещё и закрывает порт, поэтому второй listen бросает ошибку. Слушайте порт один раз и считайте первое сообщение (SendPort воркера) handshake.
Зачем вообще handshake: SendPort работает в одну сторону: spawn даёт воркеру способ достучаться до вас, поэтому воркер создаёт свой ReceivePort и первым делом отправляет назад его sendPort
Один слушатель на всё: направляйте первое сообщение в Completer<SendPort>, а все следующие отправляйте в обработчик ответов
Не отправляйте раньше времени: завершение spawn не значит, что воркер уже слушает; пока не пришёл handshake, задачу отправить некуда
Следите за смертью на старте: передайте onExit в spawn, иначе воркер, упавший до handshake, оставит await ready.future висеть вечно
Закрывайте порты при завершении: открытый ReceivePort держит свой изолят живым
Она убила воркер и напечаталась в консоль. Необработанная ошибка никогда не переходит в изолят, из которого запустили воркер, поэтому ответ, который вы ждёте через await, так и не приходит; отсюда бесконечный лоадер.
**onError**: SendPort, который получает каждую необработанную ошибку списком из двух элементов: toString() ошибки и стектрейс строкой или null; сам объект ошибки не передаётся, поэтому поймать его по типу нельзя
**onExit**: срабатывает при любом завершении изолята, включая kill(); используйте его, чтобы завершить ошибкой все ждущие запросы
**errorsAreFatal**: по умолчанию true; ставьте false для долгоживущего воркера, который должен пережить одну неудачную задачу
Лучше: ошибки как данные: оборачивайте каждую задачу в try/catch внутри воркера и отправляйте назад (id, error), чтобы упал один запрос, а остальные продолжили работу
**Isolate.run делает это за вас**: ошибка, брошенная или полученная через await внутри вычисления, пробрасывается в вызывающий код со своим типом и стектрейсом; только необработанная ошибка, например из future без await, приходит как RemoteError
Отчёты о крашах: runZonedGuarded и PlatformDispatcher.instance.onError в корневом изоляте ошибок воркера не видят; пересылайте их сами
kill() по умолчанию использует priority: Isolate.beforeNextEvent: остановиться, когда управление в следующий раз вернётся в event loop. Синхронный цикл считается одним событием, поэтому воркер сначала его заканчивает; Isolate.immediate прерывает его посреди цикла.
**Isolate.immediate: VM прерывает выполняемый код на ближайшей проверке safepoint
Ничего не раскручивается**: ни finally, ни ожидающих future, ни таймеров; недописанный файл так и останется недописанным, поэтому никогда не полагайтесь на kill, чтобы закрыть базу данных или сбросить данные
Он асинхронный: kill() отправляет управляющее сообщение и сразу возвращается; на следующей строке изолят ещё жив, поэтому ждите onExit, если нужно, чтобы он точно завершился
**У run и compute нет хендла: их нельзя убить, можно только проигнорировать результат
Предпочитайте кооперативную отмену**: отправьте сообщение, и пусть воркер проверяет флаг между кусками работы; kill(priority: Isolate.immediate) оставьте запасным вариантом для зависшего воркера
dispose не закрывает _results и не убивает воркер, поэтому после каждого визита остаются работающий изолят-сканер и открытый порт, чей слушатель держит мёртвый State живым и вызывает у него setState. Порт нужно закрыть, а изолят убить в dispose.
Никто не остановит изолят за вас: он живёт, пока его entry-функция не вернётся без оставшейся работы, не вызовет Isolate.exit или пока его не убьют; Flutter освобождает виджеты, а не изоляты, поэтому таймер или цикл внутри scanFolder работает вечно
Открытый порт даёт вторую утечку: ReceivePort остаётся открытым до close(), а его слушатель держит State, поэтому освобождённая страница никогда не собирается сборщиком мусора, а каждое сообщение вызывает setState у уничтоженного State и бросает исключение (в debug это FlutterError)
Исправление: сначала закрыть порт, чтобы ни одно сообщение не дошло до State, затем kill(priority: Isolate.immediate), который не ждёт окончания текущего события воркера; сам воркер этого никогда не заметит, потому что send в закрытый порт молча отбрасывается
Гонка при запуске: Isolate.spawn завершается асинхронно; если страницу закрыли раньше, _worker ещё null, поэтому then убивает новый изолят, когда mounted уже false
Как это поймать: в селекторе изолятов DevTools после каждого визита появляется ещё один scanner (для этого и нужен debugName); в обычном Dart открытый ReceivePort не даёт процессу завершиться
send синхронно и глубоко копирует весь граф объектов на отправляющем изоляте. Глубоко неизменяемые значения (строки, числа, булевы, null, константы) не копируются, а разделяются, поэтому строка почти ничего не стоит, а массив байтов требует полного копирования памяти.
Платит отправитель: копирование происходит внутри send, поэтому большое сообщение, отправленное с UI-изолята, вызывает джанк на UI-изоляте, хотя остальное делает воркер
Стоимость линейная: String на 40 МБ уходит за доли миллисекунды на десктопной VM, те же байты в Uint8List уходят за несколько миллисекунд, а на телефоне ещё медленнее
Графы объектов дают худший случай: декодированный JSON состоит из миллионов мелких объектов, и каждый копируется; отправляйте внутрь сырую String, а наружу отдавайте компактный результат
**Isolate.exit: передаёт последнее сообщение без копирования (см. вопрос про группу изолятов)
TransferableTypedData: перемещает буфер байтов вместо повторного копирования
Практическое правило**: если у вас есть и response.body, и response.bodyBytes, отдавайте воркеру String
Начиная с Dart 2.15, Isolate.spawn создаёт новый изолят в группе вызывающего: изоляты разделяют загруженную программу и одну управляемую кучу, хотя каждый видит только свои объекты. Раз куча общая, Isolate.exit может передать последнее сообщение, не копируя его.
Что разделяет группа
Код и метаданные классов: ничего не загружается и не компилируется заново, поэтому spawn занимает намного меньше миллисекунды и мало памяти
Одна куча и один сборщик мусора: объекты остаются приватными для своего изолята, но воркер, активно выделяющий память, вызывает сборки, которые ненадолго останавливают и UI-изолят
Отправить можно любой объект: включая ваши классы и замыкания, потому что у обеих сторон одни и те же классы; отказ получают только владельцы нативных ресурсов
Isolate.exit
Отправить и умереть за один шаг: Isolate.exit(port, message) сразу завершает изолят, а получатель получает те же самые объекты, без копии
После него ничего не выполняется: ни finally, ни ожидающие future; вызывайте его последним, когда результат готов
**На нём построен Isolate.run**: поэтому одноразовый воркер возвращает огромный результат почти бесплатно, а долгоживущий платит копированием за каждый send
За пределами группы
**Isolate.spawnUri**: загружает отдельную программу в новой группе; через границу проходят только примитивы, списки и мапы из них, SendPort и TransferableTypedData, а Isolate.exit туда не работает. Это инструмент для тулинга и серверов; во Flutter-приложениях он практически не используется
Он делает каждую отправку передачей буфера вне кучи за постоянное время, но TransferableTypedData.fromList один раз копирует байты при создании. Для одного перехода это та же одна копия, что делает обычный send, поэтому выигрыш появляется на нескольких переходах или на границе spawnUri.
Копия делается при создании: fromList копирует данные в память вне кучи Dart; send затем передаёт указатель, а materialize() даёт получателю ByteBuffer без новой копии
Один раз и навсегда: materialize() можно вызвать один раз и ровно в одном изоляте; после отправки хендл у отправителя пуст, и вызов там бросает ArgumentError
Исходник не освобождается: fromList его скопировал, поэтому исходный Uint8List держит свои 40 МБ, пока вы не отпустите ссылку; если оставить её, пиковая память удвоится
Склеивает куски: fromList принимает список typed data и объединяет их, удобно для буфера, собранного из многих закодированных частей
Когда не нужен: для одного перехода внутри группы обычный send или Isolate.exit так же дёшевы и проще
Только VM: на вебе бросает UnsupportedError
Когда работа одного вызова мала по сравнению с тем, что стоит и выбрасывает запуск изолята. Каждый Isolate.run поднимает изолят, копирует захваченные входные данные и теряет всё прогретое; долгоживущий воркер платит за старт один раз и сохраняет свои кеши.
Цена запуска: заметно меньше миллисекунды на десктопной VM и несколько миллисекунд на слабом телефоне, плюс новый event loop и свежие глобальные переменные; сообщение уже работающему воркеру обходится в микросекунды
Потерянное тёплое состояние: разобранная таблица, скомпилированный RegExp, открытая база данных пересоздаются на каждом вызове, и это обычно намного дороже самого запуска
Нет back-pressure: пятьдесят строк, прокрученных в зону видимости, превращаются в пятьдесят изолятов, которые делят четыре ядра и память
Тёплому воркеру нужен протокол: id запроса, Map<int, Completer> для маршрутизации ответов, ошибки, отправленные назад как данные, и сообщение о завершении вместо kill
Правило: Isolate.run для редких тяжёлых задач вроде одного большого ответа; воркер или небольшой пул для потока мелких, например миниатюр
Прогресс передают через SendPort, в который воркер пишет по ходу работы. Отмена приходит сообщением, которое воркер должен сам проверять, потому что снаружи изолята прервать работающий цикл Dart нельзя ничем, кроме убийства.
Два направления требуют двух портов: воркер создаёт свой ReceivePort для управляющих сообщений и первым делом отправляет назад его SendPort, потому что ReceivePort отправить нельзя
Сообщения приходят на витке event loop: один долгий синхронный цикл не даёт event loop провернуться, поэтому воркер прочитал бы отмену только после окончания работы
Режьте на куски и уступайте: await Future<void>.delayed(Duration.zero) между кусками; await null создаёт микрозадачу, и сообщения порта она не доставляет
Размер куска определяет задержку отмены: подбирайте его под обещанную отзывчивость, а не под пропускную способность
**kill(priority: Isolate.immediate)**: запасной вариант для зависшего воркера; он пропускает finally, так что временные файлы останутся
В конце закрывайте все порты: открытый ReceivePort держит изолят живым, и это самая частая утечка изолятов
Размер пула выбирайте по Platform.numberOfProcessors и бюджету памяти, а освободившийся воркер пусть сам берёт следующую задачу из одной общей очереди. Именно заранее закреплённые за воркерами задачи позволяют одному огромному изображению заблокировать всё, что стоит за ним.
**Начинайте с numberOfProcessors - 1, ограничьте тремя-четырьмя: одно ядро принадлежит UI, а телефон включает в это число и медленные энергоэффективные ядра
Настоящий потолок задаёт память: у каждого воркера свои объекты, а RGBA-битмап на 40 МП занимает около 160 МБ, пока обрабатывается; если их четыре одновременно, ОС убьёт приложение раньше, чем загрузится CPU
Одна общая очередь: раздача по кругу даёт блокировку головы очереди (head-of-line blocking) с лишними шагами
Для скролла новые задачи идут первыми: видимую строку запросили последней; выбрасывайте задачи для уехавших за экран строк и ограничьте очередь, чтобы один флинг не поставил тысячу задач
Вытеснения нет**: работающую задачу прервать нельзя, поэтому сторожевой таймер убивает воркер через Isolate.immediate, завершает его задачу ошибкой и запускает замену
На вебе нет изолятов Dart. compute во Flutter web просто выполняет await null;, а затем return callback(message);: одна уступка микрозадаче, а потом ваша функция в главном потоке браузера.
Isolate.spawn и Isolate.run бросают UnsupportedError и в JavaScript-, и в WebAssembly-сборке, так что --wasm не спасает. compute есть на вебе только для того, чтобы один и тот же вызов компилировался везде.
Лучше всего: не делать эту работу в браузере: перенесите её на сервер или используйте браузерные API, которые уже работают вне главного потока, например crypto.subtle или createImageBitmap
Дальше: резать и уступать: await Future<void>.delayed(Duration.zero) между кусками даёт браузеру отрисовать кадр; await null создаёт только микрозадачу и кадр отрисовать не даёт, поэтому compute и вешает вкладку
В крайнем случае: настоящий Web Worker: вторая точка входа на Dart, скомпилированная в JavaScript и управляемая через postMessage; данные проходят через structured clone, поэтому отправляйте строки или простые JS-значения, но не объекты Dart. Пакеты вроде squadron и isolate_manager оборачивают это
Держите место вызова чистым: условный экспорт выбирает версию с изолятом или веб-версию, вместо проверок kIsWeb по всему коду
DevTools смотрит на один изолят за раз: CPU-профайлер, отладчик и снимки кучи следуют за изолятом, выбранным в селекторе изолятов, а по умолчанию это главный. Работы и памяти вашего воркера просто нет на экране, пока вы его не выберете.
Дайте воркеру имя: debugName: 'export-worker' в spawn или run; безымянные воркеры видны как неразличимые id
Ловите короткие задачи: запускайте с paused: true, выберите изолят в DevTools, затем вызовите isolate.resume(isolate.pauseCapability!)
Размечайте фазы: оборачивайте их в Timeline.timeSync('export.encode', ...) из dart:developer; frame chart показывает только работу UI и raster, события таймлайна от воркера видны на своей дорожке во вкладке Performance
Profile-режим на реальном устройстве: debug-сборки работают на JIT и искажают все замеры
Ищите утечку сравнением снимков кучи: сделайте два до и после запуска и отфильтруйте по классам воркера; запущенные изоляты делят одну кучу с корневым, поэтому снимок покрывает всю группу. Типичные причины живут в конкретном изоляте: top-level кеш без вытеснения, незакрытый ReceivePort, подписка, пережившая свой запрос
Внешние байты не видны на графике кучи: буферы изображений и TransferableTypedData сначала проявляются в ProcessInfo.currentRss и в системных инструментах памяти