Приветствую вас, читатели. Для начала я рекомендую вам ознакомиться с первой частью моего цикла статей.

В этом цикле я пишу о балансе, состояниях разработчика, корутинах и Dart.

Все части:

Об авторе

Меня зовут Антон, и я — чайный программист-даос.
Я 10 лет пишу код на разных языках, увлекаюсь даосизмом, китайским чаем и созданием никому не нужных (кроме меня) велосипедов.
Чуть подробнее обо мне тут.

Шаг 1. Пробуем собрать Dart VM.

Первым делом клонируем репозиторий. Dart SDK весит много.

Когда-то столько же места занимали полноценные 3D-игры со всеми ассетами.

Теперь переходим к настройке и сборке.

Как истинный программист, я решил: "Документация? Да ну, я же знаю все системы сборки!"

Спойлер: не знал.

10:45:20 anton@anton-lin arch-x86_64 ~/development/laboratory/dart main
(?) make
make: *** No targets specified and no makefile found.  Stop.
10:45:21 anton@anton-lin arch-x86_64 ~/development/laboratory/dart main
(?) cmake .
CMake Warning: Ignoring extra path from command line: "."
CMake Error: The source directory "/home/anton/development/laboratory/dart" does not appear to contain CMakeLists.txt.
Specify --help for usage, or press the help button on the CMake GUI.
10:45:24 anton@anton-lin arch-x86_64 ~/development/laboratory/dart main
(?) ninja
ninja: error: loading 'build.ninja': No such file or directory
10:45:31 anton@anton-lin arch-x86_64 ~/development/laboratory/dart main
(?) ./configure
bash: ./configure: No such file or directory
10:45:36 anton@anton-lin arch-x86_64 ~/development/laboratory/dart main
(?) ./gradlew
bash: ./gradlew: No such file or directory
10:46:06 anton@anton-lin arch-x86_64 ~/development/laboratory/dart main
(?) ./build.sh
bash: ./build.sh: No such file or directory
10:46:10 anton@anton-lin arch-x86_64 ~/development/laboratory/dart main
(?) 

После этого перформанса я всё-таки заглянул в документацию.

Оказалось, чтобы собрать один язык, нужен другой:

Dart SDK requires Python 3 to build.

Забавно, что для сборки JVM, конечно, нужен C++ компилятор, но вот Dart требует ещё и Python.

Хотя, возможно, и JVM теперь зависит от Python — давно не проверял.

Но это ещё не всё. Пропустим комментарии и просто поставим необходимое.

Install Chromium's depot tools:

И вот она, разгадка — почему нужны Python и Chromium depot tools. Оказывается, Dart использует Google-специфичный менеджер зависимостей и систему сборки GN (да-да, ту самую, что и в Chromium).

Dart SDK uses gclient to manage dependencies which are described in the DEPS file. If you switch branches or update sdk checkout you need to run gclient sync to bring dependencies in sync with the SDK version.

Спасибо, кэп. Теперь всё ясно.

IMPORTANT: You must follow instructions for Getting the source before attempting to build. Just cloning a GitHub repo or downloading and unpacking a ZIP of the SDK repository would not work.

Так, зависимости подтянули. Теперь можно собирать? Так вот, что нужно было запустить ! А я все make да ninja...

./tools/build.py --mode release --arch x64 create_sdk

Небольшая ремарка, вот статы моего компьютера, на котором я собирал и пересобирал Dart огромное множество раз:

OS: Arch Linux x86_64
Host: ASUSTeK COMPUTER INC. ROG STRIX B650E-I GAMING WIFI
Kernel: 6.14.0-3-cachyos
Uptime: 1 hour, 2 mins
Packages: 1279 (pacman)
Shell: bash 5.2.37
Resolution: 3840x2160, 3840x2160, 3840x2160
WM: Hyprland
Theme: Colloid-Dark [GTK2/3]
Icons: Colloid-Dark [GTK2/3]
Terminal: kitty
Terminal Font: CaskaydiaCove Nerd Font Mono 15.0
CPU: AMD Ryzen 9 7950X3D (32) @ 5.763GHz
GPU: AMD ATI Raphael
GPU: NVIDIA GeForce RTX 4090
Memory: 15602MiB / 63425MiB
Не калькулятор вроде бы.

Давайте посмотрим, сколько ушло времени на полную пересборку в Release режиме:

А вот сколько уходило на пересборку одного изменения в C++ header файле в debug режиме:

Почти 10 минут жди, если ты в debug режиме поменяешь что-то в одном из базовых заголовках в Dart VM C++ коде.

Кстати, во втором случае дольше по той причине, что во время сборки Dart собирает самого себя (частично). Разумеется, debug Dart дольше будет собирать сам себя чем release Dart.

Каждая пересборка Dart. Прости меня, мой верный Ryzen:

Шаг 2. Добавляем свою библиотеку в Dart VM

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

К счастью, существующие примеры в кодовой базе здорово помогли - я просто анализировал, как реализованы другие библиотеки, и повторял аналогичный подход.

Давайте добавим нашу библиотеку dart:fibers во все необходимые места кода.

Интеграция в Dart SDK

  1. Создаем директорию для библиотеки:

    • Переходим в sdk/lib

    • Создаем папку fiber

  2. Добавляем файл сборки:

    • Создаем fiber_sources.gni

    • Заполняем содержимым:

fiber_sdk_sources = [
  "fiber.dart",
]
  1. Реализуем основу библиотеки:

    • Создаем fiber.dart

    • Добавляем минимальную реализацию:

library dart.fiber; // Обязательная декларация

class Fiber { }
  1. Подключаем библиотеку в core:

    • Открываем sdk/lib/core/core.dart

    • Добавляем экспорт: export "dart:fiber" show Fiber;

  2. Создаем тесты:

    • Переходим в runtime/tests/vm/dart

    • Создаем папку fiber

    • Добавляем fiber_test.dart с тестом создания объекта Fiber

  3. Интегрируем в систему сборки:

    • Открываем runtime/vm/BUILD.gn

    • Добавляем импорты:

import("../../sdk/lib/fiber/fiber_sources.gni")
import("../lib/fiber_sources.gni")
  • Включаем файлы в сборку: + fiber_runtime_cc_files в разделе allsources = ...

  1. Обновляем конфигурацию SDK:

    • В sdk/BUILD.gn добавляем "fiber" в _fiber_runtime_cc_files

  2. Настраиваем метаданные библиотеки:

    • Открываем sdk/lib/_internal/sdk_library_metadata/lib/libraries.dart

    • В секцию const Map<String, LibraryInfo> libraries добавляем:

'fiber': const LibraryInfo(
  'fiber/fiber.dart',
  categories: 'Client,Server,Embedded',
  maturity: Maturity.STABLE,
  dart2jsPatchPath: '_internal/js_runtime/lib/fiber_patch.dart',
),

Примечание: параметр dart2jsPatchPath можно опустить, если не требуется платформо-специфичная реализация

После выполнения этих шагов Dart должен успешно собраться.

Откровенно говоря, эта схема построена на основе уже рабочей библиотеки, так что возможны некоторые недочёты — не судите строго.

На этом работа с Dart SDK завершена. Давайте сделаем паузу и задумаемся над ключевым вопросом: достаточно ли возможностей Dart SDK для реализации полноценных корутин?

Теоретически, реализовать их можно — например, через подход stackless корутин, возможно с применением модели состояний.

Но я выбрал другой путь — stackfull корутины с жёстким переключением контекста на уровне Dart ASM. Почему? Потому что stackless-корутины в Dart уже существуют — это привычные Future с механизмом async-await.

Раз уж я пошёл этим путём, остаётся решить главную задачу: научить Dart переключать контекст между стеками корутин. А для этого потребуется интеграция нашей библиотеки в Dart VM.

Интеграция в Dart VM

  1. Добавляем символ библиотеки
    Первым делом открываем runtime/vm/symbols.h и добавляем строку V(DartFiber, "dart:fiber"). Это что-то вроде констант.

  2. Регистрируем объекты в ObjectStore
    В runtime/vm/object_store.h дописываем:

    • M(Fiber, fiber) — метаданные для типа Fiber

    • RW(Library, fiber_library) — ссылку на библиотеку

    ObjectStore выступает в роли глобального реестра объектов, привязанного к группе изолят.

  3. Интеграция с системой библиотек
    В runtime/vm/object.h (несмотря на его внушительные 13595 строк) просто добавляем объявление:

    static LibraryPtr FiberLibrary();
    
  4. Инициализация библиотеки
    В runtime/vm/object.cc реализуем загрузку библиотеки:

    lib = Library::LookupLibrary(thread, Symbols::DartFiber());
    if (lib.IsNull()) {
        lib = Library::NewLibraryHelper(Symbols::DartFiber(), true);
        lib.SetLoadRequested();
        lib.Register(thread);
    }
    object_store->set_bootstrap_library(ObjectStore::kFiber, lib);
    ASSERT(!lib.IsNull());
    ASSERT(lib.ptr() == Library::FiberLibrary());
    
  5. Настройка Native-вызовов
    Регистрируем резолверы для нативной работы:

    library = Library::FiberLibrary();
    ASSERT(!library.IsNull());
    library.set_native_entry_resolver(resolver);
    library.set_native_entry_symbol_resolver(symbol_resolver);
    

Остальные места регистрации следуют той же логике — можно ориентироваться на аналогичные реализации других модулей.

Финальный штрих: поддержка кросс-платформенности
Поскольку код содержит архитектурно-зависимые части, используем механизм @patch. Для этого создаём patch-файлы под каждую платформу Dart:

  • VM: sdk/lib/_internal/vm/lib/fiber_patch.dart

  • WASM: sdk/lib/_internal/wasm/lib/fiber_patch.dart

  • js_runtime: sdk/lib/_internal/js_runtime/lib/fiber_patch.dart

  • js_dev_runtime: sdk/lib/_internal/js_dev_runtime/lib/fiber_patch.dart

Теперь можно переходить к проектированию Fiber API.

Шаг 3. Моделируем API

Мы подошли к самому интересному этапу — проектированию Fiber API. Теперь, когда Dart SDK и VM подготовлены, можно дать волю инженерной фантазии.

class Fiber { // По факту будет extension type, но это детали
  // Каким должен быть идеальный Fiber API?

  // Базовый метод - запуск главного (нулевого) Fiber
  static Fiber launch(void Function() entry, { int size = _kDefaultStackSize, Object? argument}) // size определяет стек корутины

  // Создание дочерних корутин
  static Fiber spawn(void Function() entry, { 
    bool persistent = false, // флаг переиспользования после завершения
    int size = _kDefaultStackSize, 
    String? name, 
    Object? argument
  })

  // Управление выполнением
  static void suspend() // приостанавливает текущий Fiber, возвращая контроль caller'у
  static void schedule(Fiber fiber) // планирует выполнение указанного Fiber
  static void reschedule() // планирует текущий Fiber с переключением контекста

  // Методы для интроспекции
  int get index // уникальный идентификатор
  int get size // текущий размер стека
  String get name // человекочитаемый идентификатор
  FiberState get state // текущее состояние (Created, Running и т.д.)
  FiberAttributes get attributes // флаги поведения
  FiberArgument get argument // обёртка для параметров
}

Бонусный метод, родившийся прямо во время написания статьи:

static void sleep(Duration time) // приостанавливает Fiber на указанное время

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

Такого API достаточно для построения сложных абстракций вроде каналов и условий.

Шаг 4. Пишем реализацию

Самая сложная часть: реализация корутин

Делаем глубокий вдох, сейчас будет тяжко и душно

Прежде чем погружаться в код, важно понять: корутина — это самостоятельный объект, который должен эффективно работать как в C++, так и в Dart-коде. Поэтому объявлять его нужно в обеих частях.

Класс _Coroutine в runtimne/vm/object.h и вsdk/lib/_internal/vm/lib/fiber_patch.dart:

@patch
@pragma("vm:entry-point")
class _Coroutine {
  // Фабричный конструктор для создания корутины
  @pragma("vm:external-name", "Coroutine_factory")
  external factory _Coroutine._(int size, Function trampoline);

  // Основные поля и методы:
  @pragma("vm:recognized", "other")
  external void Function() get _entry;
  @pragma("vm:recognized", "other")
  external set _entry(void Function() value);

  @pragma("vm:recognized", "other")
  external void Function() get _trampoline;
  @pragma("vm:recognized", "other")
  external set _trampoline(void Function() value);

  @pragma("vm:recognized", "other")
  external Object? get _argument;
  @pragma("vm:recognized", "other")
  external set _argument(Object? value);

  // Связь между корутинами
  @pragma("vm:recognized", "other")
  external _Coroutine get _caller;
  @pragma("vm:recognized", "other")
  external set _caller(_Coroutine value);

  // Критические методы управления
  @pragma("vm:recognized", "other")
  external static void _initialize(_Coroutine root);

  @pragma("vm:recognized", "other")
  external static void _transfer(_Coroutine from, _Coroutine to);

  @pragma("vm:recognized", "other")
  external static void _fork(_Coroutine from, _Coroutine to);

  // Текущая выполняемая корутина
  @pragma("vm:recognized", "other")
  external static _Coroutine? get _current;
}

Что здесь особенно важно:

  • @pragma аннотации обеспечивают интеграцию с VM

  • Все поля и методы объявлены как external — их реализация будет нативной

  • Класс содержит минимально необходимый набор для управления контекстом

  • Класс Coroutine в runtimne/vm/object.h (вспомогательные поля скрыты):

class Coroutine : public Instance {
 public:
  static CoroutinePtr New(uintptr_t size, FunctionPtr trampoline);

  void HandleJumpToFrame(Thread* thread, uword stack_pointer);
  void HandleRootEnter(Thread* thread, Zone* zone);
  void HandleRootExit(Thread* thread, Zone* zone);
  void HandleForkedEnter(Thread* thread, Zone* zone);
  void HandleForkedExit(Thread* thread, Zone* zone);

  void recycle(Zone* zone);
  void dispose(Thread* thread, Zone* zone, bool remove_from_registry = true);

  ClosurePtr entry();
  static uword entry_offset();

  intptr_t attributes();
  static uword attributes_offset();
  void set_attributes(intptr_t value);
  void or_attribute(intptr_t value);
  void and_attribute(intptr_t value);
  void change_state(intptr_t from_value, intptr_t to_value);

  ObjectPtr argument();
  static uword argument_offset();

  CoroutinePtr caller();
  static uword caller_offset();

  uword native_stack_base();
  static uword native_stack_base_offset();

  uword stack_root();
  static uword stack_root_offset();

  uword stack_base();
  static uword stack_base_offset();

  uword stack_limit();
  static uword stack_limit_offset();

  uword overflow_stack_limit();
  static uword overflow_stack_limit_offset();
};

Задекларировать - задекларировали, теперь я собираюсь рассказать вам о реализации всех основных функций.

Всего 59 файлов. В каждом есть свои уникальные и интересные особенности Dart VM.

На 1808 результатов не смотрите, у Dart VM есть встроенный генератор C++ кода в одном месте, он там всякое творит.

Поступим так: я выделю несколько самых интересных участков кода реализации и расскажу про них.

Для начала я бы хотел кратко пояснить про основные способы реализации "особых" функций в Dart. Если вкратце:

  • vm:external-name - эти функции реализуются в C++ коде и вызываются из Dart кода. Декларируются с помощью extern и vm:external-name.

  • RuntimeEntry - эти функции реализуются в C++ коде и вызываются из заглушек. Декларируются с помощью DEFINE_RUNTIME_ENTRY, DEFINE_LEAF_RUNTIME_ENTRY и DEFINE_RAW_LEAF_RUNTIME_ENTRY.

  • vm:recognized - эти функции реализуются в скомпилированном Dart коде, но их тела описываются в C++ коде. Ниже будет пример того, как выглядит реализация.

case MethodRecognizer::kCoroutineInitialize: {
  body += LoadLocal(parsed_function_->RawParameterVariable(0));
  body += CoroutineInitialize();
  body += NullConstant();
  break;
}
  • vm:entry-point - реализуется в Dart коде, про них хорошо описано тут

  • @Native - по идее это современная замена vm:external-name, которая появилась в процессе развития FFI функционала, но я решил использовать vm:external-name

Реализация этих способов претендует на отдельную статью, не будем долго на них задерживаться.

Сущности

Живут они в coroutine, bootstrap_natives, object и raw_object.

Задача: создать C++ объект корутины через Dart код. У нас уже есть функция external factory _Coroutine._(int size, Function trampoline);, давайте её реализуем

DEFINE_NATIVE_ENTRY(Coroutine_factory, 0, 3) {
  GET_NON_NULL_NATIVE_ARGUMENT(Smi, size, arguments->NativeArgAt(1));
  GET_NON_NULL_NATIVE_ARGUMENT(Closure, trampoline, arguments->NativeArgAt(2));
  return Coroutine::New(size.Value(), trampoline.function()); // Объявлена в object.h. В ней выполняется выделение памяти под стек и инициализация полей корутины.
}

Теперь про object и raw_object. Все объекты Dart, которые управляются из кода C++ определяются в этих файлах. Почему их два ?

Дело в том, что в Dart существует два представления объектов. Это связано с тем, что Dart использует технику Tagged указателей:

  • Tagged - тегированый указатель на реальный объект. Чтобы "достать" реальный объект, нужно вызвать untag()

  • Untagged - "сырое" представление, реальный физический объект, с которым можно работать

В object реализуются Tagged представления, в raw_object - Untagged.

Теперь расскажу про основные функции наших корутин.


Функции

HandleJumpToFrame

Данная функция вызывается в момент, когда Dart VM захочет "прыгнуть" в другой кадр стека.

Например, у вас произошел выброс исключения, и Dart должен его правильно обработать, "прыгнуть" в ближайший catch или вообще "улететь" из Dart кода.

Такие "прыжки" могут спровоцировать смену корутин, потому что у каждой корутины свой стек. И эти смены стеков нам нужно обрабатывать.

Ситуации и методы обработки следующие:

  • мы не нашли стек, в который выполняется "прыжок" - вызываем HandleRootExit, так как эта ситуация означает, что мы выпрыгнули в оригинальный нативный стек, в котором мы создали нашу первую корутину

  • мы "прыгаем" в рамках текущего стека - ничего не делаем

  • мы "прыгнули" в другую корутину - следовательно, текущую корутину мы уничтожаем (либо идем в recycle, либо в dispose), а новую корутину "пробуждаем" и заходим в неё.

HandleRootEnter и HandleForkedEnter

Для некоторых операций (например, обработки исключений) требуется хранить в объекте потока (Thread) указатель на текущую корутину.

Дополнительно, в Dart VM в некоторых местах выполняется проверка длины стека через поле stack_limit_ объекта потока, а значит, нам нужно это поле изменять при назначении стека корутины в роли текущего.

HandleRootExit и HandleForkedExit

Первая функция выполняется в тот момент, когда мы завершили все наши корутины и планируем вернутся к обычному выполнению потока в обычный "нативный" стек.

Вторая функция - в тот момент, когда дочерняя корутина завершилась.

У нас могут остаться объекты корутин, в том числе корневая корутина, которые нужно уничтожить.

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

recycle и dispose

Обе функции отвечают за обработку корутины, когда корутина уже не нужна.

Разница в том, что recycle переведёт корутину в состояние disposed, но не уничтожит её объект и память, выделенную под стек. Вторая функция полностью уничтожает корутину и связанный с ней стек.


Поля и мета

Файлы: raw_object_fields, slot, runtime_offsets_list, runtime_offsets_extracted, runtime_api.

Эти файлы объединяет общий факт: они - та ещё заноза :)

Расшифрую: каждый раз, когда мне нужно изменить поля или функции в объекте корутины, я вынужден обновлять эти файлы.

raw_object_fields

В этом файле мы указываем поля наших корутин.

Подозреваю, что я не расскажу лучше разработчиков Dart Team:

// This file (and "raw_object_fields.cc") provide a kind of reflection that
// allows us to identify the name of fields in hand-written "Raw..." classes
// (from "raw_object.h") given the class and the offset within the object. This
// is used for example by the snapshot profile writer ("v8_snapshot_writer.h")
// to show the property names of these built-in objects in the snapshot profile.

slot

В слотах объявляются поля в зависимости от их специфики: TAGGED, UNTAGGED, NULLABLE, NONULLABLE и другие.

Слоты используются в основном в IL компиляторе: он преобразует из внутреннего представления Dart VM Kernel в ASM представление.

runtime_offsets_list и runtime_offsets_extracted

А вот эту парочку я вообще обожаю.

Если вам нужно обратиться к полю объекта внутри заглушки, вам нужен offset этого поля. Вообще, на первый взгляд slot и runtime_offset дублируют друг друга.

Я для себя разделил так: slot для IL, runtime_offset для ASM. Пишите в комментариях, если есть более логичное разделение, и я все наврал.

А и ещё, если я не ошибся, если не указать какие-то поля в runtime_offsets_list, могут быть проблемы при генерации Dart Snapshot.

Как с этими файлами работать ? В runtime_offsets_list пишется что-то вроде FIELD(Coroutine, name_offset), а затем запускается ./tools/run_offsets_extractor.dart руками, чтобы сгенерировался сей ужас на 13К строчек:

...
static constexpr dart::compiler::target::word AOT_InstructionsSection_HeaderSize = 0x40;
static constexpr dart::compiler::target::word AOT_InstructionsTable_InstanceSize = 0x30;
static constexpr dart::compiler::target::word AOT_Int32x4_InstanceSize = 0x18;
static constexpr dart::compiler::target::word AOT_Integer_InstanceSize = 0x8;
static constexpr dart::compiler::target::word AOT_KernelProgramInfo_InstanceSize = 0x60;
static constexpr dart::compiler::target::word AOT_LanguageError_InstanceSize = 0x30;
static constexpr dart::compiler::target::word AOT_Library_InstanceSize = 0x88;
static constexpr dart::compiler::target::word AOT_LibraryPrefix_InstanceSize = 0x28;
static constexpr dart::compiler::target::word AOT_LinkedHashBase_InstanceSize = 0x38;
static constexpr dart::compiler::target::word AOT_LocalHandle_InstanceSize = 0x8;
static constexpr dart::compiler::target::word AOT_MegamorphicCache_InstanceSize = 0x30;
...

runtime_api

В этом файле мы декларируем в основном offset-ы - методы, которые возвращают смещения полей.

class Coroutine : public AllStatic {
 public:
  ...
  static word name_offset();
  static word index_offset();
  static word entry_offset();
  static word trampoline_offset();
  ...
  static word InstanceSize();
  FINAL_CLASS();
};

И снова приведу описание от разработчиков:

// This header defines the API that compiler can use to interact with the
// underlying Dart runtime that it is embedded into.
//
// Compiler is not allowed to directly interact with any objects - it can only
// use classes like dart::Object, dart::Code, dart::Function and similar as
// opaque handles. All interactions should be done through helper methods
// provided by this header.
//
// This header also provides ways to get word sizes, frame layout, field
// offsets for the target runtime. Note that these can be different from
// those on the host. Helpers providing access to these values live
// in compiler::target namespace.

Братья изоляты и потоки

Здесь нам нужно добавить обработку сборщика мусора. В изолят:

void Isolate::VisitStackPointers(ObjectPointerVisitor* visitor, ValidationPolicy validate_frames) {
  if (mutator_thread_ != nullptr) {
    if (mutator_thread_->has_coroutine()) { // Вот этот кусок
      mutator_thread_->VisitObjectPointersCoroutine(this, visitor, validate_frames);
      return;
    }
    mutator_thread_->VisitObjectPointers(visitor, validate_frames);
  }
}

А в поток в эту жесть...

...
visitor->set_gc_root_type("frame");
while (frame != nullptr) {
  frame->VisitObjectPointers(visitor);
  frame = thread_frames_iterator.NextFrame();
  if (frame != nullptr && StubCode::InCoroutineForkStub(frame->GetCallerPc())) {
    frame->VisitObjectPointers(visitor);
    break;
  }
  if (frame != nullptr && StubCode::InCoroutineInitializeStub(frame->GetCallerPc())) {
    const uword stub_fp = *reinterpret_cast<uword*>(coroutine_->untag()->native_stack_base());
    StackFrameIterator native_coroutine_frames_iterator(stub_fp, validation_policy, this, cross_thread_policy, StackFrameIterator::kStackOwnerCoroutine);
    StackFrame* frame = native_coroutine_frames_iterator.NextFrame();
    while (frame != nullptr) {
      frame->VisitObjectPointers(visitor);
      frame = native_coroutine_frames_iterator.NextFrame();
    }
    break;
  }
}
auto coroutines = isolate->coroutines_registry().untag()->data();
auto coroutines_count = Smi::Value(isolate->coroutines_registry().untag()->length());
for (auto index = 0; index < coroutines_count; index++) {
  auto item = Coroutine::RawCast(coroutines.untag()->element(index));
  if ((item->untag()->attributes() & (Coroutine::CoroutineAttributes::suspended)) != 0) {
    UntaggedCoroutine::VisitStack(item, visitor);
  }
}
visitor->clear_gc_root_type();
...

Этот код был добавлен в новую функцию VisitObjectPointersCoroutine, которая отвечает за обработку стека всех корутин при сборке мусора.

Аналогичный код пришлось добавить в RestoreWriteBarrierInvariantCoroutine, которая отвечает за восстановление барьеров записи.

Логика этого кода примерно следующая:

  1. Посетить каждый кадр основного стека (стека потока), который является текущим (на него указывает регистр RSP)

  2. Если доходим до функции создания дочерней корутины (CoroutineFork), то останавливаемся

  3. Если доходим до функции инициализации основной корутины (CoroutineInitialize), то выполняем обработку изначального (нативного) стека

  4. Делаем тоже самое (UntaggedCoroutine::VisitStack) для каждой "suspended" корутины

  5. Психуем, потому что этот код на самом деле не работает... точнее сказать, не всегда работает


Чтобы собрать правильно, собери неправильно

Есть такой замечательный файл - recognized_method_list.

Самый смешной файл в моем списке. Помните, выше я говорил про vm:recognized ? Такие методы нужно дублировать в этот файл, но все не так просто.

Алгоритм следующий:

  1. Декларируешь Dart метод с vm:recognized

  2. Декларируешь в recognized_method_list строчку: V(_Coroutine, _initialize, CoroutineInitialize, 0)

  3. Запускаешь сборку

  4. Получаешь ошибку, в которой будет что-то вроде

...
s/0x00000000/0x12345678/
...
  1. Копируешь кусок с s/ в файл и вызываешь sed на этот файл и runtime/vm/compiler/recognized_methods_list.h

Я это не я придумал, смотрите сюда:

// object.cc: CheckSourceFingerprint
if (SourceFingerprint() != fp) {
  // This output can be copied into a file, then used with sed
  // to replace the old values.
  // sed -i.bak -f /tmp/newkeys \
  //    runtime/vm/compiler/recognized_methods_list.h
  THR_Print("s/0x%08x/0x%08x/\n", fp, SourceFingerprint());
  return false;
}

Обожаю этот файл. Особенно учитывая, что при его изменении, нужно пересобрать 1000+ файлов.


Компилятор

В Dart верхнеуровневый компилятор сосредоточен в il и kernel_to_il.

В моей реализации корутин существует три основные функции:

  • CoroutineInitialize - отвечает за инициализацию стека корневой корутины и вызов входящей (entry) функции

  • CoroutineFork - отвечает за инициализацию стека дочерней корутины, переход на дочернюю корутину и обработку выхода из нее

  • CoroutineTransfer - отвечает за смену контекста (переключение стека) между корутинами

Эти функции реализуются на уровне Dart заглушек, а вызываются через Dart IL. Я постараюсь на примере CoroutineInitialize описать, как выглядит добавление такого функционала в Dart.

В il.h мы добавляем инструкцию:

class CoroutineInitializeInstr : public TemplateDefinition<1, Throws> {
 public:
  CoroutineInitializeInstr(Value* root, intptr_t deopt_id) : TemplateDefinition(InstructionSource(TokenPosition::kNoSource), deopt_id) {
    SetInputAt(0, root);
  }
  Value* root() const { return inputs_[0]; }
  virtual bool CanCallDart() const { return true; }
  virtual bool ComputeCanDeoptimize() const { return false; }
  virtual bool ComputeCanDeoptimizeAfterCall() const { return !CompilerState::Current().is_aot(); }
  virtual bool HasUnknownSideEffects() const { return true; }
  virtual intptr_t NumberOfInputsConsumedBeforeCall() const { return InputCount(); }
  virtual bool MayCreateUnsafeUntaggedPointer() const { return true; }
  DECLARE_INSTRUCTION(CoroutineInitialize);
  PRINT_OPERANDS_TO_SUPPORT
  DECLARE_EMPTY_SERIALIZATION(CoroutineInitializeInstr, TemplateDefinition)
 private:
  DISALLOW_COPY_AND_ASSIGN(CoroutineInitializeInstr);
};

В il.cc мы добавляем парочку нужных функций:

LocationSummary* CoroutineInitializeInstr::MakeLocationSummary(Zone* zone, bool opt) const {
  const intptr_t kNumInputs = 1;
  const intptr_t kNumTemps = 0;
  LocationSummary* locs = new (zone)LocationSummary(zone, kNumInputs, kNumTemps, LocationSummary::kCall);
  locs->set_in(0, Location::RegisterLocation(CoroutineInitializeABI::kCoroutineReg));
  return locs;
}

void CoroutineInitializeInstr::EmitNativeCode(FlowGraphCompiler* compiler) {
  compiler->GenerateStubCall(source(), StubCode::CoroutineInitialize(),  UntaggedPcDescriptors::kOther, locs(), deopt_id(), env());
}

Идея этих правок в том, чтобы реализовать вызов заглушки StubCode::CoroutineInitialize, обернув его в IL инструкцию.

Теперь свяжем инструкцию и наш vm:recognized метод CoroutineInitialize с помощью kernel_to_il:

Fragment FlowGraphBuilder::CoroutineInitialize() {
  CoroutineInitializeInstr* instr = new (Z) CoroutineInitializeInstr(Pop(), GetNextDeoptId());
  return Fragment(instr);
}

...
case MethodRecognizer::kCoroutineInitialize: {
  body += LoadLocal(parsed_function_->RawParameterVariable(0));
  body += CoroutineInitialize();
  body += NullConstant();
  break;
}
...

Результат: вызов CoroutineInitialize в Dart коде будет заменен на вызов заглушки CoroutineInitialize.

Далее я вам расскажу про все три заглушки подробно.


Компилятор потяжелее

Здесь мы рассмотрим constants_x64, stub_code_List, stub_code_compiler и stub_code_compiler_x64.

Что нужно Dart для выполнения вставку на языке ассемблера ? Нужен простой советский... stub_code_compiler.

Но для начала хочу напомнить, как выглядит сигнатура наших функций:

  external static void _initialize(_Coroutine root); // Один аргумент
  external static void _transfer(_Coroutine from, _Coroutine to); // Два аргумента
  external static void _fork(_Coroutine from, _Coroutine to); // Тоже два аргумента

Определим регистры для наших функций в constants_x64:

struct CoroutineInitializeABI {
  static constexpr Register kCoroutineReg = RDI; // Один аргумент
};

struct CoroutineForkABI {
  static constexpr Register kCallerCoroutineReg = RSI; // Первый аргумент
  static constexpr Register kForkedCoroutineReg = RDI; // Второй аргумент
};

struct CoroutineTransferABI {
  static constexpr Register kFromCoroutineReg = RDI; // Первый аргумент
  static constexpr Register kToCoroutineReg = RSI; // Второй аргумент
  static constexpr Register kToStackLimitReg = RDX; // Потребуется для реализации
};

Укажем имена наших заглушек в stub_code_List:

V(CoroutineTransfer)
V(CoroutineInitialize)
V(CoroutineFork)

И теперь реализация в stub_code_compiler:

void StubCodeCompiler::GenerateCoroutineInitializeStub() {
   const Register kCoroutine = CoroutineInitializeABI::kCoroutineReg;
  __ EnterStubFrame();
  __ PushObject(compiler::NullObject());
  __ PushRegister(kCoroutine);
  __ CallRuntime(kEnterCoroutineRuntimeEntry, 1);
  __ PopRegister(kCoroutine);
  __ Drop(1);
  __ PushRegister(FPREG);
  __ StoreCompressedIntoObjectOffset(kCoroutine, Coroutine::native_stack_base_offset(), SPREG);
  __ LoadFieldFromOffset(SPREG, kCoroutine, Coroutine::stack_base_offset());
  __ PushRegister(kCoroutine);
  __ LoadCompressedFieldFromOffset(FUNCTION_REG, kCoroutine, Coroutine::trampoline_offset());
  __ Call(compiler::FieldAddress(FUNCTION_REG, Function::entry_point_offset()));
  __ PopRegister(kCoroutine);
  __ StoreCompressedIntoObjectOffset(kCoroutine, Coroutine::stack_base_offset(), SPREG);
  __ LoadFieldFromOffset(SPREG, kCoroutine, Coroutine::native_stack_base_offset());
  __ PopRegister(FPREG);
  __ PushObject(compiler::NullObject());
  __ CallRuntime(kExitCoroutineRuntimeEntry, 0);
  __ Drop(1);
  __ LeaveStubFrame();
  __ Ret();
}

void StubCodeCompiler::GenerateCoroutineForkStub() {
  const Register kCallerCoroutine = CoroutineForkABI::kCallerCoroutineReg;
  const Register kForkedCoroutine = CoroutineForkABI::kForkedCoroutineReg;
  __ EnterStubFrame();
  __ PushObject(compiler::NullObject());
  __ PushRegister(kForkedCoroutine);
  __ CallRuntime(kEnterForkedCoroutineRuntimeEntry, 1);
  __ PopRegister(kForkedCoroutine);
  __ Drop(1);
  __ LoadCompressedFieldFromOffset(kCallerCoroutine, kForkedCoroutine, Coroutine::caller_offset());
  __ PushRegister(FPREG);
  __ StoreCompressedIntoObjectOffset(kCallerCoroutine, Coroutine::stack_base_offset(), SPREG);
  __ LoadFieldFromOffset(SPREG, kForkedCoroutine, Coroutine::stack_base_offset());
  __ PushRegister(kForkedCoroutine);
  __ LoadCompressedFieldFromOffset(FUNCTION_REG, kForkedCoroutine, Coroutine::trampoline_offset());
  __ LoadCompressedField(TMP, compiler::FieldAddress(FUNCTION_REG, Function::entry_point_offset()));
  __ call(TMP);
  __ PopRegister(kForkedCoroutine);
  __ StoreCompressedIntoObjectOffset(kForkedCoroutine, Coroutine::stack_base_offset(), SPREG);
  __ LoadCompressedFieldFromOffset(kCallerCoroutine, kForkedCoroutine, Coroutine::caller_offset());
  __ LoadFieldFromOffset(SPREG, kCallerCoroutine, Coroutine::stack_base_offset());
  __ PopRegister(FPREG);
  __ PushObject(compiler::NullObject());
  __ CallRuntime(kExitForkedCoroutineRuntimeEntry, 0);
  __ Drop(1);
  __ LeaveStubFrame();
  __ Ret();
}

void StubCodeCompiler::GenerateCoroutineTransferStub() {
  const Register kFromCoroutine = CoroutineTransferABI::kFromCoroutineReg;
  const Register kToCoroutine = CoroutineTransferABI::kToCoroutineReg;
  const Register kToStackLimit = CoroutineTransferABI::kToStackLimitReg;
  __ EnterStubFrame();
  __ LoadFieldFromOffset(TMP, kFromCoroutine, Coroutine::attributes_offset());
  __ AndImmediate(TMP, ~Coroutine::CoroutineAttributes::running);
  __ OrImmediate(TMP, Coroutine::CoroutineAttributes::suspended);
  __ StoreFieldToOffset(TMP, kFromCoroutine, Coroutine::attributes_offset());
  __ LoadFieldFromOffset(TMP, kToCoroutine, Coroutine::attributes_offset());
  __ AndImmediate(TMP, ~Coroutine::CoroutineAttributes::suspended);
  __ OrImmediate(TMP, Coroutine::CoroutineAttributes::running);
  __ StoreFieldToOffset(TMP, kToCoroutine, Coroutine::attributes_offset());
  __ PushRegister(FPREG);
  __ StoreCompressedIntoObjectOffset(kFromCoroutine, Coroutine::stack_base_offset(), SPREG);
  __ LoadFieldFromOffset(SPREG, kToCoroutine, Coroutine::stack_base_offset());
  __ PopRegister(FPREG);
  __ LoadFieldFromOffset(kToStackLimit, kToCoroutine,  Coroutine::overflow_stack_limit_offset());
  __ StoreToOffset(kToCoroutine, THR, Thread::coroutine_offset());
  __ StoreToOffset(kToStackLimit, THR, Thread::saved_stack_limit_offset());
  compiler::Label scheduled_interrupts;
  __ LoadFromOffset(TMP, THR, Thread::stack_limit_offset());
  __ testq(TMP, compiler::Immediate(Thread::kInterruptsMask));
  __ BranchIf(ZERO, &scheduled_interrupts);
  __ StoreToOffset(kToStackLimit, THR, Thread::stack_limit_offset());
  __ Bind(&scheduled_interrupts);
  __ LeaveStubFrame();
  __ Ret();
}

Страшно ? Мне вот очень. Давайте вкратце расскажу, что происходит в каждой функции и зачем.

Для начала про два ключевых регистра: RBP и RSP.

RSP (SPREG в нейминге Dart) - актуальный указатель на текущий стек.

RBP (FPREG в нейминге Dart) - указатель на текущий стек на момент вызова функции (указатель на "кадр" стека).

Для начала общие части:

__ EnterStubFrame(); // Пролог функций-заглушек, сохраняет текущий регистр RBP на стек и меняет этот регистр на RSP
__ LeaveStubFrame(); // Восстанавливает стек из RBP регистра и достает RBP из стека
__ Ret(); // Возврат к тому, кто нас вызвал

CoroutineInitialize

  1. Подготавливаем аргументы и вызываем EnterCoroutineRuntimeEntry

  2. Сохраняем текущий RBP на стек, записываем RSP в поле native_stack_base

  3. Переключаемся на новый стек, загружая значение из stack_base в RSP

  4. Сохраняем указатель на корутину на стек

  5. Загружаем trampoline-функцию и передаём управление ей

Здесь начинается "жизнь" корутины — функция будет выполняться, пока не завершатся все связанные корутины.

  1. Восстанавливаем указатель на корутину из стека

  2. Обнуляем указатель на стек в stack_base

  3. Возвращаем оригинальный стек, загружая RSP из native_stack_base

  4. Восстанавливаем RBP

  5. Подготавливаем аргументы и завершаем работу через ExitCoroutineRuntimeEntry

Про trampoline-функцию
Изначально мне не удалось корректно передать аргументы, поэтому в качестве переходника используется:

static void _run() => _Coroutine._current!._entry();  

CoroutineFork

  1. Подготавливаем аргументы и вызываем EnterForkedCoroutineRuntimeEntry, предварительно сохранив kForkedCoroutine (на случай перезаписи регистра)

  2. Извлекаем kForkedCoroutine и получаем из неё kCallerCoroutine

В принципе, можно было использовать callee-saved регистры, но для надёжности решил не рисковать — RuntimeEntry это скомпилированный C++ код, и поведение может быть непредсказуемым.

  1. Сохраняем RBP на стек

  2. Переключаемся на стек новой корутины, загружая stack_base в RSP

  3. Сохраняем kForkedCoroutine на новом стеке

  4. Аналогично CoroutineInitialize, вызываем trampoline

Управление вернётся сюда только после завершения дочерней корутины.

  1. Восстанавливаем kForkedCoroutine из стека

  2. Обнуляем stack_base текущей корутины

  3. Достаём kCallerCoroutine

  4. Восстанавливаем стек из caller

  5. Восстанавливаем RBP

  6. Подготавливаем аргументы и выходим через ExitForkedCoroutineRuntimeEntry

CoroutineTransfer

Эта функция вызывается часто, поэтому важно избежать накладных расходов на RuntimeEntry.

  1. Обновляем статус текущей корутины: running -> suspended

  2. Обновляем статус следующей корутины: suspended -> running

  3. Сохраняем RBP на стек

  4. Записываем текущий RSP в stack_base активной корутины

  5. Переключаем стек, загружая RSP из stack_base целевой корутины

  6. Восстанавливаем RBP

  7. Обновляем saved_stack_limit и coroutine в Thread

  8. Обрабатываем прерывания: если stack_limit потока совпадает с маской прерываний, не меняем его, чтобы Thread корректно обработал прерывание

Здесь стоит заметить вот какую особенность. В Tarantool корутинах у нас было вот так:

void coro_transfer (coro_context *prev, coro_context *next) {
  pushq %rbp
  pushq %rbx
  pushq %r12
  pushq %r13
  pushq %r14
  pushq %r15
  movq %rsp, (%rdi)
  movq (%rsi), %rsp
  popq %r15
  popq %r14
  popq %r13
  popq %r12
  popq %rbx
  popq %rbp
  popq %rcx
  jmpq *%rcx
}

Вопрос: почему я не сохраняю явно регистры на стек ? Пишите в комментариях ваши догадки.

Чуть не забыл про stub_code_compiler_x64. Даже в заглушках могут быть архитектурные особенности. В нашем случае это обработка JumpToFrame заглушки:

void StubCodeCompiler::GenerateJumpToFrameStub() {
  ...
  Label no_coroutine;
  __ Load(TMP, compiler::Address(THR, compiler::target::Thread::coroutine_offset()));
  __ CompareObject(TMP, NullObject());
  __ BranchIf(EQUAL, &no_coroutine);
  __ MoveRegister(TMP, RSP);
  __ SmiTag(TMP);
  __ PushRegister(CallingConventions::kArg1Reg);
  __ PushObject(NullObject());
  __ PushRegister(TMP);
  __ CallRuntime(kJumpToFrameCoroutineRuntimeEntry, 1);
  __ Drop(2);
  __ PopRegister(CallingConventions::kArg1Reg);
  __ Bind(&no_coroutine);
  ...
}

Этот сложный код делает простую вещь: вызывает JumpToFrameCoroutineRuntimeEntry, если у нас есть корутина в потоке.


Неужели простой код

Последняя интересная часть: наши RuntimeEntry. Они находятся в runtime_entry и выглядят вот так:

DEFINE_RUNTIME_ENTRY(EnterCoroutine, 1) {
  auto& coroutine = Coroutine::CheckedHandle(zone, arguments.ArgAt(0));
  coroutine.HandleRootEnter(thread, zone);
}
DEFINE_RUNTIME_ENTRY(ExitCoroutine, 0) {
  auto& coroutine = Coroutine::CheckedHandle(zone, thread->coroutine());
  coroutine.HandleRootExit(thread, zone);
}
DEFINE_RUNTIME_ENTRY(EnterForkedCoroutine, 1) {
  auto& coroutine = Coroutine::CheckedHandle(zone, arguments.ArgAt(0));
  coroutine.HandleForkedEnter(thread, zone);
}
DEFINE_RUNTIME_ENTRY(ExitForkedCoroutine, 0) {
  auto& coroutine = Coroutine::CheckedHandle(zone, thread->coroutine());
  coroutine.HandleForkedExit(thread, zone);
}
DEFINE_RUNTIME_ENTRY(JumpToFrameCoroutine, 1) {
  Coroutine::Handle(Thread::Current()->coroutine()).HandleJumpToFrame(thread, Smi::Value(Smi::RawCast(arguments.ArgAt(0))));
}

Это просто прокси-фунции, которые делегируют вызов в object.cc.

Шаг 5. Тестируем

Я написал несколько наборов тестов. Каждый из них тестирует определенную функциональную область корутин. Рассмотрим каждый набор подробнее.

Test Suite: launch

Здесь у меня вот такой набор тестов:

  • testEmpty - пробуем запустить пустую корутину

  • testTerminated - проверяем, что после запуска корутина disposed

  • testFunction - пробуем запустить корутину-функцию с аргументами

  • testClosure - пробуем запустить корутину-замыкание с аргументами

  • testFork - создаем одну дочернюю корутину

  • testForks - создаем несколько дочерних корутину

Test Suite: captures

Здесь тесты, связанные работой с переменными вне корутин:

  • testGlobalState - меняем глобальные переменные

  • testClosureState - меняем переменные вне корутины-замыкания

Test Suite: flow

Тестируем возвраты из корутин в разных ситуациях:

  1. корутина завершилась и вернулась к родителю (или в планировщик)

  2. корутина приостановлена и возобновлена из родителя

  3. две корутины приостанавливаются и возобновляются

Test Suite: lifecycle

Проверяем, что правильно работают операции recycle() и dispose().

Test Suite: state

В моей реализаии есть реестр корутин, который динамически изменяется. Эти изменения здесь и проверяются.

Test Suite: exceptions

Множество разных тестов, которые обрабатывают ситуации c выбросом исключений:

  • выброс наружу из главной корутины

  • выброс из дочерней в родительскую

  • выбросы исключений после приостановки и возобновления

Test Suite: stress

Этот тест оказался для меня реальным стрессом. Именно он - причина, почему я назвал эту часть "история поражения и победы".

В данном тесте я тестирую работу Dart VM и Dart GC: создается и умирает очень много корутин (>100000), в которых происходят аллокации данных.

Задача теста - просто дойти до конца. Если в реализации корутин есть проблемы, которые влияют на сборку мусора, тест просто упадет из-за внутренних ошибок.

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

Шаг 6. Страдаем.

Прежде чем перейти к описанию состояния, стоит пояснить, зачем вообще об этом говорить.

Перед нами доработка сложной системы внутри языка программирования.

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

Этот текст — не попытка похвастаться или пожаловаться.

Его цель — заставить задуматься о том, как "сложное программирование" влияет на наше состояние.

Лично у меня вся эта история с Dart вызвала гремучую смесь: гнев, обида, отчаяние и выгорание.

Это тот момент, когда начинаешь сомневаться: "А точно ли я разработчик? Может, я ошибся профессией?"

Стоит ли вообще рефлексировать на эту тему? Ну не получилось — бывает, живём дальше?

Но ответ в том, что это мерзкое состояние знакомо многим. И часто мы ищем его причину в себе. Однако вот важный момент: состояние управляет обстоятельствами, но и обстоятельства влияют на состояние.

То, что я чувствовал, вызвано не только внутренними факторами, но и внешними:

  • прозрачностью, понятностью и очевидностью кода, в который пришлось погружаться

  • его качеством, расширяемостью и поддерживаемостью

  • а также, конечно, моим опытом и навыками

Думайте, друзья...

Ах да, я же хотел фиксировать уровень решимости после каждой итерации. Здесь он упал до 10%. Было сильное желание всё бросить и уйти в долгий перерыв.

Шаг 7. Остановились, подышали, пошли дальше.

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

Дальше был смешной момент - мне стало стыдно рассказывать в статьях об очевидно неработающей реализации, и я решил сделать ещё один подход.

Не могу сказать, что текущая реализация работает на 100%, но, если честно, мне страшно писать дополнительные тесты — так что пока буду считать, что всё работает.

Найдете баг - мне не говорите, лучше сразу PR выставляйте.

В итоге я попробовал исправить эту неприятность со сборщиком. Спойлер: стресс тест прошел успешно. И мне захотелось про это рассказать, чтобы история была завершенной.

Шаг 8. Восстаём из пепла аки феникс

Итак, что мы имеем сейчас.

  1. Функциональные тесты работают, значит в самом коде (и Dart, и C++) ошибок нет.

  2. Мы обрабатываем стек всех корутин аналогично стеку потока.

  3. Почему же тогда стресс тест не проходит ? Давайте разбираться.

GC — дар и проклятие.

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

Будем считать, что вы перешли по ссылке и все прочитали.

Я же напишу про конкретно те особенности сборщика мусора в Dart, которые оказывают влияние на работу стресс теста.

Особенности сборщика Dart:

В нем участвует несколько потоков

...
P(compactor_tasks, int, 2,"The number of tasks to use for parallel compaction.")
P(concurrent_mark, bool, true, "Concurrent mark for old generation.")
P(concurrent_sweep, bool, true, "Concurrent sweep for old generation.")
...

Это флаги из flag_list, который могут включать или отключать конкурентный mark и sweep, а также управлять параллелизмом compaction.

Я пытался "играться" с этими флагами, и мне даже показалось, что если сборщик сделать полностью линейным и однопоточным, то стресс тест проходит. Хотя я параллельно менял реализацию — так что не факт.

Он учитывает стек

В Isolate есть функция VisitStackPointers, которая вызывается во время сборки мусора (разных фаз).

В ней выполняется обработка стека для mutator_thread_ (поток, который по сути выполняет пользовательский код).

Он оперирует барьерами записи

Про барьеры опять же можно почитать тут.

В Dart очень много функционала по управлению этими барьерами. Корутин эти барьеры касаются в функции Thread::RestoreWriteBarrierInvariant.

В коде есть вот такой комментарий для этой функции:

// Write barrier elimination assumes that all live temporaries will be
// in the remembered set after a scavenge triggered by a non-Dart-call
// instruction (see Instruction::CanCallDart()), and additionally they will be
// in the deferred marking stack if concurrent marking started. Specifically,
// this includes any instruction which will always create an exit frame
// below the current frame before any other Dart frames.
//
// Therefore, to support this assumption, we scan the stack after a scavenge
// or when concurrent marking begins and add all live temporaries in
// Dart frames preceding an exit frame to the store buffer or deferred
// marking stack.

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

Я бы мог на этом шаге сходить к ИИ и попросить: "Вот тебе код функции, дай мне описание для статьи".

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

Он должен "знать" про все объекты Dart

Что будет, если вы создали объект Dart и положили ссылку в объект, о котором не знает сборщик ?

Как сделать так, чтобы при последующих сборках этот ваш объект не был уничтожен, потому что GC не смог найти на него ссылок ?

Руководствуясь этими мыслями, я был вынужден сделать корутину как стандартный Dart объект.

И это первое, от чего я решил в итоге избавиться (внезапно).

Шаг 9. Убираем лишние объекты

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

  1. Сборщик решил собрать объект корутины и удалил указатель на стек

  2. В момент обработки объектов корутин вызвались функции, меняющие содержимое корутин (recycle/dispose)

  3. В момент обработки объектов корутин были вызваны: CoroutineFork или CoroutineTransfer

  4. В момент обработки стеков были вызваны: CoroutineFork или CoroutineTransfer

  5. В правильном ли я месте выполняю обработку стека ? Вот, например, для _SuspendState обработка фрейма стека делается в момент обработки объекта _SuspendState

  6. В "пьяном" ночном угаре я ещё много чего фантазировал, но все что было в ночном кодинге, остается в ночном кодинге...

Я решил избавляться от всех этих ситуаций радикально. И первое, что я сделал - превратил корутины из Dart объекта в кусок C++ памяти, вот такой кусок:

class Coroutine {
  ...
  Coroutine* caller_;
  uword owner_;
  uword trampoline_;
  uword stack_size_;
  uword native_stack_base_;
  uword stack_root_;
  uword stack_base_;
  uword stack_limit_;
  uword overflow_stack_limit_;
  uword attributes_;
  uword index_;
  ...
}

Здесь те же поля, но это будет уже не Dart объект, а обычный "нативный". И обратите внимание, здесь нет ссылок на другие Dart объекты.

Теперь логичный вопрос — "а как с ним вообще будем работать из Dart ?".

А вот так:

extension type _Coroutine(int handle) {}
external _Coroutine? _Coroutine_create(int size, int owner_index, Object owner, int attributes, Function trampoline);
external void _Coroutine_idle(int timeout);
external void _Coroutine_initialize(_Coroutine root);
external void _Coroutine_transfer(_Coroutine from, _Coroutine to);
external void _Coroutine_fork(_Coroutine from, _Coroutine to);
external _Coroutine? _Coroutine_current();
external int _Coroutine_getIndex(_Coroutine coroutine);
external int _Coroutine_getOwner(_Coroutine coroutine);
external int _Coroutine_getAttributes(_Coroutine coroutine);
external void _Coroutine_setAttributes(_Coroutine coroutine, int attributes);
external _Coroutine? _Coroutine_getCaller(_Coroutine coroutine);
external void _Coroutine_setCaller(_Coroutine coroutine, _Coroutine caller);

C++ указатель на объект это Dart int. Для типизации я сделал extension type.

Но, подождите, создать объект через NATIVE_ENTRY мы можем, а вот все эти _getIndex и прочее - это что, будут другие NATIVE_ENTRY ?

Вообще, можно было бы и так, но я не очень верю в скорость NATIVE_ENTRY, поэтому я решил пойти вот таким путем:

case MethodRecognizer::kCoroutine_getAttributes: {
  body += LoadLocal(parsed_function_->RawParameterVariable(0));
  body += UnboxTruncate(kUnboxedAddress);
  body += ConvertUnboxedToUntagged();
  body += LoadNativeField(Slot::Coroutine_attributes());
  body += Box(kUnboxedInt64);
  break;
}
case MethodRecognizer::kCoroutine_setAttributes: {
  body += LoadLocal(parsed_function_->RawParameterVariable(0));
  body += UnboxTruncate(kUnboxedAddress);
  body += ConvertUnboxedToUntagged();
  body += LoadLocal(parsed_function_->RawParameterVariable(1));
  body += UnboxTruncate(kUnboxedInt64);
  body += StoreNativeField(Slot::Coroutine_attributes());
  body += NullConstant();
  break;
}

Так ведь веселее, правда ? Мы получаем Boxed представление int, делаем из него Unboxed, а потом этот Unboxed приводим к Untagged (указатель на C++ объект).

И просто по указателю + смещению (Slot::Coroutine_attributes) берем нужное нам поле. Аналогично с _Coroutine_setAttributes, но там больше операций.

Как думаете, это быстрее NATIVE_ENTRY ? Пишите свои мысли, а то может быть зря я это все.

Последний штрих: как достать Fiber (который чисто Dart сущность) из Coroutine (которая C++ сущность) ?

Не смейтесь:

class _FiberPool {
  final List<Fiber?> _fibers = [];

  Fiber allocate() {
    final fiber = Fiber(_fibers.length);
    _fibers.add(fiber);
    return fiber;
  }

  Fiber get(int index) => _fibers[index]!;

  void free(int index) => _fibers[index] = null;

  void clear() => _fibers.clear();
}

final _pool = _FiberPool();

Просил же, не смейтесь :) Да, я решил просто иметь Dart массив с Fiber объектами и в Coroutine хранить owner - индекс Dart Fiber.

Не говорите мне ничего про оптимальность, преждевременная оптимизация до добра не доводит, я решил не рисковать.

Кстати, наверное можно вместо двух индексов использовать один, но мне не до того было. Не думал про это.

Итак, я избавился от лишнего объекта и при этом функциональные тесты все ещё работают, это радует. Понадобились изменения во многих местах, но основные я здесь указал.

Шаг 10. Внедряем конкурентную безопасность

Допустим, последовательная сборка мусора без параллельности работает. Но как быть с конкурентной ?

Какие вообще есть способы обезопасить выполнение кода при наличии сборки мусора ?

Dart VM предлагает нам использовать SafePoint, но ещё есть старые добрые классические Mutex-ы. Я выбрал комбинацию.

Вот так выглядит использование SafePoint в CoroutineFork:

тут страшный код
__ EnterFullSafepoint(); // мне очень сильно повезло, что в Dart была готовая такая функция: входим
__ PopRegister(kForkedCoroutine);
__ StoreToOffset(SPREG, kForkedCoroutine, Coroutine::stack_base_offset());
__ LoadFromOffset(kCallerCoroutine, kForkedCoroutine, Coroutine::caller_offset());
__ LoadFromOffset(SPREG, kCallerCoroutine, Coroutine::stack_base_offset());
__ PopRegister(FPREG);
__ movq(PP, compiler::Address(THR, Thread::global_object_pool_offset()));
__ ExitFullSafepoint(false); // и выходим
тут тоже страшный код

Глядя на это, мне вспоминается момент из прекрасного мультика:

Не спрашивайте... Мой мозг после битвы с Dart GC ещё не восстановился.

Но SafePoint здесь мне было мало, моя паранойя говорит: синхронизируй корутины полностью !

Держите:

MutexLocker lock(isolate->group()->coroutine_mutex());

Этот Mutex я храню в IsolateGroup, про группы тут.

Для нас это просто объект, который не меняется между потоками и доступен как при сборке мусора, так и при выполнении функций над корутинами.

В итоге я сделал блокировки:

  1. Thread::VisitObjectPointersCoroutine - здесь идет обработка стеков корутин

  2. Thread::RestoreWriteBarrierInvariantCoroutine - здесь тоже идет обработка стеков корутин

  3. Coroutine::HandleJumpToFrame - тут меняются состояния корутин

  4. Coroutine::HandleRootEnter - тут меняются состояния корутин

  5. Coroutine::HandleRootExit - тут меняются состояния корутин

  6. Coroutine::HandleForkedEnter - тут меняются состояния корутин

  7. Coroutine::HandleForkedExit - тут меняются состояния корутин

Влияет ли это на производильность ? Конечно! А вы что думали, что блокировки это бесплатно ?

Но вопросы производительсти меня мало волновали на тот момент. Я решил в первую очередь добиться корректного совместного выполнения GC и корутин.

Итого:

  1. Никаких дополнительных объектов, не будем смущать GC

  2. Синхронизировали все, что синхронизируется

  3. Помолимся

Шаг 11. С замиранием сердца запускаем тесты

exit - значит, что тест дошел до конца

(?) /home/anton/development/dart-sdk-upstream/sdk/runtime/tests/vm/dart/fiber/fiber_stress.exe
exit

Вы не представляете, что я ощутил в этот момент. Запускаем ещё раз, потом ещё и ещё. Не падает!

Уважаемый Читатель, если ты пойдешь его запускать, и он у тебя упадет, - не расстраивай уставшего разработчика, пожалуйста, я хочу спать этой ночью !

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

Не скажу, что я очень часто его перезапускал, потому что я вообще не верил в то, что это сработает, но на момент написания статьи оно работает.

Так что держите картинку манула, который прекрасно передает мое состояние. А нам пора закругляться.

Шаг 12. Фиксируем финальное состояние

Решимость вернулась к 100%. Я снова стал думать, что могу дописать корутины до конца.

Я не буду утверждать, что я починил все баги. Я даже не буду утверждать, что GC с корутинами работает нормально.

Но я успокоился. Тесты проходят, какого-то груза незавершенного дела у меня нет.

Конечно, шаг влево, шаг вправо, и я снова найду очередной баг, который очень захочется исправить, но я решил не вовлекаться.

Появится правильное состояние - пойду искать баги и перезапускать стресс тесты много раз подряд.

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

Спасибо за внимание !

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