При разработке Dart изначально была заложена идея полной независимости динамической памяти (objectstore / heap), снимка кода и event loop между изолятами. Одним из сценариев использования такой изоляции является разделение сервисного процесса виртуальной машины (vm-service, используется в том числе в DevTools) и основного кода, но также API для изолятов позволяет создавать собственные независимые процессы для выполнения кода с автономной памятью. Но как на самом деле работают изоляты сейчас и почему порты на самом деле существуют в модели "плоского мира", попробуем разобраться в этой статье.

Важно отметить, что несмотря на изоляцию областей динамической памяти, между изолятами гораздо больше общего, чем раздельного. Все изоляты используют общий экземпляр Dart VM, частично переиспользуются снимки инструкций (полученные в результате компиляции и общие статические константы), а также все порты в действительности хранятся в единой хэш-таблицы и мы можем отправить данные в любой из них из любого изолята (или даже из Flutter Embedder). Да, все что здесь описано, также применимо для использования с Flutter Framework, но с этим мы разберемся в отдельной статье, где будут рассмотрены подробности работы с потоками и платформенными каналами в Embedder.

Прежде всего вспомним, как запускается приложение на Dart. Исходный код приложения (и все исходные коды подключаемых пакетов) компилируются в промежуточное представление, представляющее собой абстрактное синтаксическое дерево с выделением основных токенов и структурных элементов языка. Далее оно может быть запущено в режиме JIT-компиляции, когда код постепенно преобразуется в оптимизированный машинный код (после многократного исполнения, уточнения типов и исключения неиспользуемых фрагментов), этот режим используется в компиляции в режиме debug и он позволяет выполнять hot reload без сброса состояния памяти. Второй сценарий запуска - предварительная компиляция в оптимизированный машинный код (AOT), когда результатом становится исполняемый файл, внутри которого интегрированы снимки сериализованных представлений объектов данных (констант времени компиляции), этот режим используется при создании exe-файлов (и в profile/release-сборках для Flutter). В обоих случаях код взаимодействует с базовой библиотекой Dart VM, которая в том числе обеспечивает механизм поддержки изолятов. Библиотека написана на языке C и исходный текст можно быть получен с github-репозитория https://github.com/dart-lang/sdk/.

Вспомним некоторые базовые сущности Dart-приложения:

  • глобальная или статическая константа в классе (или константный класс), в том числе строки - при компиляции сохраняется в сериализованном виде в снимке (snapshot) и загружаются в динамическую память при запуске изолята и представляют начальное "прогретое" состояние памяти. Константы являются иммутабельными, поэтому нет никаких проблем с переиспользованием снимка памяти между разными изолятами.

  • зона - управляемое окружение для запуска Dart-кода, которое позволяет переопределить управление виртуальным временем, сделать перехват обработки выводимых сообщений, изменить поведение event loop, сделать дополнительную обработку вызовов функций или создания объектов класса. Также зона является получателем исключения в случае, если оно не было обработано в основном коде в блоке try-catch;

  • переменная может сохранять иммутабельное значение простого типа (числа, логические значения, строки) или указатель на данные экземпляра объекта. Здесь важно помнить, что код сохраняется однократно и хранится независимо от данных и при создании нового экземпляра переиспользуется. При создании объекта выделение памяти происходит внутри области динамической памяти, уникальной для каждого изолята;

  • изолят - механизм Dart для разделения динамической памяти и очередей event loop с возможностью запуска параллельных потоков для выполнения кода (в терминах Dart - MutatorThread);

  • порт - внутренний механизм для передачи сообщений между изолятами, получателем всегда является один изолят (тот, который его создал), но отправлять сообщения могут неограниченное количество изолятов. Порты используются для передачи данных при запуске изолята (сериализованные аргументы функции), получения результата вычисления в изоляте, передачи сообщений (например, заданий) в долгоживущий изолят (например, используется в Conduit для выполнения обработки http-запросов в параллельных потоках), а также для управления самим изолятом.

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

Кроме изолятов для выполнения кода, Dart VM настраивает как минимум один изолят vm-service (используется для получения актуальной информации о состоянии VM, в том числе для регистрации и запроса списка существующих изолятов, который также используется в Observatory / DevTools). При использовании JIT-компиляции также появляется kernel-service, который используется для компиляции и hot reload.

Для отладки изолятов прежде всего начнем со сборки Dart SDK с поддержкой отладки. По умолчанию, в сборке из пакетов флаги трассировки не поддерживаются, исправим это:

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH=`pwd`/depot_tools:$PATH
mkdir dart-sdk
cd dart-sdk
fetch dart
cd sdk
./tools/build.py --mode debug create_sdk
#здесь может быть архитектура x64 или другой каталог для Windows/Linux-сборки, путь можно посмотреть в строке ninja: Entering directory во время сборки
export PATH=`pwd`/xcodebuild/DebugARM64/dart-sdk/bin:$PATH
dart --version
cd ../..

Добавления флага --mode debug включает поддержку опций трассировки виртуальной машины, которые нам потребуется, чтобы более подробно изучить механизм изолятов.
В названии версии должно быть упомянуто (main), что означает что сборка выполнена из текущего состояния ветки main git-репозитория Dart SDK.

Начнем с простого проекта по умолчанию (в Dart это консольное приложение, которое выводит сумму чисел):

dart create demoproject
cd demoproject
dart compile exe bin/demoproject.dart

Теперь при запуске включим трассировку операций с изолятами. При запуске в AOT-режиме флаги (которые в том числе, управляют поведений пула потоков при создании нескольких изолятов) добавляются в переменную окружения DART_VM_OPTIONS и записываются через запятую названиями опций с префиксом --. Для запуска в jit-режиме флаги могут передаваться как аргументы командной строки в dart (dart --trace_isolates bin/demoproject.dart). Например, добавим трассировку операций с изолятами и портами (а также операций внутри vm-service):

DART_VM_OPTIONS="--trace_isolates,--trace_service" bin/demoproject.exe

Теперь мы можем видеть все операции, связанные с открытием/закрытием портов, запуском и остановкой изолятов, отправкой и обработкой сообщений. Как можно видеть, при запуске Dart VM создаются два управляющих порта и два изолята (vm-isolate для запуска кода и vm-service для выполнения фоновых задач, например сборки мусора), при этом и изоляты и порты определяются глобальным идентификатором, единым для всей Dart VM (их также можно увидеть в DevTools на вкладке VM Tools/VM). Запуск изолята сопровождается заполнением памяти снимков данных и кода (isolate snapshot), при этом может использовать как снимок скомпилированного приложения (код и константные данные), так и внешний .aot (из файла или URI).

В приложении могут существовать несколько типов изолятов:

  • Kernel Isolate - существует только один, используется для JIT-режима и обеспечивает компиляцию и горячее обновление кода, для трассировки можно добавить флаг --trace_kernel;

  • Service Isolate - тоже только один на приложение, реализует регистрацию запущенных изолятов (используется в DevTools), профилирование, регистрацию событий для timeline, управление breakpoints, для трассировки флаг --trace_service. Полный список поддерживаемых методов можно посмотреть в файле runtime/vm/service.cc, константа ServiceMethodDescriptor. Теоретически, если получить send-порт для отправки сообщений в vm-service, можно из кода получать информацию о текущем состоянии приложения (выделении памяти, списка объектов, зарегистрированных изолятах и портах и др.). Эта же информация публикуется для подключения DevTools через WebSocket при запуске dart --observe;

  • VM Isolate - изолят с выполняемым кодом (минимум один создается при запуске, могут быть созданы программно при необходимости). Изоляты объединяются в группы изолятов, системные изоляты существуют в своих группах, как и main-изолят, но при запуске новых изолятов внутри группы, новые группы не порождаются и передача объектов через порт происходит частичным переиспользованием внутреннего представления объекта без необходимости полной сериализации-десериализации;

  • Temp Isolate - создается внутри Dart VM при создании нового изолята с FFI-функцией (запуска нативного кода в отдельном потоке).

В реализации изолятов в Dart VM для Flutter (https://github.com/flutter/engine/blob/main/runtime/dart_isolate.cc) также запускается дополнительный платформенный изолят для использования в PlatformTaskRunner (обеспечивает координацию между всеми TaskRunner на стороне платформы).

Посмотрим теперь, сколько на самом деле потоков выполнения (threads) создает наше приложение при запуске:

для MacOS:
ps M `ps -Af | grep demoproject | grep -v grep | awk '{ print $2 }'`

Для Linux:
ps huH -p `ps -Af | grep demoproject | grep -v grep | awk '{ print $2 }'`

Как можно видеть, приложение запускается с основным потоком (связанным с консолью) и несколькими дополнительными потоками (используется MutatorThreadPool для запуска потоков, выполняющих Dart-код), из которых используются только два - для service isolate и для vm isolate (и могут создаваться вспомогательные потоки внутри изолятов для фоновых задач, например выполнения Garbage Collect).

Добавим теперь запуск нового изолята и посмотрим на изменения состояния:

import 'package:demoproject/demoproject.dart' as demoproject;

import 'dart:async';
import 'dart:isolate';

String hello(String s) {
  print('Hello $s');
}

void main(List<String> arguments) async {
  print('Hello world: ${demoproject.calculate()}!');
  //для запуска с ожиданием Isolate.spawn(hello, 'world', paused: true, debugName: 'newIsolate');
  print(await Isolate.run(() => hello('world')));
}

Последовательность запуска в нашем приложении:

  • открывается control port и запускается изолят (vm-service), открывается еще один порт для передачи команд в vm-service (будет использоваться для передачи сообщений об изменении состояния новых изолятов);

  • открывается control port и запускается изолят main, main отправляет приветственное сообщение для регистрации в vm-service;

  • открывается контрольный порт с именем newIsolate (если бы не было указано debugName, имя было бы по названию функции hello) и с ним связывается новый изолят newIsolate, сообщаем об этом в vm-service;

  • открывается sendPort для передачи в изолят newIsolate (внутри spawn), также открывается receivePort для получения ответа, обработчик сообщений привязывается к изоляту main;

  • отправляется ответ из newIsolate -> main (результат вычисления функции)

  • поскольку мы сами не остановили изолят, то его останавливает dart vm (отправкой сообщения в control port)

[+] Opening port: 
	handler:    newIsolate
	port:       3430006360344695
[+] Starting isolate:
	isolate:    newIsolate
[>] Posting message:
	len:        60
	source:     (3430006360344695) newIsolate
	dest:       vm-service
	dest_port:  4024355357029071
[<] Handling message:
	len:        60
	handler:    vm-service
	port:       4024355357029071
[.] Message handled (OK):
	len:        60
	handler:    vm-service
	port:       4024355357029071
[+] Opening port: 
	handler:    newIsolate
	port:       3761695881728719
[>] Posting message:
	len:        0
	source:     (3430006360344695) newIsolate
	dest:       newIsolate
	dest_port:  3761695881728719
[>] Posting message:
	len:        49
	source:     (3430006360344695) newIsolate
	dest:       main
	dest_port:  4544454706936315
[+] Starting message handler:
	handler:    newIsolate
[<] Handling message:
	len:        49
	handler:    main
	port:       4544454706936315
[-] Closing port:
	handler:    main
	port:       4544454706936315
[.] Message handled (OK):
	len:        49
	handler:    main
	port:       4544454706936315
[-] Stopping message handler (OK):
	handler:    main
...
[>] Posting message:
	len:        32
	source:     <native code>
	dest:       newIsolate
	dest_port:  3430006360344695
[.] Message handled (OK):
	len:        54
	handler:    vm-service
	port:       4024355357029071
[<] Handling message:
	len:        32
	handler:    newIsolate
	port:       3430006360344695
[!] Unhandled exception in newIsolate:
         exception: isolate terminated by vm

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

Создание изолята происходит одним из двух сценариев:

  • heavyweight - изолят создан из главного изолята (parent у него имеет название main), в этом случае создается полная копия snapshot памяти изолята

  • lightweight - используется, если указана группа изолятов (появляется после heavyheight), изолят создается в пределах IsolateGroup и частично переиспользует память (в частности создается общий snapshot для кода) При запуске изолята используется или app_snapshot и для данных и для инструкций (если он порождается через spawn / run) или может загружаться внешний .aot-файл (из файла или URI, тогда для него создается полностью новый контекст). Вся логика создания изолятов и их групп, а также стартовое состояние снимка памяти для инструкций и данных определяется при запуске DartVM в точке входа main (в main_impl.cc).

Сообщения между изолятами передаются посредством портов, при этом порт может рассматриваться как очередь сообщений, в которых кроме обычных сообщений, есть еще приоритетные oob-сообщения для управления самим изолятом (через API они доступны как методы класса Isolate для приостановки/продолжения/завершения изолята, подключения и отключения обработчиков событий успешного завершения или ошибки). Также изолят может быть завершен из кода (через exit) или автоматически при завершении основного изолята (terminated by VM).

Список активных изолятов можно получить через Dart VM Service Protocol, команду getVM, а список связанных receiver-частью портов через команду getPorts или увидеть на вкладке VM Tools / VM в DevTools.

Для управления изолятами используется относительно простой API:

  • Isolate(controlPort) - создать изолят с привязанным управляющим портом (можно создать дополнительный объект Isolate к уже существующему изоляту, для этого можно извлечь из него isolate.controlPort);

  • Isolate.spawn(func, message, debugName: ...) - создает новый изолят (в отдельной группе, если из main, в той же группе, если из существующего изолята, уже присоединенного к группе). debugName будет использоваться в трассировке. Снимок кода изолята сохраняется как в исходном приложении, функция должна быть глобальной (нельзя использовать методы класса);

  • await Isolate.run(() => func, debugName: ...) - создает новый изолят, запускает в нем функцию и возвращает Future, которая разрешается в результат (или ошибку) при завершении выполнения изолята;

  • Isolate.spawnUri(uri, args, message, debugName: ...) - извлечь исходный текст (можно использовать в debug-сборке) или .aot (можно применять в release) и запустить изолят с ним. Здесь снимок памяти инструкций и данных полностью загружается из внешнего файла. AOT-файл может быть расположен как в сети, так и на локальной файловой системе. Важно: во Flutter этот механизм не доступен;

  • Isolate.current - получить объект с описанием текущего изолята;

  • Isolate.exit - завершить выполнение изолята изнутри и вернуть результат;

Объект Isolate поддерживает выполнение следующих действий:

  • pause() - приостановить выполнение (возвращает Capability-объект, в котором есть возможность продолжения выполнения);

  • resume(cap) - продолжает выполнение (принимает ранее полученный от pause объект capability);

  • kill - принудительное завершение изолята (сразу или на обработке следующего события);

  • ping - проверка доступности изолята, возвращает указанное сообщение через переданный порт в случае, если изолят существует и не был завершен в коде или со стороны VM.

  • addErrorListener / removeErrorListener - добавить/удалить порт для отправки сообщения при завершении изолята с ошибкой

  • addOnExitListener / removeOnExitListener - добавить/удалить порт для отправки результата выполнения (можно передать значение)

Посмотрим более внимательно на порты. Внутренняя реализация портов представлена в классе PortMap и представляет хэш-таблицу, где порт идентифицируется случайным числом до 2^53 и, при желании, может быть зарегистрирован в общем реестре. Несмотря на то, что кажется что порт относится к конкретному изоляту (поскольку создается в нем, как переменная в локальном heap’е), но в действительности все порты (вернее их send-часть) доступны всем изолятам.

Во Flutter для работы с портами можно использовать статические методы класса IsolateNameServer:

  • registerPortWithName (регистрирует sendPort по глобальному имени)

  • lookupPortByName(name) - находит sendPort внутри любого изолята (по глобальному имени)

  • removePortNameMapping(name) - удаляет привязку порта от глобального имени

Все созданные порты автоматически закрываются при завершении изолята, которых их создал. Сообщения, отправленные в порт (со стандартным приоритетом) обрабатываются в event loop, наряду с другими асинхронными задачами, для этого PortReceiver реализует интерфейс Stream и может быть интерпретирован как поток значений.

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

  • для примитивных типов выполняется сериализация в любом случае (при использовании TransferrableTypeData передается указатель, из которого выполняется копирование в память нового изолята);

  • для heap-объектов в пределах группы изолятов выполняется копирование внутреннего представления и выделение памяти в памяти нового изолята (без полной сериализации);

  • в случае несовпадения групп изолятов выполняется полная сериализация объекта и повторная материализация его в контексте изолята получателя порта.

В следующей части статьи мы рассмотрим отличия в реализации изолятов/портов в случае использования Flutter, а также поговорим о способах взаимодействия между Embedder, FFI-кодом, Flutter Engine с помощью использования Dart-портов и посмотрим, как можно улучшить производительность приложения при запуске на мобильных и desktop-платформах.

А в заключение приглашаю на открытый урок, посвященный разработке плавных и отзывчивых Flutter-приложений, который пройдет 24 июля в 20:00.

На занятии мы поговорим о типичных проблемах, из-за которых возникают «зависания» интерфейса (в том числе на Impeller), научимся их обнаруживать с помощью инструментов DevTools, Perfetto и интегрировать замеры производительности в код с помощью dart:developer. Также мы посмотрим принципы работы ServiceExtensions и создадим собственное расширение для отслеживания виджетов с потенциальными проблемами. Для примера будем оптимизировать несложную игру с большим количеством визуальных эффектов, из-за которых в исходном варианте не получается достичь ожидаемых 60 кадров в секунду.

Записаться на урок можно на странице курса "Flutter Mobile Developer".

Комментарии (2)


  1. PackRuble
    24.07.2024 13:50

    Спасибо! Сложно и подробно.


  1. MADTeacher
    24.07.2024 13:50

    Спасибо! Интересно было ознакомиться со статьей!

    Имеется один вопрос. Согласно документации Dart, речь о создании новой изоляционной группы идет только при использовании статического метода spawnUri класса Isolate и не фигурирует в методе spawn (его частный случай - метод run). Где можно посмотреть, что новая изоляционная группа создается при использовании spawn в главном изоляте стартовой изоляционной группы приложения?