18 августа 2015

Задачи, микрозадачи, очереди и планы

JavaScriptБраузеры
Из песочницы
Предлагаю вашему вниманию перевод статьи «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 на данный момент не подлежит исправлению, так как события мутаций не происходят после колбеков.

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

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


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

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

Здесь кто-нибудь остался? Алё?! Алё?
Теги:переводперевод с английскогоjavascriptecmascriptgoogle chromesafarifirefoxinternet explorerмикрозадачизадачиочереди
Хабы: JavaScript Браузеры
+20
40,7k 193
Комментарии 9
Лучшие публикации за сутки