Frontend blog.

Event loop: микрозадачи и макрозадачи

Поток выполнения в браузере, ровно, как и в Node.js, основан на событийном цикле. Понимание работы событийного цикла важно для оптимизаций, иногда для правильной архитектуры.

Событийный цикл

Идея событийного цикла проста. Есть бесконечный цикл, в котором движок JavaScript ожидает задачи, исполняет их и снова ожидает появления новых.

Общий алгоритм движка:

  1. Пока есть задачи: выполнить их, начиная с самой старой.
  2. Бездействовать до появления новой задачи, а затем перейти к пункту 1.

Примеры задач:

  • Когда загружается внешний скрипт <script src="...">, то задача — это выполнение этого скрипта.
  • Когда пользователь двигает мышь, задача — сгенерировать событие mousemove и выполнить его обработчики.
  • Когда истечет таймер, установленный с помощью setTimeout(func, ...), задача — это выполнение функции func.
  • И так далее.

Может так случиться, что задача поступает, когда движок занят чем-то другим, тогда она становится в очередь.

Очередь, которую формируют такие задачи, называют «очередью макрозадач».

Например, когда движок занят выполнением скрипта, пользователь может передвинуть мышь, тем самым вызвав появление события mousemove, или может истечь таймер, установленный setTimeout, и т.п. Эти задачи формируют очередь.

Задачи из очереди исполняются по правилу «первым пришел — первым ушел». Когда браузер заканчивает выполнение скрипта, он обрабатывает событие mousemove, затем выполняет обработчик, заданный setTimeout, и так далее.

Отметим две детали:

  1. Рендеринг (отрисовка страницы) никогда не происходит во время выполнения задачи движком. Не имеет значения, сколь долго выполняется задача. Изменения в DOM отрисовываются только после того, как задача выполнена.
  2. Если задача выполняется очень долго, то браузер не может выполнять другие задачи, обрабатывать пользовательские события, поэтому спустя некоторое время браузер предлагает «убить» долго выполняющуюся задачу. Такое возможно, когда в скрипте много сложных вычислений или ошибка, ведущая к бесконечному циклу.

Посмотрим, как можно применить эти знания.

Пример 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 делает часть работы (*), а затем, если необходимо, планирует очередной запуск (**):

  1. Первое выполнение производит счет: i=1...1000000.
  2. Второе выполнение производит счет: i=1000001...2000000.
  3. ...и так далее.

Теперь если новая сторонняя задача (например, событие 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');

Какой здесь будет порядок?

  1. code появляется первым, т.к. это обычный синхронный вызов.
  2. promise появляется вторым, потому что .then проходит через очередь микрозадач и выполняется после текущего синхронного кода.
  3. timeout появляется последним, потому что это макрозадача.
/* Событийный цикл --->
---> *script* -> рендеринг -> микрозадачи
---> *mousemove* -> рендеринг -> микрозадачи
---> *setTimeout* ---> Событийный цикл */

Все микрозадачи завершаются до обработки каких-либо событий или рендеринга, или перехода к другой макрозадаче.

Это важно, так как гарантирует, что общее окружение остается одним и тем же между микрозадачами — не изменены координаты мыши, не получены новые данные по сети и т. п.

Итого

Более подробный алгоритм событийного цикла (упрощенный):

  1. Выбрать и исполнить старейшую задачу из очереди макрозадач (например, «script»).
  2. Исполнить все микрозадачи:
  • пока очередь микро задач не пуста: выбрать из очереди и исполнить старейшую микрозадачу
  1. Отрисовать изменения страницы, если они есть.
  2. Если очередь макрозадач пуста — подождать, пока появится макрозадача.
  3. Перейти к шагу 1.

Чтобы добавить в очередь новую макрозадачу:

  • используйте setTimeout(f) с нулевой задержкой.

События пользовательского интерфейса и сетевые события в промежутках между микрозадачами не обрабатываются: микрозадачи исполняются непрерывно одна за другой.

Конспект статьи из учебника по JavaScriptСобытийный цикл: микрозадачи и макрозадачи

вернуться к списку