Start Debugging

В чем разница между isolate в Dart и потоком?

Поток разделяет память со всеми остальными потоками процесса. Isolate в Dart не разделяет: у него собственная куча, один цикл событий, а с другими isolate он общается только сообщениями. Разбираем, что это значит на уровне VM, где isolate groups размывают границу и как все это проявляется во Flutter, FFI и вебе.

Поток - это контекст выполнения, который разделяет кучу процесса со всеми остальными потоками, и именно поэтому многопоточному коду нужны блокировки, атомарные операции и барьеры памяти. Isolate в Dart - это контекст выполнения, который владеет собственной памятью и выполняет один цикл событий, а добраться до другого isolate он может только отправив сообщение через порт. Практическое следствие в том, что в Dart нет ключевого слова lock, нет volatile и нет гонок данных на объектах Dart, а платой становится то, что все передаваемое другому isolate копируется, если только вы не воспользуетесь одним из двух обходных путей. Isolate действительно выполняются на настоящих потоках операционной системы из пула, которым управляет VM, но соответствие не является взаимно однозначным, и вы никогда не программируете против него. Все дальнейшее относится к Dart 3.12.2 и Flutter 3.44.7.

Если вы попали сюда потому, что вычисление подвешивает UI, и вам нужен код, который это исправит, механика описана в руководстве по написанию isolate в Dart для CPU-нагруженной работы. Эта статья о модели, которая лежит ниже, потому что большинство ошибок с isolate на самом деле вызвано неверной ментальной моделью того, чем isolate является.

Модель: одна куча и один цикл событий на isolate

Документация языка Dart укладывает это в одну фразу: “isolate похожи на потоки или процессы, но у каждого isolate своя память и один поток, выполняющий цикл событий”. Здесь два утверждения, и оба важны.

Своя память означает, что у каждого isolate есть собственная копия каждого глобального и статического поля. Объявленная на верхнем уровне int requestCount = 0 - это не одна переменная в программе, а по одной переменной на isolate. Изменение ее в воркере оставляет копию главного isolate нетронутой, потому что, как сказано в документации, “у каждого isolate свои глобальные поля, благодаря чему никакое состояние одного isolate недоступно из любого другого isolate”.

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

// The Dart event loop, conceptually. Dart 3.12.
while (eventQueue.waitForEvent()) {
  eventQueue.processNextEvent();
}

Начавшееся событие ничто не вытесняет. Колбэк, который тратит 90 мс на разбор JSON, удерживает цикл 90 мс, и каждый таймер, каждый завершенный future, а во Flutter и каждый кадр ждут за ним. Это противоположность потоку, который планировщик операционной системы может приостановить посреди инструкции, чтобы дать поработать другому потоку.

Сложите два утверждения вместе, и получится модель акторов: изолированное состояние, последовательная обработка, передача сообщений. Как говорит документация, “отсутствие разделяемого состояния между isolate означает, что такие сложности параллелизма, как мьютексы, блокировки и гонки данных, не возникнут”.

Состояние гонки, которое вы не можете написать на Dart

Это самый наглядный способ почувствовать разницу. В C# следующий код содержит настоящую гонку, и чинится она через Interlocked или блокировку:

// C# 14, .NET 11. Two threads, one heap, one bug.
static int _counter;

var t1 = new Thread(() => { for (var i = 0; i < 100_000; i++) _counter++; });
var t2 = new Thread(() => { for (var i = 0; i < 100_000; i++) _counter++; });
t1.Start(); t2.Start(); t1.Join(); t2.Join();
Console.WriteLine(_counter); // Not 200000. Ever, reliably.

Перевод на Dart гонки не содержит, но и делает не то, чего ждет новичок:

// Dart 3.12.
import 'dart:isolate';

int counter = 0; // one copy per isolate, not one per program

void bump(int times) {
  for (var i = 0; i < times; i++) {
    counter++;
  }
}

Future<void> main() async {
  await Future.wait([
    Isolate.run(() { bump(100000); return counter; }),
    Isolate.run(() { bump(100000); return counter; }),
  ]);
  print(counter); // 0
}

Каждый порожденный isolate доводит собственный counter до 100000, а затем умирает вместе с ним. Главный isolate печатает 0. Здесь нет разорванного чтения, которое надо ловить, и нет блокировки, которую надо добавить, потому что единственной переменной, за которую можно было бы бороться, никогда не существовало. Любое значение, которое должно вернуться, обязано вернуться сообщением, и именно этим является возвращаемое значение Isolate.run.

Что на самом деле выполняет isolate: пул потоков VM

Isolate не висят в воздухе. Dart VM выполняет их на потоках операционной системы, и правила этих отношений описаны в разборе внутреннего устройства Dart VM за авторством Вячеслава Егорова.

Поток операционной системы “может войти только в один isolate за раз. Он должен покинуть текущий isolate, если хочет войти в другой”. И в обратную сторону: “с isolate одновременно может быть связан только один mutator-поток. Mutator-поток - это поток, который выполняет код Dart и использует публичный C API виртуальной машины”.

То есть инвариант звучит как “по одному за раз в обе стороны”, а не “один к одному навсегда”. Разные потоки операционной системы могут выполнять один и тот же isolate в разные моменты, а один поток за свою жизнь может обслужить несколько isolate. VM не выделяет isolate отдельный поток так, как new Thread() выделяет его делегату: “внутри VM использует пул потоков для управления потоками операционной системы, и код построен вокруг понятия ThreadPool::Task, а не вокруг понятия потока операционной системы”. Фоновая работа, такая как сборка мусора и JIT-компиляция, отправляется в этот пул как задачи.

Вывод для вашего кода такой: isolate - это единица, в терминах которой вы рассуждаете, а потоки - деталь реализации под ней. Вы не можете закрепить isolate за ядром, не можете передать isolate в нативный API, ожидающий дескриптор потока, и не должны считать, что идентичность потока операционной системы для вашего isolate сохраняется между точками приостановки.

Isolate groups: разделяемая куча, которую язык от вас прячет

Здесь утверждение “у каждого isolate своя память” перестает быть буквально верным на уровне реализации, и об этом стоит знать, потому что это объясняет цифры производительности.

Начиная с Dart 2.15 VM объединяет isolate в isolate groups. Isolate.spawn и Isolate.run создают новый isolate внутри текущей группы; только Isolate.spawnUri запускает новую группу со свежей копией программы. Внутри группы VM разделяет структуры программы, и, как сказано в разборе внутреннего устройства VM, isolate одной группы “разделяют одну и ту же кучу, управляемую сборщиком мусора”.

Анонс Dart 2.15 приводит цифры: запуск дополнительного isolate в существующей группе стал “более чем в 100 раз быстрее”, а такие isolate “потребляют в 10-100 раз меньше памяти”, чем до появления групп. Поэтому spawnUri - медленный путь, а spawn - тот, к которому вы тянетесь.

Гарантия на уровне языка при этом не меняется. Вы по-прежнему не можете добраться до объектов другого isolate, изоляция обеспечивается выше кучи, а разделяемая куча остается деталью реализации. Но именно она делает возможными еще две вещи.

Копирование - это цена, и есть два способа ее не платить

По умолчанию отправка объекта через SendPort копирует весь его граф объектов. Отправьте Map с 50000 записей, и принимающий isolate получит глубокую копию, а изменения в ней будут невидимы отправителю. Большинство объектов Dart отправлять можно. Документированные исключения - объекты, за которыми стоят нативные ресурсы, например Socket, а также ReceivePort, DynamicLibrary, Finalizable, Finalizer, NativeFinalizer, Pointer, UserTag и все, что помечено @pragma('vm:isolate-unsendable'). Кроме них, как говорит документация, “отправить можно любой объект”.

Первый обходной путь - Isolate.exit. Он “синхронно завершает текущий isolate” и передает финальное сообщение, а поскольку отправитель и получатель находятся в одной группе и, значит, на одной куче, “этот граф объектов финального сообщения будет переназначен принимающему isolate без копирования”. Копии нет, ценой становится то, что isolate завершается прямо здесь: отложенные блоки finally не выполнятся, а поставленная в очередь асинхронная работа не выполнится никогда.

Чаще всего вы получаете это бесплатно. Isolate.run, добавленный в Dart 2.19, реализован поверх Isolate.spawn и Isolate.exit именно для того, чтобы результат возвращался без копирования:

// Dart 3.12. One-shot work, result transferred rather than copied.
final parsed = await Isolate.run(() {
  final text = File('bulk.json').readAsStringSync();
  return jsonDecode(text) as Map<String, dynamic>;
});

Второй обходной путь - TransferableTypedData, который передает владение буфером байтов между isolate без копирования. Используйте его, когда полезная нагрузка - это байты (изображение, скачанный файл, декодированный аудиобуфер), а не граф объектов.

Если вы регулярно отправляете большие результаты, учтите компромисс, который прямо описан в руководстве самого Flutter: “порождение новых isolate и копирование объектов из одного isolate в другой требуют накладных расходов на производительность. Если вы многократно выполняете одно и то же вычисление через Isolate.run, лучшую производительность может дать создание isolate, которые не завершаются сразу”.

async/await - это тоже не поток

Самое частое заблуждение в этой области - считать, что await уносит работу с текущего isolate. Это не так. Future, Stream и await - это конструкции планирования на единственном цикле событий того isolate, в котором вы уже находитесь. Ожидание чтения из сокета отдает цикл, пока операционная система выполняет ввод-вывод, и поэтому асинхронности достаточно для работы с сетью и файлами. Ожидание функции, которая проводит 200 мс в плотном цикле, не отдает ничего, потому что внутри нее нет точки приостановки.

Правило короткое. Асинхронность нужна, чтобы ждать; isolate нужны, чтобы считать. Если дорогая часть - синхронная работа процессора, снять ее с цикла может только isolate. Если результат затем идет в виджеты, сравнение FutureBuilder, StreamBuilder и AsyncValue из Riverpod объясняет, каким асинхронным примитивом его показывать.

Где модель потоков просвечивает во Flutter

Flutter выполняет ваше приложение в главном isolate, который также называют корневым. Как говорит документация Flutter, “приложения Flutter выполняют всю работу в одном isolate, главном isolate”, и “все задачи UI и сам Flutter привязаны к главному isolate”.

Ниже движок действительно использует несколько потоков операционной системы для растеризации, ввода-вывода и работы с платформой, и их расстановка недавно изменилась: начиная с Flutter 3.29, “потоки UI и платформы объединены на iOS и Android. Точнее, поток UI убран, а код Dart выполняется на нативном потоке платформы”. Это изменение на уровне потоков, у которого нет аналога на уровне isolate, и оно хорошо показывает независимость двух слоев. Ваш код Dart не переехал в другой isolate, он переехал на другой поток операционной системы, и в модели isolate этого никто не заметил.

Два следствия больно бьют по фоновым isolate:

// Dart 3.12, Flutter 3.44.7. Platform channels from a background isolate.
Future<void> _isolateMain(RootIsolateToken rootIsolateToken) async {
  BackgroundIsolateBinaryMessenger.ensureInitialized(rootIsolateToken);
  final prefs = await SharedPreferences.getInstance();
  // ... plugin calls now work here
}

Если вы гоняетесь за пропущенными кадрами и еще не уверены, что ответ вообще в isolate, сначала измеряйте: разбор профилирования джанка в DevTools показывает, как отличить долгий синхронный колбэк от проблемы верстки или растеризации, а лечатся они совершенно по-разному. Если работа в итоге относится к платформенной стороне, добавление платформенного кода без написания плагина обойдется дешевле.

FFI - место, где вы касаетесь настоящих потоков

Единственное место, где поток снизу становится виден, - это dart:ffi. Синхронный вызов FFI выполняется на том потоке операционной системы, который в этот момент является mutator-потоком isolate, и блокирует этот поток, а значит и цикл событий isolate, до возврата. Долгие нативные вызовы должны жить в воркер-isolate ровно по той же причине, что и долгие циклы на Dart.

Колбэки в обратную сторону ограничены тем же правилом “один isolate на поток”, и поэтому у NativeCallable (Dart 3.1) есть разные разновидности. NativeCallable.isolateLocal “должен вызываться из того же потока, который его создал”, тогда как NativeCallable.listener и NativeCallable.isolateGroupBound “могут вызываться из любого потока”. Если нативная библиотека дергает ваш колбэк из собственного рабочего потока, isolateLocal - это отложенный аварийный сбой, а listener - тот конструктор, который вам нужен.

В вебе нет ни того, ни другого

В вебе isolate нет вовсе. Dart, скомпилированный в JavaScript, выполняется в единственном потоке браузера, поэтому compute не параллелит, а мягко деградирует: “на веб-платформах это выполнит колбэк в текущем цикле событий. На нативных платформах это выполнит колбэк в отдельном isolate”. Ответ браузера - web workers, но это не замена один в один, потому что “создать web worker можно только объявив отдельную точку входа программы и скомпилировав ее отдельно”, и они копируют данные через границу, не имея тех API передачи, что есть у isolate.

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

Что меняется

У строгой модели есть известная цена: игры, физика и конвейеры обработки изображений платят за копирование данных, логически принадлежащих одному вычислению. Команда Dart исследует избирательное послабление, которое отслеживается в зонтичной задаче о многопоточности с разделяемой памятью в dart-lang/sdk, с языковым предложением от Вячеслава Егорова. Первая фаза охватывает разделяемую нативную память: разделяемые isolate, статические поля, помеченные @pragma('vm:shared'), для тривиально разделяемых типов и вызовы в isolate group из произвольного нативного потока. NativeCallable.isolateGroupBound - видимая верхушка этой работы.

Ничего из этого не меняет модель по умолчанию, и на момент Dart 3.12 к этому стоит относиться как к экспериментальному и читать задачу-трекер, прежде чем закладываться на него. Безопасное допущение для продакшн-кода сегодня прежнее: isolate владеют своим состоянием, сообщения - это копии, а Isolate.exit вместе с TransferableTypedData - ваши единственные пути без копирования.

Как выбрать правильную ментальную модель

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад