Предлагаю вашему вниманию перевод статьи «Tasks, microtasks, queues and schedules» Джейка Арчибальда (Jake Achibald), занимающего должность Developer Advocate for Google Chrome.

Когда я сказал своему коллеге Мэту Ганту, что подумываю о написании статьи об очерёдности микрозадач и порядке их исполнения внутри событийного цикла браузера, он сказал «Джейк, буду честен, я об этом читать не стану». Что ж, я всё же написал, поэтому откиньтесь на спинку кресла и давайте вместе в этом разберёмся, ладно?

На самом деле, если вам будет проще посмотреть видео, есть замечательное выступление Филиппа Робертса на JSConf, которое рассказывает о событийном цикле – оно не покрывает микрозадачи, но в остальном является отличным вступлением в тему. В любом случае, погнали…

Давайте рассмотрим следующий код на JavaScript:
console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

Как вы думаете, в каком порядке должны вывестись логи?

Верный ответ: script start, script end, promise1, promise2 и setTimeout, однако покамест порядок в разных браузерах довольно часто различен.

Microsoft Edge, Firefox 40, iOS Safari и настольный Safari 8.0.8 логируют setTimeout перед promise1 и promise2. Что действительно странно, ибо Firefox 39 и Safari 8.0.7 работали верно.

Почему так происходит


Для более точного понимания процесса нужно сначала представить как событийный цикл обрабатывает задачи и микрозадачи. На первый раз это может показаться слишком сложным. Глубокий вдох…

Каждый «поток» имеет собственный событийный цикл, а значит и каждый веб-воркер, так что они могут выполняться независимо, тогда как все окна с одного домена (по правилу same origin) делят между собой один и тот же событийный цикл, ведь они могут синхронно коммуницировать между собой. Событийный цикл работает постоянно, исполняя поставленные в очередь задачи. Задачи выполняются последовательно и не могут пересекаться. Ладно-ладно, не уходите…

Задачи планируются таким образом чтобы браузер мог из их дебрей ступить на землю JavaScript/DOM и быть уверенным что эти действия происходят поочерёдно. Обработка колбека события щелчка мыши требует планирования задачи, так же как и разбор HTML и setTimeout из примера выше.

setTimeout ждёт заданной отсрочки и затем планирует новую задачу для своего колбека. Поэтому setTimeout выводится в лог после script end, так как логирование script end является частью первой задачи, а вывод слова setTimeout – второй. Наберитесь терпения, мы почти у цели, впереди самое интересное…

Микрозадачи обычно планируются для вещей, который должны исполняться моментально после текущего исполняемого сценария. Например, реагирование на пачку действий или для того, чтобы сделать что-то асинхронно без необходимости терять производительность на пустом месте из-за полностью новой задачи. Очередь микрозадач развёртывается в конце каждой полной задачи, а также после колбеков в случае если никакой другой JavaScript не находится на стадии исполнения. Любые дополнительные микрозадачи, поставленные в очередь во время развёртывания очереди микрозадач, добавляются в конец очереди и тоже обрабатываются. Микрозадачи включают в себя колбеки Mutation observer и промисов, как в примере выше.

Как только промис решается или если он уже был решён, он ставит в очередь микрозадачу на исполнение колбека. Это даёт уверенность, что колбеки промисов исполняются асинхронно даже если они уже решены. Итак, вызов .then(func) у решённого промиса немедленно ставит в очередь микрозадачу. Вот почему promise1 и promise2 выводятся в журнал после script end, ведь текущий исполняемый сценарий должен завершиться до того как начнут обрабатываться микрозадачи. promise1 и promise2 выводятся в журнал до setTimeout ибо микрозадачи всегда развёртываются до следующей большой задачи.

Прим. переводчика: в этом месте у автора в оригинале вставлена великолепная наглядная презентация работы планироващика JavaScript, однако повторить это на Хабре у меня едва ли имеется техническая возможность, за сим отправляю любознательного читателя на страницу оригинала.

Да, я и правда сделал пошаговую анимированную диаграмму. Как вы провели свою субботу, наверняка гуляли где-то на свежем воздухе с друзьями? Что ж, я – нет. На случай, если что-то не ясно в моём обалденном UI, попробуйте пощёлкать стрелочки вправо-влево.

Что неправильно в некоторых браузерах?


Они выводят в журнал script start, script end, setTimeout, promise1 и promise2. Колбеки промисов исполняются после оных setTimeout. Похоже, для колбеков промисов заводится целая отдельная задача вместо простой микрозадачки. Такое поведение может привезти к проблемам с производительностью при использовании промисов, ведь колбеки могут незаслуженно откладываться до выполнения рендеринга и прочих относящихся к большой задаче вещей. Вот заявки на исправление аномалии в Edge и Firefox (прим. переводчика: к моменту написания перевода в заявке для Firefox выяснилось, что от неожиданного поведения страдают только 40-я и 41-я версии, а начиная с 42-й аномалия не воспроизводится). Ночные сборки WebKit ведут себя как положено, поэтому я предполагаю что вскоре и Safari вновь вернётся на путь праведный.

Как понять когда используются задачи, а когда – микрозадачи


Хотя таким образом мы и делаем предположение что реализация верна, единственный способ – тестировать. Смотреть порядок вывода журнала относительно промисов и setTimeout.

Точный способ – посмотреть спецификацию. Например, шаг 14 setTimeout ставит в очередь задачу, тогда как в спецификации фиксирования мутации шаг 5 создаёт микрозадачу.

В мире ECMAScript микрозадачи именуют заданиями («jobs»). На шаге 8.a спецификации PerformPromiseThen для постановки микрозадачи в очередь вызывается EnqueueJob. К сожалению, покамест нет явного отношения между заданиями («jobs») и микрозадачами, однако в одной из рассылок es-discuss упоминалось что они должны использовать общую очередь.

Теперь давайте взглянем на более комплексный пример. В зале кто-то сконфужено вскрикнет «Нет, они не готовы!». Не обращайте внимания, вы готовы.

Первый уровень: Схватка с Боссом


Следующая задачка могла бы показаться мне сложной до того как я написал этот пост. Вот небольшой кусок HTML:
<div class="outer">
  <div class="inner"></div>
</div>

Рассуждая логически, что выведет в журнал следующий JavaScript код если я щёлкну div.inner?
// Придержим ссылки на эти элементы
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Послушаем изменения атрибутов внешнего
// элемента с классом outer
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// А вот и колбек…
function onClick() {
  console.log('click');

  setTimeout(function() {
    console.log('timeout');
  }, 0);

  Promise.resolve().then(function() {
    console.log('promise');
  });

  outer.setAttribute('data-random', Math.random());
}

// …который мы повесим на оба элемента
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

Попробуйте подумать прежде чем перейдёте к ответу. Подсказка: логи могут выводится больше раза.

Испытание


Прим. переводчика: у автора в этом месте в блоге есть интерактивный DOM элемент (прямая ссылка) на котором можно воочию проверить поведение вашего браузера.

Вы думали будет иначе? Спешу вас успокоить, возможно вы были правы. К сожалению, у разных браузеров разная степень приятия этого мнения:
  • click
  • promise
  • mutate
  • click
  • promise
  • mutate
  • timeout
  • timeout

  • click
  • mutate
  • click
  • mutate
  • timeout
  • promise
  • promise
  • timeout
  • click
  • mutate
  • click
  • mutate
  • promise
  • promise
  • timeout
  • timeout
  • click
  • click
  • mutate
  • timeout
  • promise
  • timeout
  • promise

Кто прав?


Обработка события «click» это задача. Колбеки Mutation observer и промиса ставятся в очередь как микрозадачи. Колбек setTimeout это задача. (Прим. переводчика: тут снова интерактивная диаграмма, поясняющая пошагово принцип работы приведённого ранее кода, рекомендую взглянуть.)

Так что правильно ведёт себя Chrome. Для меня в новость было узнать что микрозадачи развёртываются после колбеков (если только это не часть выполнения другого сценария JavaScript), я думал что их развёртывание ограничено лишь окончанием выполнения задачи. Это правило описано в спецификации HTML по вызову колбеков:
If the stack of script settings objects is now empty, perform a microtask checkpoint
HTML: Cleaning up after a callback, шаг 3
…а чекпойнт микрозадач означает не что иное кроме развёртывания очереди микрозадач, если только мы уже не развёртываем очередь микрозадач. А вот что нам говорит спецификация ECMAScript о заданиях («jobs»):
Execution of a Job can be initiated only when there is no running execution context and the execution context stack is empty…
ECMAScript: Jobs and Job Queues
…хотя «can be» в контексте HTML носит характер «must be», т.е. «обязан».

Что недопоняли браузеры?


Firefox и Safari верно опустошают очередь микрозадач между обработчиками щелчков, как видно по колбекам мутации, но промисы ставятся в очередь иначе. Это можно было бы простить, особенно учитывая туманность связи между заданием («jobs») и микрозадачей, однако я ожидал что они выполнятся между обработчиками. Заявка на Firefox. Заявка на Safari.

Мы уже поняли, что Edge ставит промисы в очередь неверно, но он также не стал опустошать очередь микрозадач между обработчиками щелчков, вместо этого очередь развернулась лишь после вызова всех обработчиков, что объясняет единственный вывод mutate после обоих click в журнале. Это ошибка.

Злой брат Босса с Первого уровня


Блин! А что если к предыдущему примеру добавить:
inner.click();

Событие начнёт обрабатываться точно так же как и до этого, но посредством вызова из сценария, а не от реального взаимодействия пользователя.

Испытание


Прим. переводчика: в оригинале тут ещё одна интерактивная площадка, где можно нажать кнопку и узнать правильный ответ для своего браузера (ссылка прямая).
  • click
  • click
  • promise
  • mutate
  • promise
  • timeout
  • timeout
  • click
  • click
  • mutate
  • timeout
  • promise
  • promise
  • timeout
  • click
  • click
  • mutate
  • promise
  • promise
  • timeout
  • timeout
  • click
  • click
  • mutate
  • timeout
  • promise
  • timeout
  • promise
И я не перестаю получать различные результаты в Chrome, я уже сто раз обновлял эту таблицу думая что до этого по ошибке проверял в Canary. Если у вас в Chrome другие результаты, скажите мне в комментариях на какой вы версии.

Почему теперь по-другому?


Прим. переводчика: в этом месте ещё один последний раз автор даёт нам возможность насладиться визуализацией чудес инженерной мысли браузеростроителей (ссылка, опять-таки, прямая).

Итак, правильный порядок следующий: click, click, promise, mutate, promise, timeout и последний timeout, что, похоже, означает что Chrome работает корректно.

После того как каждый из обработчиков щелчка вызван…
If the stack of script settings objects is now empty, perform a microtask checkpoint
HTML: Cleaning up after a callback, шаг 3
Ранее это означало что микрозадачи будут выполнены между обработчиками щелчка, однако явный .click() происходит синхронно, так что сценарий, который вызвал .click() между обработчиками щелчка всё ещё будет в стеке. Приведённое правило удостоверяет, что микрозадачи не прерывают выполнение JavaScript-кода. Это означает, что очередь микрозадач не будет развёрнута до тех пор, пока все обработчики не выполнятся; очередь до микрозадач дойдёт лишь после всех обработчиков событий.

Разве это важно?


Ещё бы, это будет съедать вас изнутри (уф). Я столкнулся с этим когда попытался создать лаконичную обёртку над IndexedDB, использующую промисы вместо ужасных объектов IDBRequest. С ней IDB почти стал мне приятен.

Когда в IDB срабатывает событие успешности, объект транзакции становится неактивным после передачи управления (шаг 4). Если я создам промис, который решается во время возбуждения этого события, обработчики должны бы исполниться до шага 4 пока транзакция ещё активна, однако этого не происходит ни в одном браузере кроме Chrome, из-за чего библиотека становится как бы бесполезной.

В Firefox с этим можно справиться, ведь полифилы промисов, такие как es6-promise, используют Mutation observers для колбеков, которые есть не что иное как микрозадачи. Safari при этом исправлении вступает в состояние гонки, но дело, скорее всего, в их поломанной реализации IDB. К сожалению IE/Edge на данный момент не подлежит исправлению, так как события мутаций не происходят после колбеков.

Остаётся лишь надеяться что в этом вопросе мы когда-то сможем наблюдать взаимозаменяемость.

Мы сделали это!


В заключение:
  • Задачи исполняются по порядку и браузер может рендерить в промежутках между ними
  • Микрозадачи исполняются по порядку и исполняются:
    • после каждого колбека, если только это не часть выполнения какого-то другого сценария
    • в конце каждой задачи

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

Здесь кто-нибудь остался? Алё?! Алё?

Комментарии (9)


  1. codemake
    19.08.2015 10:44
    +1

    А почему не использовать явный вызов задач в Заданном порядке?
    Вместо setTimeout использовать callback и управлять очередью по вашему сценарию
    (без гадания как это будет работать в разных браузерах).


    1. RusSuckOFF
      19.08.2015 14:50

      Так вроде речь идет именно про асинхронные задачи. А тут уж без setTimeout, Promise и тд не обойтись.


      1. codemake
        19.08.2015 18:30

        Promise — это и есть callback,
        a setTimeout — это костыль.


  1. Dlussky
    19.08.2015 12:21
    +1

    тогда как все окна с одного домена (по правилу same origin) делят между собой один и тот же событийный цикл, ведь они могут синхронно коммуницировать между собой.


    Я специально полез в оригинал, но там «all windows on the same origin», что тоже не проясняет ситуацию. Собственно фраза абсолютно некорректна, т.к. просто случайные окна с одного домена коммуницировать синхронно не могут. Коммуницировать они могут, если, например, одно открыто из другого через window.open. Тогда они действительно будут иметь общий event loop. Обычные же окна или вкладки будут иметь независимые event loop'ы, что легко проверить, если заблокировать поток выполнения в одной из вкладок через alert или prompt, и убедиться, что остальные окна/вкладки продолжают выполнять код (интересно, что хром соблюдает глобальную модальность подобных сообщений, не давая уйти с заблокированной вкладки но код в других вкладках выполняет, и интерфейс обновляет).


    1. nilfalse
      19.08.2015 17:50

      На самом деле «могут синхронно коммуницировать» не обязательно означает «должны», но всё же замечание дельное, согласен.
      Вероятно, под «окнами» автор неявно подразумевал глобальные window объекты у same-origin фреймов одной вкладки, а также окна вкладок, открытых с помощью window.open, однако не берусь утверждать.


    1. StreetStrider
      31.08.2015 22:23

      Коммуницировать-то они могут, через storage event, но обработка такого события делается ровно так же, как и обработка любого другого, асинхронно (в отдельном фрейме). А синхронность проверить очень просто, нужно открыть две вкладки с одного домена и подвесить одну из них бесконечным циклом. Если, как говорится в статье, все вкладки с одного домена делят между собой один событийный цикл, то подвесятся они все (в чём я сомневаюсь).


      1. Dlussky
        22.09.2015 13:36
        +1

        Если все вкладки открыты через window.open — подвиснут все.
        И в то же время, какие-то события будут продолжать обрабатываться, несмотря на бесконечные циклы или например confirm(), потому что EVENT LOOP IS A LIE!
        подробнее: http://stackoverflow.com/questions/2734025/is-javascript-guaranteed-to-be-single-threaded/2734311#2734311


  1. StreetStrider
    31.08.2015 22:32

    В мире ECMAScript микрозадачи именуют заданиями («jobs»).
    Вот это место смутило. По идее как раз стандарт и определяет что и как называется. Тогда неясно откуда взялась «авторское» именование и почему оно противоречит стандарту и, мало того, указывается как основное, более правильное (!).

    Что касательно задач и микрозадач, то, опять же, я думаю, ситуация здесь несколько проще, чем автор пытается нам описать.
    Задача, в терминологии автора, соответствует функции, прилетевшей из события или таймера (setTimeout, setInterval), а микрозадача соответствует setImmediate. Таким образом, микрозадача будет выполняться раньше, чем любая задача (потому что первая ставится в начало очереди, а вторая — в конец). Это же проясняет и ситуацию с промисами. Если имплементация промиса для вызова уже резовленного промиса «использует» setImmediate (ставит обработку в начало очереди), то мы получаем правильный порядок, если setTimeout(fn, 0) (ставит обработку в конец очереди), то будет некорректная последовательность в выводе.


    1. rock
      31.08.2015 22:52

      Вы не правы. Начнем с того, что ECMAScript не единственный стандарт, регулирующий это дело. Мало того, до ECMAScript 6 в нем вообще отсутствовали упоминания микрозадач (ок, назовём так) в любом виде, а задач там нет и сейчас. setImmediate это как раз задача (task) — он не блокирует основной событийный цикл. Микрозадачи это, например, Promise (по стандарту), process.nextTick (сравните с setImmediate на ноде), микрозадачи создаются MutationObserver.