open atlas
↑ К треку
Внутренности движка JavaScript JSE · 07 · 01

Движок против цикла

У движка нет цикла событий — им владеет хост (браузер, Node через libuv). Движок предоставляет очередь микрозадач и одну операцию: PerformMicrotaskCheckpoint. Хост запускает задачу; на возврате движок осушает микрозадачи.

JSE Middle ◷ 12 min
Уровень
ОсновыJuniorMiddleSenior

Вы десяток раз читали, что «у JavaScript есть цикл событий». Потом вы открываете исходники V8 и ищете его grep’ом — а там никакого цикла событий нет. Там есть парсер, интерпретатор, оптимизирующие компиляторы, сборщик мусора и очередь отложенных jobs. Тот самый цикл, что планирует ваши колбэки, живёт совсем в другом месте: в message loop Chromium, в libuv у Node. Движок и цикл — это две разные программы.

У движка нет цикла событий

Обзорные браузерные уроки трактуют цикл событий как единую машину — browser/01-event-loop/01-loop-model — это взгляд со стороны рантайма. Этот юнит — взгляд со стороны движка, и первая поправка структурная: у движка нет цикла событий. ECMAScript, язык, который реализует V8, вообще его не упоминает. Спецификация определяет jobs и job queue и говорит, что хост обязан рано или поздно запустить очередь jobs — но алгоритм, решающий, когда и какую задачу запускать следующей, явно отдан хосту (через HOST-хуки HostEnqueuePromiseJob, HostCallJobCallback).

Итак, разделение труда такое:

  • Хост (message loop Chromium, Node плюс libuv, Deno, Bun) владеет циклом. Он держит очереди макрозадач (таймеры, завершения I/O, message-события), решает, какая задача пойдёт следующей, и управляет рендерингом. Это while (true) на самом верху.
  • Движок (V8) владеет одной очередью — очередью микрозадач (тем самым «jobs queue» из ECMAScript) — и выставляет одну операцию для её осушения: PerformMicrotaskCheckpoint. Он исполняет JavaScript, который ему передал хост, до конца, и на выходе осушает свои микрозадачи.

Хост и движок сцеплены настолько точно, что порядок асинхронности перестаёт ощущаться как фольклор — поймите это разделение один раз, и каждый следующий async-сюрприз получает механическое объяснение.

Когда говорят «цикл событий», имеют в виду цикл хоста. Когда говорят «микрозадачи запускаются после текущей задачи», имеют в виду чекпоинт движка. Смешение этих двух — причина, по которой порядок асинхронности ощущается как фольклор, а не как механизм.

Форма одного оборота: хост вызывает, движок возвращает, микрозадачи осушаются

Один оборот цикла хоста — это вызов внутрь движка и возврат наружу. Хост выбирает одну макрозадачу (скажем, сработавшее событие click или истёкший таймер) и вызывает движок на связанном с ней JavaScript-колбэке. Движок кладёт фрейм на стек, исполняет ваш код до конца — JavaScript исполняется run-to-completion, никакого вытеснения посреди функции нет — и когда стек вызовов пустеет, движок выполняет чекпоинт микрозадач: он осушает каждую поставленную в очередь микрозадачу (реакции Promise, jobs от queueMicrotask), пока очередь не опустеет, включая микрозадачи, поставленные другими микрозадачами. Только тогда управление возвращается хосту, который рендерит (в браузере) и выбирает следующую макрозадачу.

Ключевая деталь: осушение микрозадач происходит внутри единственного вызова хост-в-движок, до того как движок вернётся. С точки зрения хоста, запуск обработчика клика и запуск каждой реакции Promise, которую он запланировал, — это одна неделимая задача. Вот почему микрозадачи всегда идут перед следующим таймером, перед следующим рендером, перед чем угодно следующим: они не отдельная задача, которую планирует хост, — они хвост уже исполняющейся задачи.

Кто даёт чекпоинт и кто его вызывает

PerformMicrotaskCheckpoint — это V8; а вот решение его вызвать частично разделено. V8 автоматически запустит микрозадачи, когда стек вызовов JS опустеет (политика микрозадач «auto» по умолчанию, используемая браузером). Node использует явную политику: libuv прогоняет фазу, а Node сам вызывает runMicrotasks() в чётко определённых точках (после каждого колбэка макрозадачи и между фазами) — поэтому у Node свои правила порядка, которые мы разберём в следующем уроке. В любом случае очередь и осушение — движка; ритм — хоста.

Кто чем владеет в «цикле событий»
Цикл while / выбор задачи
хост
Очереди макрозадач (таймеры, I/O)
хост
Шаги рендеринга
хост (браузер)
Очередь микрозадач (jobs queue)
движок
PerformMicrotaskCheckpoint (чекпоинт микрозадач)
движок
Когда срабатывает чекпоинт
политика хоста

Run-to-completion — несущее правило

Всё вышесказанное опирается на одну гарантию движка: run-to-completion (выполнение до завершения). Как только хост передал движку колбэк, этот колбэк исполняется от начала до конца без вкрапления другого JavaScript. Никакая вторая задача, никакая микрозадача — ничто его не вытесняет. Именно это делает цикл for атомарным с точки зрения программы и именно это заставляет await (следующие уроки) нуждаться в явном механизме приостановки — движок не может просто «поставить функцию на паузу» так, как ОС ставит на паузу поток, потому что run-to-completion это запрещает. Функция должна вернуться хосту, а микрозадача — возобновить её позже.

Викторина

Вы ищете grep'ом цикл событий в исходниках V8 и не находите его. Где на самом деле живёт цикл, который планирует ваши колбэки `setTimeout`?

Викторина

С точки зрения хоста, запуск обработчика клика плюс каждая реакция Promise, которую он планирует, — это сколько задач?

Расставь шаги по порядку

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

  1. 1 Хост выбирает макрозадачу таймера и вызывает движок
  2. 2 Движок исполняет колбэк таймера до конца (run-to-completion)
  3. 3 Движок выполняет чекпоинт микрозадач, осушая все поставленные в очередь микрозадачи
  4. 4 Движок возвращает управление хосту с пустым стеком
  5. 5 Хост прогоняет шаги рендеринга, затем возвращается к выбору следующей задачи
Почему это работает

Зачем вообще такое разделение? Потому что один движок (V8) должен обслуживать хосты с радикально разными циклами — браузер, который обязан рендерить на 60 fps, и сервер, который не рендерит вовсе и вместо этого жонглирует тысячами сокетов через libuv. Расположив цикл в хосте, каждый хост планирует под свой мир, переиспользуя одно ядро языка. Контракт движка узок намеренно: «дай мне колбэк, я исполню его до конца и осушу свои jobs, потом верну тебе поток».

Вспомните перед уходом
  1. 01
    Точно разделите обязанности движка и хоста в «цикле событий».
  2. 02
    Почему микрозадачи всегда идут перед следующей макрозадачей и перед рендерингом, без исключений в браузере?
  3. 03
    Run-to-completion запрещает движку приостанавливать функцию посреди исполнения. Какое следствие это имеет для async/await?
Итог

«У JavaScript есть цикл событий» — полезная ложь, скрывающая настоящую архитектуру. У движка — V8 — нет цикла; он реализует язык ECMAScript, владеет очередью микрозадач (jobs queue) и выставляет одну операцию, PerformMicrotaskCheckpoint, чтобы её осушать. Цикл живёт в хосте: message loop Chromium в браузере, libuv в Node. Один оборот этого цикла — вызов в движок и возврат из него: хост выбирает макрозадачу, движок исполняет её до конца (run-to-completion, без вытеснения), движок осушает свою очередь микрозадач, включая микрозадачи, поставленные во время осушения, и только потом возвращается, после чего браузерный хост рендерит и выбирает следующую задачу. Осушение микрозадач происходит внутри этого единственного вызова — вот почему микрозадачи это хвост текущей задачи, а не новая. Браузер использует автоматическую политику микрозадач V8; Node — явную политику, где libuv и Node решают ритм; очередь по-прежнему движка, тайминг — хоста. Теперь, когда встретишь микрозадачу, сработавшую «слишком рано», или рендер, который так и не случился, — знаешь, в какой слой смотреть: в чекпоинт движка или в цикл хоста.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 5 завершено
Связанные уроки
встречается в184

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.