Событийный цикл: микрозадачи и макрозадачи
Поток выполнения в браузере, равно как и в Node.js, основан на событийном цикле.
Понимание работы событийного цикла важно для оптимизаций, иногда для правильной архитектуры.
В этой главе мы сначала разберём теорию, а затем рассмотрим её практическое применение.
Событийный цикл
Идея событийного цикла очень проста. Есть бесконечный цикл, в котором движок JavaScript ожидает задачи, исполняет их и снова ожидает появления новых.
Общий алгоритм движка:
- Пока есть задачи:
- выполнить их, начиная с самой старой
- Бездействовать до появления новой задачи, а затем перейти к пункту 1
Это формализация того, что мы наблюдаем, просматривая веб-страницу. Движок JavaScript большую часть времени ничего не делает и работает, только если требуется исполнить скрипт/обработчик или обработать событие.
- Когда загружается внешний скрипт , то задача – это выполнение этого скрипта.
- Когда пользователь двигает мышь, задача – сгенерировать событие mousemove и выполнить его обработчики.
- Когда истечёт таймер, установленный с помощью setTimeout(func, . ) , задача – это выполнение функции func
- И так далее.
Задачи поступают на выполнение – движок выполняет их – затем ожидает новые задачи (во время ожидания практически не нагружая процессор компьютера)
Может так случиться, что задача поступает, когда движок занят чем-то другим, тогда она ставится в очередь.
Очередь, которую формируют такие задачи, называют «очередью макрозадач» (macrotask queue, термин v8).
Например, когда движок занят выполнением скрипта, пользователь может передвинуть мышь, тем самым вызвав появление события mousemove , или может истечь таймер, установленный setTimeout , и т.п. Эти задачи формируют очередь, как показано на иллюстрации выше.
Задачи из очереди исполняются по правилу «первым пришёл – первым ушёл». Когда браузер заканчивает выполнение скрипта, он обрабатывает событие mousemove , затем выполняет обработчик, заданный setTimeout , и так далее.
Пока что всё просто, не правда ли?
Отметим две детали:
- Рендеринг (отрисовка страницы) никогда не происходит во время выполнения задачи движком. Не имеет значения, сколь долго выполняется задача. Изменения в DOM отрисовываются только после того, как задача выполнена.
- Если задача выполняется очень долго, то браузер не может выполнять другие задачи, обрабатывать пользовательские события, поэтому спустя некоторое время браузер предлагает «убить» долго выполняющуюся задачу. Такое возможно, когда в скрипте много сложных вычислений или ошибка, ведущая к бесконечному циклу.
Это была теория. Теперь давайте взглянем, как можно применить эти знания.
Пример 1: разбиение «тяжёлой» задачи.
Допустим, у нас есть задача, требующая значительных ресурсов процессора.
Например, подсветка синтаксиса (используется для выделения цветом участков кода на этой странице) – довольно процессороёмкая задача. Для подсветки кода надо выполнить синтаксический анализ, создать много элементов для цветового выделения, добавить их в документ – для большого текста это требует значительных ресурсов.
Пока движок занят подсветкой синтаксиса, он не может делать ничего, связанного с DOM, не может обрабатывать пользовательские события и т.д. Возможно даже «подвисание» браузера, что совершенно неприемлемо.
Мы можем избежать этого, разбив задачу на части. Сделать подсветку для первых 100 строк, затем запланировать setTimeout (с нулевой задержкой) для разметки следующих 100 строк и т.д.
Чтобы продемонстрировать такой подход, давайте будем использовать для простоты функцию, которая считает от 1 до 1000000000 .
Если вы запустите код ниже, движок «зависнет» на некоторое время. Для серверного JS это будет явно заметно, а если вы будете выполнять этот код в браузере, то попробуйте понажимать другие кнопки на странице – вы заметите, что никакие другие события не обрабатываются до завершения функции счёта.
let i = 0; let start = Date.now(); function count() < // делаем тяжёлую работу for (let j = 0; j < 1e9; j++) < i++; >alert("Done in " + (Date.now() - start) + 'ms'); > count();
Браузер может даже показать сообщение «скрипт выполняется слишком долго».
Давайте разобьём задачу на части, воспользовавшись вложенным setTimeout :
let i = 0; let start = Date.now(); function count() < // делаем часть тяжёлой работы (*) do < i++; >while (i % 1e6 != 0); if (i == 1e9) < alert("Done in " + (Date.now() - start) + 'ms'); >else < setTimeout(count); // планируем новый вызов (**) >> count();
Теперь интерфейс браузера полностью работоспособен во время выполнения «счёта».
Один вызов count делает часть работы (*) , а затем, если необходимо, планирует свой очередной запуск (**) :
- Первое выполнение производит счёт: i=1…1000000.
- Второе выполнение производит счёт: i=1000001…2000000.
- …и так далее.
Теперь если новая сторонняя задача (например, событие onclick ) появляется, пока движок занят выполнением 1-й части, то она становится в очередь, и затем выполняется, когда 1-я часть завершена, перед следующей частью. Периодические возвраты в событийный цикл между запусками count дают движку достаточно «воздуха», чтобы сделать что-то ещё, отреагировать на действия пользователя.
Отметим, что оба варианта – с разбиением задачи с помощью setTimeout и без – сопоставимы по скорости выполнения. Нет большой разницы в общем времени счёта.
Чтобы сократить разницу ещё сильнее, давайте немного улучшим наш код.
Мы перенесём планирование очередного вызова в начало count() :
let i = 0; let start = Date.now(); function count() < // перенесём планирование очередного вызова в начало if (i < 1e9 - 1e6) < setTimeout(count); // запланировать новый вызов >do < i++; >while (i % 1e6 != 0); if (i == 1e9) < alert("Done in " + (Date.now() - start) + 'ms'); >> count();
Теперь, когда мы начинаем выполнять count() и видим, что потребуется выполнить count() ещё раз, мы планируем этот вызов немедленно, перед выполнением работы.
Если вы запустите этот код, то легко заметите, что он требует значительно меньше времени.
Всё просто: как вы помните, в браузере есть минимальная задержка в 4 миллисекунды при множестве вложенных вызовов setTimeout . Даже если мы указываем задержку 0 , на самом деле она будет равна 4 мс (или чуть больше). Поэтому чем раньше мы запланируем выполнение – тем быстрее выполнится код.
Итак, мы разбили ресурсоёмкую задачу на части – теперь она не блокирует пользовательский интерфейс, причём почти без потерь в общем времени выполнения.
Пример 2: индикация прогресса
Ещё одно преимущество разделения на части крупной задачи в браузерных скриптах – это возможность показывать индикатор выполнения.
Обычно браузер отрисовывает содержимое страницы после того, как заканчивается выполнение текущего кода. Не имеет значения, насколько долго выполняется задача. Изменения в DOM отображаются только после её завершения.
С одной стороны, это хорошо, потому что наша функция может создавать много элементов, добавлять их по одному в документ и изменять их стили – пользователь не увидит «промежуточного», незаконченного состояния. Это важно, верно?
В примере ниже изменения i не будут заметны, пока функция не завершится, поэтому мы увидим только последнее значение i :
> count();
…Но, возможно, мы хотим что-нибудь показать во время выполнения задачи, например, индикатор выполнения.
Если мы разобьём тяжёлую задачу на части, используя setTimeout , то изменения индикатора будут отрисованы в промежутках между частями.
Так будет красивее:
while (i % 1e3 != 0); if (i < 1e7) < setTimeout(count); >> count();
Теперь показывает растущее значение i – это своего рода индикатор выполнения.
Пример 3: делаем что-нибудь после события
В обработчике события мы можем решить отложить некоторые действия, пока событие не «всплывёт» и не будет обработано на всех уровнях. Мы можем добиться этого, обернув код в setTimeout с нулевой задержкой.
В главе Генерация пользовательских событий мы видели пример: наше событие menu-open генерируется через setTimeout , чтобы оно возникло после того, как полностью обработано событие «click».
menu.onclick = function() < // . // создадим наше собственное событие с данными пункта меню, по которому щёлкнули мышью let customEvent = new CustomEvent("menu-open", < bubbles: true >); // сгенерировать наше событие асинхронно setTimeout(() => menu.dispatchEvent(customEvent)); >;
Макрозадачи и Микрозадачи
Помимо макрозадач, описанных в этой части, существуют микрозадачи, упомянутые в главе Микрозадачи.
Микрозадачи приходят только из кода. Обычно они создаются промисами: выполнение обработчика .then/catch/finally становится микрозадачей. Микрозадачи также используются «под капотом» await , т.к. это форма обработки промиса.
Также есть специальная функция queueMicrotask(func) , которая помещает func в очередь микрозадач.
Сразу после каждой макрозадачи движок исполняет все задачи из очереди микрозадач перед тем, как выполнить следующую макрозадачу или отобразить изменения на странице, или сделать что-то ещё.
setTimeout(() => alert("timeout")); Promise.resolve() .then(() => alert("promise")); alert("code");
Какой здесь будет порядок?
- code появляется первым, т.к. это обычный синхронный вызов.
- promise появляется вторым, потому что .then проходит через очередь микрозадач и выполняется после текущего синхронного кода.
- timeout появляется последним, потому что это макрозадача.
Более подробное изображение событийного цикла выглядит так:
Все микрозадачи завершаются до обработки каких-либо событий или рендеринга, или перехода к другой макрозадаче.
Это важно, так как гарантирует, что общее окружение остаётся одним и тем же между микрозадачами – не изменены координаты мыши, не получены новые данные по сети и т.п.
Если мы хотим запустить функцию асинхронно (после текущего кода), но до отображения изменений и до новых событий, то можем запланировать это через queueMicrotask .
Вот пример с индикатором выполнения, похожий на предыдущий, но в этот раз использована функция queueMicrotask вместо setTimeout . Обратите внимание – отрисовка страницы происходит только в самом конце. Как и в случае обычного синхронного кода.
while (i % 1e3 != 0); if (i < 1e6) < queueMicrotask(count); >> count();
Итого
Более подробный алгоритм событийного цикла (хоть и упрощённый в сравнении со спецификацией):
- Выбрать и исполнить старейшую задачу из очереди макрозадач (например, «script»).
- Исполнить все микрозадачи:
- Пока очередь микрозадач не пуста: — Выбрать из очереди и исполнить старейшую микрозадачу
- Отрисовать изменения страницы, если они есть.
- Если очередь макрозадач пуста – подождать, пока появится макрозадача.
- Перейти к шагу 1.
Чтобы добавить в очередь новую макрозадачу:
- Используйте setTimeout(f) с нулевой задержкой.
Этот способ можно использовать для разбиения больших вычислительных задач на части, чтобы браузер мог реагировать на пользовательские события и показывать прогресс выполнения этих частей.
Также это используется в обработчиках событий для отложенного выполнения действия после того, как событие полностью обработано (всплытие завершено).
Для добавления в очередь новой микрозадачи:
- Используйте queueMicrotask(f) .
- Также обработчики промисов выполняются в рамках очереди микрозадач.
События пользовательского интерфейса и сетевые события в промежутках между микрозадачами не обрабатываются: микрозадачи исполняются непрерывно одна за другой.
Поэтому queueMicrotask можно использовать для асинхронного выполнения функции в том же состоянии окружения.
Web Workers
Для длительных тяжёлых вычислений, которые не должны блокировать событийный цикл, мы можем использовать Web Workers.
Это способ исполнить код в другом, параллельном потоке.
Web Workers могут обмениваться сообщениями с основным процессом, но они имеют свои переменные и свой событийный цикл.
Web Workers не имеют доступа к DOM, поэтому основное их применение – вычисления. Они позволяют задействовать несколько ядер процессора одновременно.
Event loop¶
Event loop (или цикл событий) позволяет выполнять однопоточному Node.js неблокирующие операции ввода/вывода, передавая их выполнение ядру системы, когда это возможно.
Стадии event loop¶
Кратко работу event loop Node.js можно описать так: операция передается на выполнения ядру системы, после завершения Node.js получает уведомление в том, что определенная для операции callback-функция может быть добавлена в очередь выполнения.
Инициализация event loop происходит в момент запуска сервера Node.js, и с этого момента он начинает свою работу, которую можно разделить на несколько этапов:
timers : выполнение callback-функций, зарегистрированных функциями setTimeout() и setInterval() ;
pending callbacks : вызов callback-функций операций ввода/вывода, выполнение которых было отложено на предыдущей стадии цикла событий;
idle, prepare : выполнение внутренних действий, необходимых самому event loop;
poll : выполнение callback-функций завершенных асинхронных операций и управление фазой timers;
check : выполнение callback-функций, зарегистрированных функцией setImmediate() ;
close callbacks : обработка внезапно завершающихся действий.
На стадии timers выполняются зарегистрированные таймерами функции, причем переход на стадию контролируется стадией poll. Из-за блокировки стадией poll цикла событий, таймеры могут выполняться с некоторой задержкой, т. е. через больший интервал времени, чем тот, который был задан. Рассмотрим ситуацию на примере.
1 2 3 4 5 6 7 8
let request = require('request'); setTimeout(() => console.log('Timeout'), 25); //предполагаем, что запрос выполняется 20 миллисекунд, а callback - 10 миллисекунд request('http://www.example.com', (error, response, body) => console.log('Response: ', response) );
В примере сперва вызывается функция setTimeout() , которая должна выполнить переданную ей функцию через 25 миллисекунд. Затем сразу же делает запрос к удаленному API, занимающий 20 миллисекунд. В момент завершения запроса до вызова функции в таймере останется еще 5 миллисекунд, поэтому event loop начнет выполнять callback-функцию, определенную для обработки результата запроса, выполнение которой занимает 10 миллисекунд. Получается, что в предполагаемый момент времени выполнение таймера будет невозможным из-за занятости цикла событий и его вызов произойдет по окончанию работы callback-а запроса, а именно через 30 миллисекунд после определения самого таймера.
Результат работы кода.
Response: Timeout
При pending callbacks выполняются действия, отложенные на предыдущей итерации event loop. Например, это могут быть сообщения об ошибках, которые не были выведены ранее из-за попытки системы их исправить.
При переходе в фазу poll в первую очередь проверяется, сформировалась ли очередь из callback-функций выполненных асинхронных действий. Если очередь не пуста, то в синхронном порядке начинается выполнение всех функций, находящихся в очереди. Выполнение будет продолжаться до тех пор, пока очередь не опустеет или не будет достигнут лимит выполняемых за раз callback-функций.
Если очередь оказывается пустой, то проверяется наличие действий, заданных функцией setImmediate() , и если таковые имеются — происходит переход на стадию check, в противном случае Node.js event loop проверит, есть ли таймеры для выполнения. Если таймеры имеются — произойдет переход на timers, если нет — event loop будет ждать добавления в очередь новых callback-ов и при их появлении сразу же начинать их выполнение.
Для недопущения длительной блокировки event loop, в Node.js имеется ограничение на количество выполняемых на стадии poll callback-функций.
На стадии close callbacks вызываются функции, зарегистрированные для действий, возникающих внезапно. например, событие close или disconnect для сокет соединения.
process.nextTick()¶
Node.js process.nextTick() позволяет выполнять переданные ему callback-функции в текущий момент времени, вне зависимости от того, на какой стадии находится выполнение event loop. Выполнение самого event loop продолжится сразу после завершения всех переданных process.nextTick() callback-ов.
process.nextTick(() => console.log('After')); console.log('Before');
Выполнение переданной process.nextTick() callback-функции начинается сразу после завершения текущей итерации event loop.
Как устроен Event Loop в JavaScript: параллельная модель и цикл событий

В Event Loop в языке JavaScript заключается секрет асинхронного программирования. Сам по себе JS является однопоточным, но при использовании нескольких умных структур данных можно создать иллюзию многопоточности (параллельная модель). Как это происходит, расскажем в этой статье.
Детям из Мариуполя нужно 120 ноутбуков для обучения — подари старое «железо», пусть оно работает на будущее Украины
Код JavaScript работает только в однопоточном режиме. Это означает, что в один и тот же момент может происходить только одно событие. С одной стороны это хорошо, так как такое ограничение значительно упрощает процесс программирования, здесь не возникает проблем параллелизма. Но, как правило, в большинстве браузеров в каждой из вкладок существует свой цикл событий. Среда управляет несколькими параллельными циклами.
Общим знаменателем для всех сред является встроенный механизм, называемый Event Loop JavaScript, который обрабатывает выполнение нескольких фрагментов программы, вызывая каждый раз движок JS.
Какова идея цикла событий?
Существует бесконечный цикл событий, в котором JavaScript движок ожидает свою задачу, выполняет ее и ждет новую. Алгоритм работы движка мы можем видеть при просмотре любой веб-страницы. Он включается в работу тогда, когда необходимо обработать какое-либо событие или скрипт. Схема работы выглядит следующим образом:
-
JavaScript бездействует и ждет свою задачу.
Курс Англійської.
Онлайн-навчання англійської за методикою Кембриджу — вибір понад мільярда людей.
Визуально процесс можно изобразить так:

- Stack (Стек). Представляет собой поток выполнения кода JavaScript. Event Loop выполняет одну простую задачу — осуществляет контроль стека вызовов и очереди обратных вызовов. Если стек вызовов пуст, цикл событий возьмет первое событие из очереди и отправит его в стек вызовов, который его запустит. При вызове нового метода вверху стека выделяется отдельный блок памяти. Стек вызовов отвечает за отслеживание всех операций в очереди, которые должны быть выполнены. При завершении очереди она извлекается из стека.

-
Heap (Куча) . В куче происходит создание нового объекта.
Отслеживание новых событий в цикле:
while(queue.waitForMessage())
Если в очереди нет задач, queue.waitForMessage ожидает их поступления.
Обратите внимание на курсы от наших друзей, школы Hillel и Powercode. Грамотно составленная программа обучения, а также менторство, помогут начинающим разработчикам разобраться во всех деталях и тонкостях.
Как события добавляются в очередь
Все события в браузерах постоянно добавляются в очередь, если они произошли или имеют свой обработчик. setTimeout может добавлять событие в очередь не сразу, а по прошествии указанного времени. Если на данный момент в очереди нет событий, то оно поступит в обработку сразу.
Курс Управління командою в бізнесі.
Онлайн-курс для ефективного управління командою, спрямований на створення проактивних та самостійних команд, де мікроменеджмент не потрібний.
Когда операция setTimeout обрабатывается в стеке, она отправляется соответствующему API, который ожидает до указанного времени, чтобы отправить эту операцию в обработку. Среда управляет несколькими параллельными циклами событий, например, для обработки вызовов API. Веб-воркеры также работают в собственном цикле событий.
Операция отправляется в очередь событий. Следовательно, у нас есть циклическая схема для выполнения асинхронных операций в JavaScript. Сам язык является однопоточным, но API-интерфейсы браузера действуют как отдельные потоки.
Цикл событий постоянно проверяет, пуст ли стек вызовов. Если он пуст, новые функции добавляются из очереди событий. Если это не так, то выполняется текущий вызов функции.
Давайте посмотрим, как отложить выполнение функции до тех пор, пока стек не очистится.
Пример использования setTimeout(() => <>), 0) заключается в том, чтобы вызвать функцию, но выполнить ее после выполнения всех остальных функций в коде.
const bar = () => console.log('bar') const baz = () => console.log('baz') const foo = () => < console.log('foo') setTimeout(bar, 0) baz() >foo()
bar, baz, foo — случайные имена.
При запуске кода сначала вызывается foo(). Внутри foo() мы сначала вызываем setTimeout, передавая bar в качестве аргумента, и инструктируем его таким образом, чтобы он запускался как можно быстрее, передавая 0 в качестве таймера. Затем мы вызываем baz().
Порядок функций в программе:

Event loop. Источник: The JavaScript Event Loop
Почему так происходит?
Очередь событий
При вызове setTimeout(), браузер или Node.js запускают таймер. По истечении таймера (в нашем случае мы установили 0) в качестве тайм-аута, функция обратного вызова помещается в очередь событий.
Курс Аналітик даних.
Протягом 4 місяців ви вивчите повний набір інструментів для аналізу даних та отримаєте можливість працевлаштування в Laba Group.
Очередь событий также является местом, где инициированные пользователем события (клики мышью, ввод с клавиатуры и др.) помещаются в очередь до того, как код сможет на них отреагировать.
Event Loop отдает приоритет стеку вызовов. Сначала он обрабатывает все, что находит в стеке вызовов, а когда там ничего не остается, переходит к обработке очереди событий.
setTimeout с аргументом 0 не гарантирует, что обработка будет выполнена мгновенно. Все зависит от того, сколько задач в данный момент находится в очереди. В примере ниже ”message” будет выведена быстрее обработчика callback_1. Объясняется это тем, что задержка представляет собой минимальное время, необходимое среде на выполнение запроса.
(function () < console.log('start'); setTimeout(function callback() < console.log('message from callback'); >); console.log('message'); setTimeout(function callback_1() < console.log('message from callback_1'); >, 0); console.log('finish'); >)(); // "start" // "message" // "finish" // "message from callback" // "message from callback_1"
Цикл событий в JavaScript отличается от других языков тем, что его поток выполнения никогда не блокируется, кроме некоторых исключений, таких как alert или синхронный HTTP-запрос, которые не рекомендуется использовать. Поэтому даже когда приложение ожидает запросы из хранилища или ответ с сервера, оно может обрабатывать другие процессы, например пользовательский ввод.
В заключение
Веб-сайты стали более интерактивными и динамичными, необходимость выполнения интенсивных операций стала все более актуальной (к примеру, выполнение внешних сетевых запросов для получения данных API). Чтобы обрабатывать эти операции, необходимо использование методов асинхронного программирования. Встроенный механизм Event Loop помогает JavaScript обрабатывать асинхронный код. Чтобы выйти на хороший уровень разработки с JavaScript потребуется не только ваше желание, но и наставничество практикующих специалистов. Наши друзья из школы Mate Academy с радостью помогут вам прокачать свои навыки и знания.
Курс Python.
Цю мову програмування використовують від базових концепцій до складних застосувань у сферах штучного інтелекту. Після проходження курсу гарантовано отримай роботу в ІТ.
Event Loop в деталях
В данной статье поговорим о том, почему Event Loop вообще был создан, как с ним работать и почему про него спрашивают на собесах.
JS был спроектирован как однопоточный язык программирования. Это значит, что он может выполнять только одну операцию одновременно. Тем не менее у JavaScript есть такой механизм как Event Loop, который как раз и позволяет выполнять «асинхронные» операции. Почему «асинхронные» в кавычках? Да просто потому что JavaScript тоже выполняет их синхронно, асинхронности в самом JavaScript как таковой нет. Вперед под кат, будем разбираться)
Синхронный код
С синхронным кодом все более или менее ясно: интерпретатор проходится по каждой инструкции выполняет ее и все работает.
let acc = 1; console.log(acc); // 1 acc++; console.log(acc); // 2 let anotherAcc = acc; console.log(anotherAcc, acc); // 3 3

Асинхронный код
С асинхронным кодом все немножко сложнее. Рассмотрим следующий пример:
let acc = 1; console.log("Call 1:", acc); acc++; console.log("Call 2:", acc); setTimeout(() => < acc++; console.log("Call 3:", acc); >, 0); let anotherAcc = acc; console.log("Call 4:", anotherAcc, acc); /* Output Call 1: 1 Call 2: 2 Call 4: 2 2 Call 3: 3 */
Как мы можем увидеть console.log , который был в setTimeout почему-то выполнился позже, но почему так случилось?
Тут в силу и вступает Event Loop. Так как setTimeout это асинхронная операция (таймер высчитывается на стороне браузера, а не в JS).
Проходимся по коду как интерпретатор
Давайте рассмотрим все более подробно. Мы объявляем все наши переменные и проделываем с ними какие-то операции. Все синхронные операции будут выполняться как только интерпретатор дойдет до них:

Другое дело обстоит с таймером, время которое ожидает таймаут будет считаться на стороне браузера, поэтому операция как бы «пропадет» из очереди. Таймер попал в Event Loop, где будет ждать покуда браузер не пришлет сигнал, о том что время для таймаута вышло и коллбэк внутри таймера можно выполнять.

Теперь самое неочевидное: даже если таймаут выполнился, а функция в которой мы выполняем все синхронные операции еще не выполнилась, Event Loop будет держать все что в нем содержится, покуда у нас не очистится Call Stack. Только после того как все синхронные операции в функции выполнились Event Loop отдаст нам наш таймер, который мы сможем выполнить:

Лезем в дебри
Как уже было сказано выше интерпретатор в JavaScript выполняет одну операцию за раз, все что является асинхронным он отправляет в Event Loop. Однако, вы наверное могли слышать о таких вещах как «макротаски» и «микротаски».
Дело в том, что Event Loop — единственный механизм в JavaScript, который позволяет реализовать асинхронность (хотя по сути все операции выполняются синхронно, просто очень быстро, об этом далее). Event Loop является стеком, где хранятся все задачи, которые не вошли в синхронный поток выполнения. После завершения синхронного потока — задачи начинают выполняться из Event Loop’а. Однако у Event Loop’а тоже есть свои правила. Он делит все задачи на подтипы:
- Микрозадачи
- Макрозадачи
- Задачи отрисовки
Макрозадачами являются все асинхронные операции, такие как XmlHTTPRequest, setTimeout и так далее.
В микрозадачи попадают в основном только две категории: then у промисов, а также Intersection Observer.
В задачи отрисовки попадают задачи связанные с отрисовкой и обновлением контента страницы.
Если бы мы делали свой Event Loop, то он бы выглядел следующим образом:
// Задача interface Task < execute: () =><>, > // Разные типы макрозадач interface MacroTasks < parser: Task[]; resources: Task[]; domManipulation: Task[]; events: Task[]; callbacks: Task[]; >// Типизация для самого Event Loop'а interface EventLoop < macrotasks: MacroTasks; microtasks: Task[]; nextMacrotask: () =>Task | null; needRender: () => boolean; render: () => void; > // Переменная, которая меняется в зависимости от того есть ли синхронные операции в callstack let isCallStackEmpty = false; const eventLoop: EventLoop = < macrotasks: < parser: [], // Парсеры HTML, CSS, A11y Tree resources: [], // Подгрузка ресурсов domManipulation: [], // Манипуляции с DOM events: [], // Ивенты UI callbacks: [], // Коллбэки из setTimeout и так далее >, microtasks: [], // Отдает нам следующую задачу из списка макрозадач nextMacrotask() < for (const macrotaskSection in this.macrotasks) < const queue = this.macrotasks[macrotaskSection as keyof typeof this.macrotasks]; if (queue.length) < return queue.shift() ?? null; >> return null; >, needRender: () => false, render() <> > // Event loop начал свою работу while (true) < if (!isCallStackEmpty) < continue; >for (const task of eventLoop.microtasks) < task.execute(); >eventLoop.microtasks = []; const task = eventLoop.nextMacrotask(); if (task) < task.execute(); >if (eventLoop.needRender()) < eventLoop.render(); >>
Самое интересное в данном коде находится внизу, внутри цикла while :
// Event loop начал свою работу while (true) < if (!isCallStackEmpty) < continue; >for (const task of eventLoop.microtasks) < task.execute(); >eventLoop.microtasks = []; const task = eventLoop.nextMacrotask(); if (task) < task.execute(); >if (eventLoop.needRender()) < eventLoop.render(); >>
Мы можем наглядно рассмотреть как Event Loop делает решения о том какую задачу брать и выполнять первой.
- Сначала Event Loop проверяет выполнились ли все синхронные задачи
- Потом выполняются все задачи из микротасков
- После выполнения всех микротасков — очередь очищается
- Затем мы берем одну макрозадачу из списка и выполняем ее
- После выполнения мы смотрим нужно ли нам сделать перерисовку страницы
- Если перерисовать страницу нужно — делаем это
- Все снова начинается с первого пункта 🙂
Эксперименты ✨
Весь этот флоу достаточно легко проверить. Давайте возьмем несколько микро и макро операций и попробуем запустить их:
console.log("Step 1: In global scope") setTimeout(() => console.log("Step 2: In setTimeout")); new Promise((resolve) => < console.log('Step 3: In promise constructor'); >).then(() => console.log('Step 4: In then')); setTimeout(() => console.log("Step 5: In another setTimeout"))
Step 1: In global scope Step 3: In promise constructor Step 4: In then Step 2: In setTimeout Step 5: In another setTimeout
На всякий случай напомню, что коллбэк внутри конструктора промиса является синхронным.
Как мы можем увидеть тут все выводится согласно коду, который мы рассматривали выше.
- Сначала выполнились все синхронные операции: Step 1 , Step 3 ;
- Потом выполнились все микротаски: Step 4 ;
- Затем выполнились все макротаски: Step 2 , Step 5 ;
Следует заметить что задачи из каждой очереди реализованы по принципу FIFO (First In, First Out) (Первый вошел, первый вышел), именно поэтому в списке макрозадач вывелось Step 2 , а затем Step 5 .

Два then
Теперь давайте добавим еще один then к нашему промису и посмотрим что случится:
console.log("Step 1: In global scope") setTimeout(() => console.log("Step 2: In setTimeout")); new Promise((resolve) => < console.log('Step 3: In promise constructor'); >).then(() => console.log('Step 4: In then')) .then(() => console.log('Step 5: In another then')); setTimeout(() => console.log("Step 6: In another setTimeout"))
Step 1: In global scope Step 3: In promise constructor Step 4: In then Step 5: In another then Step 2: In setTimeout Step 6: In another setTimeout
Как мы можем увидеть, особо ничего не поменялось. Просто добавилась еще одна микрозадача, в целом список выводимых строк никак не поменялся, просто после Step 4 добавился Step 5 .

Два Promise с двумя then
Теперь давайте попробуем провернуть то же, но уже с двумя промисами:
console.log("Step 1: In global scope") setTimeout(() => console.log("Step 2: In setTimeout")); new Promise((resolve) => < console.log('Step 3: In promise constructor'); resolve(); >).then(() => console.log('Step 4: In then')) .then(() => console.log('Step 5: In another then')); setTimeout(() => console.log("Step 6: In another setTimeout")) new Promise((resolve) => < console.log('Step 7: In promise constructor'); resolve(); >).then(() => console.log('Step 8: In then')) .then(() => console.log('Step 9: In another then'));
Step 1: In global scope Step 3: In promise constructor Step 7: In promise constructor Step 4: In then Step 8: In then Step 5: In another then Step 9: In another then Step 2: In setTimeout Step 6: In another setTimeout
Тут уже что-то по интереснее, давайте разбираться. Все синхронные задачи выполнились первыми. Затем подтянулись микротаски, но не в обычном порядке: вместо того чтобы выполнить console.log в последовательности Step 4 , Step 5 , Step 8 , Step 9 , он выполнился в последовательности Step 4 , Step 8 , Step 5 , Step 9 . Сейчас разберемся почему так случилось.

Дело в том, что интерпретатор JavaScript выполняет код шаг за шагом. Он увидел что у нас есть then и поместил их в очередь микрозадач, когда пришла микрозадачи отработали — интерпретатор увидел еще один then и опять поместил его в очередь микрозадач. Таким образом все then , которые были после первых then выполнялись в ряд после того как первичные then отработали.

Микрозадача, внутри которой другая микрозадача
Давайте создадим промис, внутри которого будет объявляться макрозадача. Сделаем это опять же с помощью промисов:
setTimeout(() => console.log('Step 1: In setTimeout')); new Promise(resolve => < console.log('Step 2: In promise constructor'); resolve(); >).then(() => < console.log('Step 3: In then'); setTimeout(() =>console.log('Step 4: In setTimeout (inside of "then")')); >); setTimeout(() => console.log('Step 5: In another setTimeout'));
Step 2: In promise constructor Step 3: In then Step 1: In setTimeout Step 5: In another setTimeout Step 4: In setTimeout (inside of "then")
Тут все достаточно тривиально. Во время выполнения одной из микрозадач была найдена макрозадача. Эта макрозадача была помещена в конец очереди макрозадач. Поэтому Step 4 вывелся в конце (потому что данная микрозадача была добавлена позже всех).
Макрозадача, внутри которой микрозадача
setTimeout(() => console.log('Step 1: In setTimeout')); setTimeout(() => < new Promise(resolve =>< console.log('Step 2: In promise constructor (inside setTimeout)'); resolve(); >).then(() => console.log('Step 3: In then (inside setTimeout)')); >); new Promise(resolve => < console.log('Step 4: In promise constructor'); resolve(); >).then(() => < console.log('Step 5: In then'); >); setTimeout(() => console.log('Step 6: In another setTimeout'));
Step 4: In promise constructor Step 5: In then Step 1: In setTimeout Step 2: In promise constructor (inside setTimeout) Step 3: In then (inside setTimeout) Step 6: In another setTimeout
Давайте вспомним что макрозадачи выполняются по одной. Перед тем как выполнять каждую следующую задачу Event Loop проверяет нет ли у нас активных задач из Call Stack, а также нет ли микрозадач.
Как только новая задача находится и Call Stack пустой — она сразу же выполняется. Вот почему Step 3 выполнился сразу же после Step 2 .

Вместо заключения
Если вам понравилась данная статья — то вы всегда можете перейти в мой блог, там больше схожей информации о веб-разработке.
Если у вас остались вопросы — не стесняйтесь задавать их в комментариях. Хорошего времяпрепровождения! ♂
Референсы
Базовое понимание принципа Eventloop
Все мы слышали ни раз: JavaScript однопоточный язык программирования. Но, что это означает? Из вышес.