Многопоточность и хуки
При запуске сборки с ключом --jobs страницы обрабатываются параллельно, в нескольких потоках. Как включить режим и чего от него ждать, описано в статье Многопоточная сборка. Здесь - что это означает для расширения.
Расширение обязано корректно работать в обоих режимах: пользователь включает --jobs по своему усмотрению, и расширение не может на это повлиять.
Модель исполнения
Рабочий поток - это не отдельная функция, выполняемая на другом ядре, а полная копия программы. В каждом потоке заново разбирается конфигурация, загружаются все расширения и создаются собственные экземпляры всех сервисов: markdown, leading, meta, vars, toc, vcs, search, redirects.
Распределение работы:
- Главный поток разбирает оглавление, раздаёт страницы рабочим потокам, собирает результаты и выпускает итоговые артефакты: поисковый индекс, singlepage, PDF, манифесты, редиректы, карту файлов.
- Рабочие потоки обрабатывают отдельные страницы: markdown или разводящую страницу превращают в конечный файл и записывают его.
Параллельно выполняется ровно одно - обработка отдельной страницы. Всё остальное остаётся в главном потоке.
Где выполняется какой хук
По отношению к многопоточности хуки делятся на три группы.
Хуки регистрации
Base.Command, Base.RawConfig, Base.Config, Base.BeforeAnyRun, Build.BeforeRun, Vars.PresetsLoaded, Markdown.Collects, Markdown.Plugins, Leading.Plugins, Vcs.VcsConnector, Search.Provider.
Выполняются в каждом потоке: в главном и в каждом рабочем. Это точки, где расширение объявляет о себе - добавляет ключ запуска, правит конфигурацию, регистрирует markdown-плагин, подменяет провайдера поиска, подписывается на другие хуки.
Важно
Обработчик такого хука будет вызван N + 1 раз, где N - число рабочих потоков. Побочные эффекты здесь - запись файлов, сетевые запросы, инкремент счётчиков - выполнятся столько же раз.
Хуки главного потока
Toc.Item, Toc.Includer, Toc.Included, Toc.Loaded, Toc.Dump, Toc.Filtered, Build.Entry, Build.AfterRun, Base.AfterAnyRun, Search.Page, Redirects.Page, Redirects.Release.
Выполняются только в главном потоке, независимо от значения --jobs. Здесь и только здесь можно безопасно накапливать состояние по всему проекту.
Ключевой хук - Build.Entry: он вызывается в главном потоке после того, как страница обработана, и получает результат её обработки. Так устроены штатные возможности сборщика, которым нужны данные обо всех страницах сразу: поиск, singlepage, PDF, манифест обхода, статистика сборки.
import {join} from 'node:path';
import {getBuildHooks} from '@diplodoc/cli';
export class Extension {
apply(program: Build) {
const index = new Map();
// Вызывается в главном потоке, состояние переживёт сборку
getBuildHooks(program)
.Entry.for('html')
.tap('MyExtension', (run, entry, info) => {
index.set(entry, info.title);
});
getBuildHooks(program)
.AfterRun.for('html')
.tapPromise('MyExtension', async (run) => {
await run.write(join(run.output, 'index.json'), JSON.stringify([...index]));
});
}
}
Изолированные хуки
Markdown.Loaded, Markdown.Resolved, Markdown.Dump, Leading.Loaded, Leading.Resolved, Leading.Dump, Meta.Dump, Entry.Dump, Entry.State, Entry.Page.
При --jobs > 1 выполняются в рабочем потоке, при однопоточной сборке - в главном. Обработчик видит только состояние своего потока: изменения, которые он внёс в сервисы, в главный поток не вернутся, и наоборот - данные, накопленные другими страницами, ему недоступны.
Что в них работает без оговорок:
- преобразование содержимого текущей страницы;
- чтение исходных файлов;
- запись файлов в выходную директорию;
- логирование через
run.logger.
Что работать не будет: накопление состояния в памяти расширения с расчётом прочитать его позже в главном потоке. Такое состояние нужно возвращать через Build.Entry либо собирать заново в главном потоке.
Примечание
Именно поэтому расширение, которое «работает без --jobs и молча теряет данные с --jobs», почти всегда копит состояние в изолированном хуке.
Что передаётся между потоками
Между потоками передаются только сериализуемые данные:
- из главного потока в рабочие - разобранное оглавление и данные VCS (один раз перед началом обработки), а также путь и метаданные каждой страницы;
- из рабочего потока в главный - результат обработки страницы: заголовок, метаданные, данные страницы и графы зависимостей;
- логи рабочих потоков сводятся в общий вывод по каналам
info,warnиerror; - ошибки передаются с сохранением сообщения и стека.
Функции, классы и замыкания границу потока не пересекают. Если расширение кладёт свои данные в метаданные страницы или в результат её обработки, эти данные должны быть простыми объектами.
Хук Base.Error срабатывает в том потоке, где возникла ошибка. Ошибки обработки конкретных страниц пробрасываются в главный поток и логируются там.
Идемпотентность записи
Один и тот же файл может быть записан разными путями обработки: как страница из оглавления и как включаемый файл. В однопоточной сборке порядок этих записей стабильный, в многопоточной - нет.
Поэтому все пути записи обязаны давать одинаковый результат: пишите файл целиком, со всеми метаданными, а не дописывайте его частями. Иначе итог сборки будет зависеть от того, какой поток успел записать файл последним.
Ограничения
- Watch-режим однопоточный. После первой сборки рабочие потоки останавливаются, инкрементальные пересборки идут в один поток.
- Данные VCS считаются только в главном потоке и рассылаются в рабочие в готовом виде. Обращаться к системе контроля версий из изолированных хуков нельзя.
- Откат к однопоточной сборке. Если рабочие потоки не успевают инициализироваться за 30 секунд, сборка молча продолжается в один поток.