Многопоточность и хуки

Статья обновлена 4 сентября 2026 г.

При запуске сборки с ключом --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 секунд, сборка молча продолжается в один поток.

Что почитать дальше