惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
SegmentFault 最新的问题
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
B
Blog RSS Feed
Y
Y Combinator Blog
T
Tailwind CSS Blog
博客园 - 三生石上(FineUI控件)
J
Java Code Geeks
Stack Overflow Blog
Stack Overflow Blog
aimingoo的专栏
aimingoo的专栏
Jina AI
Jina AI
The GitHub Blog
The GitHub Blog
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
A
About on SuperTechFans
H
Hackread – Cybersecurity News, Data Breaches, AI and More
D
Docker
酷 壳 – CoolShell
酷 壳 – CoolShell
C
Check Point Blog
M
MIT News - Artificial intelligence
Last Week in AI
Last Week in AI
V
V2EX
腾讯CDC
F
Fortinet All Blogs
博客园 - 叶小钗
T
The Blog of Author Tim Ferriss

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания
Пауза, двойственная по природе: контракт хука и протокол ...
Руслан Гильмуллин · 2026-06-07 · via Все публикации подряд на Хабре

Пауза, двойственная по природе: контракт хука и протокол воркера

Средний

10 мин

3.5K

Предположим, вы пишете интерпретатор машины Тьюринга, запущенный в 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 (демо).

Оригинал: https://mellonis.ru/ru/articles/pauza-dvojstvennaya-po-prirode/