Предположим, вы пишете интерпретатор машины Тьюринга, запущенный в Web Worker’е. UI должен показывать трейс — как машина шагает от состояния к состоянию, что пишется на ленту, как двигается каретка. Чтобы пользователь успевал считывать изменения в UI, между итерациями движка нужна короткая задержка — миллисекунды, регулярно, на каждом шаге. Это приостановка движка между итерациями — регулярная и предсказуемая, не «пауза» в смысле UI-кнопки «Пауза» (та останавливает машину до клика «Продолжить»).

Возникает вопрос: где именно в цикле итерации воркеру нужно реализовать приостановку? Кандидатов два, и выбор между ними фиксирует сразу два контракта: хуков движка и протокола между воркером и основным потоком. Выбрать точку — это спроектировать обе стороны сразу; промахнуться — испортить обе. Эта статья — про этот выбор.

Скриншот demo.machines.mellonis.ru: машина Тьюринга в режиме RUNNING_AUTO, кнопка «Пауза» выделена зум-кругом, слева виден пошаговый трейс выполнения
Скриншот demo.machines.mellonis.ru: машина Тьюринга в режиме RUNNING_AUTO, кнопка «Пауза» выделена зум-кругом, слева виден пошаговый трейс выполнения

Введение

turing-machine-js — интерпретатор машины Тьюринга. У движка есть хук onStep — переданный обработчик вызывается после каждого шага машины. Этим вводным про движок ограничимся. Подробнее про устройство и предыдущие изменения API — в статьях «Три мажора, две ошибки: проектирование API приостановки для интерпретатора машины Тьюринга» (1) и «Два мажора, один README, одно демо: два почти бесплатных дизайн-ревью» (2).

Первый настоящий потребитель движка — machines-demo, интерактивный отладчик. Пользовательская машина исполняется в Web Worker’е; UI на основном потоке показывает трейс. То же самое демо, которое в статье (2) спровоцировало два мажора в движке (#109, #108). В этой статье — следующий эпизод из жизни того же движка: v6.2 → v6.3 → v6.4.

В демо есть режим RUNNING_AUTO: пользователь выбирает интервал, нажимает «Старт», машина шагает автоматически. Изначально режим был устроен прямолинейно — основной поток запускал машину в режиме пошагового выполнения (через runner.step() — обёртку над движковым runStepByStep() в воркере), между шагами вставлял setTimeout(intervalMs) для трейса. Просто и работает — до тех пор, пока пользователь не поставит брейкпоинт.

Брейкпоинты в движке реализованы внутри machine.run(): цикл итераций находится в самом движке, и при попадании в debug-помеченное состояние он сам себя останавливает. runStepByStep() же — генератор: цикл по нему крутит потребитель, и брейкпоинты он не видит. machines-demo#43 и был этой дырой — пользователь ставит брейкпоинт в режиме RUNNING_AUTO, нажимает «Старт», но брейкпоинт игнорируется. Чтобы брейкпоинты заработали, RUNNING_AUTO должен перейти с runStepByStep() на run().

Но run() крутит цикл итераций внутри движка, не передавая управление наружу. Раньше приостановка жила в паузах между вызовами runner.step() на основном потоке — то есть на стороне потребителя, который сам управлял интервалом между шагами. С переходом на run() этой паузы на стороне потребителя больше нет — приостановку нужно либо переносить внутрь движка (в существующий или новый хук), либо убирать. Это и стало дизайн-вопросом.

Ещё одна архитектурная деталь, которая нам пригодится: у основного потока есть сторожевой таймер на воркер. Если воркер молчит на сообщениях дольше WORKER_TIMEOUT_MS, основной поток считает его подвисшим (например, недостижимое halt-состояние пользовательской машины) и убивает воркер. Любое размещение приостановки должно с этим таймером уживаться.

Где может жить приостановка

Два кандидата:

  1. Внутри onStep — синхронного хука, который движок зовёт после каждого шага. Сейчас его сигнатура (state) => void. Можно расширить её до (state) => void | Promise<void> — и тогда обработчик может await-ить задержку прямо там.
  2. На границе итерации — добавить новый хук, специально для асинхронных ожиданий. Имя пока неважно; важно, что вызывается он один раз в конце каждой итерации движка, и движок ждёт возвращённый Promise.

Оба варианта отвечают на вопрос «где живёт приостановка». Но они одновременно диктуют, как устроен протокол между воркером и основным потоком.

Если приостановка стоит в onStep, она оказывается посредине каждой итерации движка — в той точке, где зовётся onStep. К этой же точке приходится привязывать и всё остальное в протоколе: и сторожевой таймер, и обработку клика «Пауза» от пользователя — всё повисает на этой мид-итерационной задержке.

Если приостановка стоит на границе итерации, у воркера в конце каждой итерации появляется момент — движок не работает, ждёт Promise. В этот момент удобно помириться со сторожевым таймером и быть готовым к обработке клика «Пауза».

Дальше — оба контракта по очереди.

Контракт «Внутри onStep»

v6.2.0 пошла первым путём — расширила сигнатуру onStep с (state) => void до (state) => void | Promise<void>. Обработчик теперь мог await-ить задержку прямо там, где раньше только смотрел состояние. Со стороны воркера это выглядело красиво — один хук, одна точка ожидания, никаких новых сущностей.

Уже через несколько часов я пометил v6.2.0 в npm как deprecated. Не «вышла версия посвежее, обновляйтесь по случаю», а явное предупреждение: эта версия — неправильная.

v6.3.0 откатила расширение сигнатуры. Причина — простая: onStep задумывался как хук наблюдения, а не координации. Он работает синхронно, посредине каждой итерации движка, для того чтобы потребитель мог посмотреть на состояние и где-то его записать. Добавить ему await в сигнатуру — значит склеить две разные задачи в одну: и наблюдать, и удерживать движок. Читатель сигнатуры теперь не различает: этот хук — «посмотри на состояние» или «придержи здесь движок»? Назначение хука перестаёт быть видно из сигнатуры.

Симптом того, что что-то не так, проявился в документации. README должна была пояснить, как пользоваться расширенным onStep. Честный абзац начинался с оговорки: «onStep — это хук наблюдения, но вы можете возвращать из него Promise, и движок дождётся его перед следующей итерацией». Не получалось убрать ни «но», ни вторую половину фразы. Документация работала ровно так, как описано в статье (2): прозой давила на форму — форма треснула.

v6.4.0 добавила отдельный хук — onIter. Сигнатура: (state) => void | Promise<void>. Контракт: вызывается один раз в конце каждой итерации, асинхронно, безусловно. Имя — iteration boundary, граница итерации — описывает что хук делает (даёт точку в конце итерации, в которой можно подождать), а не зачем потребитель собирается его использовать. А использовать его можно по-разному: для приостановки между шагами трейса, для синхронизации с UI, для прерывания прогона (например, по AbortSignal).

Контракт хуков движка теперь различает три задачи:

  • onStep — синхронный, посредине итерации. Наблюдение за состоянием.
  • onPause — асинхронный, условный (срабатывает только на брейкпоинтах). Граница между ходом и остановом.
  • onIter — асинхронный, безусловный (срабатывает на каждой итерации). Координация: удержание движка для асинхронной работы потребителя.

Три хука — три разные задачи. Наблюдение не должно удерживать движок; для этого есть отдельный, асинхронный хук. Удержание не должно прятаться в хуке наблюдения; для этого есть onIter. Пауза по брейкпоинту — это вообще отдельный сценарий, и под него есть onPause, у которого событие срабатывания явно прописано в имени.

Контракт «На границе итерации»

Где живёт приостановка — теперь ясно: внутри обработчика onIter. Но обработчик onIter запускается внутри воркера, а пауза от пользователя приходит снаружи — с основного потока, по клику. Между ними нужен протокол.

Этот протокол содержит четыре механизма: пара сообщений idle/busy, флаг stepRequested, синтетическое событие paused, поле intervalMs в resume. Каждый закрывает одну конкретную проблему, которую расположение приостановки создаёт.

Сообщения idle и busy: приостановка сторожевого таймера

Упомянутый выше сторожевой таймер в onIter начинает мешать.

Приостановка в onIter на каждой итерации может легко превышать таймаут — особенно если пользователь выкрутил intervalMs на «медленно» (демо позволяет ставить интервал, скажем, в две секунды). Получается ложная тревога: воркер не подвис, он просто ждёт.

Решение — воркер сам сообщает основному потоку, в какой момент он «работает», а в какой «ждёт». Перед тем как await-ить приостановку в onIter, воркер посылает сообщение idle. После того как приостановка завершилась, посылает busy. Основной поток приостанавливает сторожевой таймер на промежутке между idle и busy. Таймер продолжает ловить настоящие зависания (например, недостижимое halt-состояние), но не путает их с легитимным замедлением трейса.

Без такой обёртки у сторожевого таймера два плохих варианта. Либо брать порог с запасом — тогда настоящие зависания не ловятся (или ловятся слишком поздно). Либо брать порог впритык — выше вероятность ложного срабатывания (воркер убивается на легитимной задержке). Сообщения idle / busy разрешают оба сценария одновременно.

Шаг — флаг внутри воркера, а не состояние движка

Когда пользователь нажимает «Шаг», основной поток посылает воркеру сообщение resume с флагом step: true — «продвинь машину на одну итерацию и снова поставь паузу». Реализовано это так: воркер выставляет флаг stepRequested = true. На следующем onIter обработчик видит флаг, сбрасывает его в false, синтезирует событие paused и снова ждёт. Машина прошла ровно один шаг.

Принципиально: «шаг» — это флаг внутри воркера, а не часть состояния движка. Движок не знает, что такое «шаг», — он просто на следующей итерации опять оказался в режиме ожидания. Координация лежит снаружи движка. onIter остаётся тонким await-хуком — он не мутирует движок, он только приостанавливает его выполнение. Это и сделало onIter жизнеспособным как контракт: в нём нет «специальной логики шага», есть только Promise, который воркер сам контролирует. Если бы шаг был частью состояния движка, контракт onIter пришлось бы расширять до «и ещё, дёрни вот этот флаг», и хук перестал бы быть тонким.

При «Шаге» замедление излишне

При нажатии на «Шаг» замедление излишне — пользователь сам играет роль таймера: кликает столько раз и так быстро, как ему нужно. Реализация это отражает: при «Шаге» intervalMs игнорируется, обработчик onIter не вставляет ожидание. Это самое чистое выражение разделения «наблюдение ≠ координация» внутри воркера: когда управляет пользователь, движок подчиняется.

Синтетическая пауза по клику

Сценарий: машина в RUNNING_AUTO — движок выполняется с приостановками по времени, между итерациями onIter ждёт intervalMs. Пользователь нажимает в UI кнопку «Пауза». Клик приходит на основной поток. Основной поток посылает воркеру сообщение pause. Воркер в этот момент удерживает движок в приостановке — Promise ещё не разрешился. Что делать?

Решение — синтезировать такую же паузу, как от брейкпоинта. У UI уже есть готовый обработчик: на paused из воркера он переключается в «остановлено». Если клик «Пауза» пойдёт по другому пути, появится вторая ветка с тем же поведением. Поэтому воркер и синтезирует paused — для UI клик «Пауза» неотличим от срабатывания брейкпоинта.

Воркер отменяет приостановку (разрешает её принудительно, заранее), выставляет флаг stepRequested = false, и из обработчика onIter после возврата из ожидания посылает наружу событие paused — синтетическое, а не возникшее в ходе нормальной работы движка. Под капотом два сценария различаются (один отменяет приостановку, другой ждёт её естественного завершения), но контракт снаружи — один сценарий «пауза».

intervalMs — параметр resume, не run()

Приостановка реализуется в onIter по intervalMs. Откуда воркер берёт это значение? Не из параметров запуска run(). А из последнего сообщения resume: каждое из них приходит с актуальным intervalMs из основного потока.

Причина — пользователь может управлять величиной задержки между приостановками во время прогона. Значение в UI читается в момент клика, не в момент старта run(); следующее resume передаст новое значение, и со следующей итерации приостановка подстроится. Маленькая цена для протокола, большой выигрыш для UX.

Двойственность, явно

Приостановка живёт в одной точке итерации. С точки зрения движка эта точка — обработчик onIter: хук, который вызывается один раз в конце каждой итерации, асинхронно. С точки зрения протокола та же точка — это промежуток между сообщениями idle и busy, в котором сторожевой таймер приостановлен, клик «Пауза» может мягко отменить приостановку, а intervalMs берётся из последнего resume.

Одна точка, два API. От выбора зависят все последующие этапы проектирования. Положить её посредине хука наблюдения — это сразу поломать обе стороны: документация на расширенный onStep не пишется без «но», обёртку сообщениями idle/busy не к чему прикрутить (один и тот же хук обозначает и наблюдение, и ожидание), синтетическое событие paused приходится посылать из неестественного места. Без чистой границы итерации страдают и хук, и протокол.

Заключение

Разделять наблюдение и координацию — это то, ради чего контракт хуков движка различает три задачи, а не сливает в одну. Склеить их — соблазн, которому поддался v6.2.0. И единственное, что позволило осознать ошибочность подхода за несколько часов — README движка не получалось написать честно, и smoke-тест демо не получалось пройти. Два почти бесплатных дизайн-ревью (статья (2)).


Код: turing-machine-js (движок, v6.2 → v6.4) и machines-demo (демо).