Event loop: микрозадачи и макрозадачи
Поток выполнения в браузере, ровно, как и в Node.js, основан на событийном цикле. Понимание работы событийного цикла важно для оптимизаций, иногда для правильной архитектуры.
Событийный цикл
Идея событийного цикла проста. Есть бесконечный цикл, в котором движок JavaScript ожидает задачи, исполняет их и снова ожидает появления новых.
Общий алгоритм движка:
- Пока есть задачи: выполнить их, начиная с самой старой.
- Бездействовать до появления новой задачи, а затем перейти к пункту 1.
Примеры задач:
- Когда загружается внешний скрипт
<script src="...">
, то задача — это выполнение этого скрипта. - Когда пользователь двигает мышь, задача — сгенерировать событие
mousemove
и выполнить его обработчики. - Когда истечет таймер, установленный с помощью
setTimeout(func, ...)
, задача — это выполнение функцииfunc
. - И так далее.
Может так случиться, что задача поступает, когда движок занят чем-то другим, тогда она становится в очередь.
Очередь, которую формируют такие задачи, называют «очередью макрозадач».
Например, когда движок занят выполнением скрипта, пользователь может передвинуть мышь, тем самым вызвав появление
события mousemove
, или может истечь таймер, установленный setTimeout
, и т.п. Эти задачи формируют очередь.
Задачи из очереди исполняются по правилу «первым пришел — первым ушел». Когда браузер заканчивает выполнение скрипта, он
обрабатывает событие mousemove
, затем выполняет обработчик, заданный setTimeout
, и так далее.
Отметим две детали:
- Рендеринг (отрисовка страницы) никогда не происходит во время выполнения задачи движком. Не имеет значения, сколь долго выполняется задача. Изменения в DOM отрисовываются только после того, как задача выполнена.
- Если задача выполняется очень долго, то браузер не может выполнять другие задачи, обрабатывать пользовательские события, поэтому спустя некоторое время браузер предлагает «убить» долго выполняющуюся задачу. Такое возможно, когда в скрипте много сложных вычислений или ошибка, ведущая к бесконечному циклу.
Посмотрим, как можно применить эти знания.
Пример 1: разбиение «тяжелой» задачи
Допустим, у нас есть задача, требующая значительных ресурсов процессора.
Например, подсветка синтаксиса на этой странице — довольно процессороемкая задача. Для подсветки кода надо выполнить синтаксический анализ, создать много элементов для цветового выделения, добавить их в документ — для большого текста это требует значительных ресурсов.
Пока движок занят подсветкой синтаксиса, он не может делать ничего, связанного с DOM, не может обрабатывать пользовательские события и т. д. Возможно даже «подвисание» браузера, что совершенно неприемлемо.
Можно избежать этого, разбив задачу на части. Сделать подсветку для первых 100 строк, затем запланировать setTimeout
(
с нулевой задержкой) для разметки следующих 100 строк и т. д.
Чтобы продемонстрировать такой подход, будем использовать для простоты функцию, которая считает от 1
до 1000000000
.
let i = 0;
let start = Date.now();
function count() {
// делаем тяжелую работу
for (let j = 0; j < 1e9; j++) {
i++
}
console.log(`Done in ${Date.now() - start}ms`);
}
count(); // -> Done in 2741ms
Браузер даже может показать сообщение «скрипт выполняется слишком долго».
Разобьем задачу на части, воспользовавшись вложенным setTimeout
:
let i = 0;
let start = Date.now();
function count() {
// делаем часть тяжелой работы (*)
do {
i++;
} while (i % 1e6 !== 0);
if (i = 1e9) {
console.log(`Done in ${Date.now() - start}ms`);
} else {
setTimeout(count); // планируем новый вызов (**)
}
}
count(); // -> Done in 6ms
Теперь интерфейс браузера будет полностью работоспособен во время выполнения «счета».
Один вызов count
делает часть работы (*)
, а затем, если необходимо, планирует очередной запуск (**)
:
- Первое выполнение производит счет: i=1...1000000.
- Второе выполнение производит счет: i=1000001...2000000.
- ...и так далее.
Теперь если новая сторонняя задача (например, событие onclick
) появляется, пока движок занят выполнением 1-й части, то
она становится в очередь, и затем выполняется, когда 1-я часть завершена, перед следующей частью. Периодические возвраты
в событийный цикл между запусками count
дают движку достаточно «воздуха», чтобы сделать что-то еще, отреагировать на
действия пользователя.
Пример 2: индикация прогресса
Еще одно преимущество разделения на части крупной задачи в браузерных скриптах — это возможность показывать индикатор выполнения.
Обычно браузер отрисовывает содержимое страницы после того, как заканчивает выполнение текущего кода. Не имеет значения, насколько долго выполняется задача. Изменения в DOM отображаются только после её завершения.
С одной стороны, это хорошо, потому что наша функция может создавать много элементов, добавлять их по одному в документ и изменять их стили — пользователь не увидит «промежуточного», незаконченного состояния.
В примере ниже изменения i
не будут заметны, пока функция не завершится, поэтому мы увидим только последнее
значение i
:
<div id="progress"></div>
<script>
function count() {
for (let i = 0; i < 1e6; i++) {
i++;
progress.innerHTML = i;
}
}
count();
</script>
...Но, возможно, мы хотим что-нибудь показать во время выполнения задачи, например, индикатор выполнения.
Если мы разобьем тяжелую задачу на части, используя setTimeout
, то изменения индикатора будут отрисованы в промежутках
между частями:
<div id="progress"></div>
<script>
let i = 0;
function count() {
// делаем часть тяжелой работы (*)
do {
i++;
progress.innerHTML = i;
} while (i % 1e3 !== 0);
if (i < 1e7) {
setTimeout(count);
}
}
count();
</script>
Теперь <div>
показывает растущее значение i
— это своего рода индикатор выполнения.
Макрозадачи и Микрозадачи
Помимо макрозадач, существуют микрозадачи.
Микрозадачи приходят только из кода. Обычно они создаются промисами: выполнение обработчика .then/catch/finally
становится микрозадачей. Микрозадачи также используются «под капотом» await
, т.к. это форма обработки промиса.
Также есть специальная функция queueMicrotask(func)
, которая помещает func
в очередь микрозадач.
Сразу после каждой макрозадачи движок исполняет все задачи из очереди микрозадач перед тем, как выполнить следующую макрозадачу или отобразить изменения на странице, или сделать что-то еще.
Например:
setTimeout(() => console.log('timeout'));
Promise.resolve()
.then(() => console.log('promise'));
console.log('code');
Какой здесь будет порядок?
code
появляется первым, т.к. это обычный синхронный вызов.promise
появляется вторым, потому что.then
проходит через очередь микрозадач и выполняется после текущего синхронного кода.timeout
появляется последним, потому что это макрозадача.
/* Событийный цикл --->
---> *script* -> рендеринг -> микрозадачи
---> *mousemove* -> рендеринг -> микрозадачи
---> *setTimeout* ---> Событийный цикл */
Все микрозадачи завершаются до обработки каких-либо событий или рендеринга, или перехода к другой макрозадаче.
Это важно, так как гарантирует, что общее окружение остается одним и тем же между микрозадачами — не изменены координаты мыши, не получены новые данные по сети и т. п.
Итого
Более подробный алгоритм событийного цикла (упрощенный):
- Выбрать и исполнить старейшую задачу из очереди макрозадач (например, «script»).
- Исполнить все микрозадачи:
- пока очередь микро задач не пуста: выбрать из очереди и исполнить старейшую микрозадачу
- Отрисовать изменения страницы, если они есть.
- Если очередь макрозадач пуста — подождать, пока появится макрозадача.
- Перейти к шагу 1.
Чтобы добавить в очередь новую макрозадачу:
- используйте
setTimeout(f)
с нулевой задержкой.
События пользовательского интерфейса и сетевые события в промежутках между микрозадачами не обрабатываются: микрозадачи исполняются непрерывно одна за другой.
Конспект статьи из учебника по JavaScript — Событийный цикл: микрозадачи и макрозадачи