Когда вы пишете console.log('A'), затем setTimeout(() => console.log('B'), 0) и Promise.resolve().then(() => console.log('C')), что напечатается первым? Если ваш ответ не «A, C, B», возможно, стоит разобраться, как именно браузер планирует выполнение кода. Это не магия и не случайность - это строгая система приоритетов, встроенная в движок V8.
Event Loop is the mechanism that checks the call stack and queues for pending tasks to execute in a single-threaded environment. Он решает, когда выполнять текущий код, а когда переключиться на обработку событий. Понимание разницы между микрозадачами (microtasks) и макрозадачами (macrotasks) позволяет предсказывать порядок выполнения любого асинхронного кода без гадания.
Ключевые выводы
- Микрозадачи всегда выполняются раньше макрозадач того же цикла.
- Очередь микрозадач опустошается полностью перед тем, как начнется следующая макрозадача.
- Promise is a built-in object representing eventual completion or failure of an asynchronous operation. Колбэки
.then()и.catch()попадают в очередь микрозадач. setTimeoutиsetIntervalсоздают макрозадачи, которые ждут своей очереди в Task Queue.- Блокировка главного потока даже одной длинной синхронной операцией остановит все асинхронные операции до ее завершения.
Как устроен Event Loop
JavaScript выполняется в одном потоке. Это значит, что в любой момент времени происходит только одна вещь: либо исполняется функция из Call Stack, либо ничего не происходит. Но тогда как мы получаем данные с сервера или реагируем на клик по кнопке, не заставляя пользователя ждать?
Ответ кроется в Web APIs (или Node.js API). Когда вы вызываете fetch() или setTimeout(), эти функции делегируют работу браузеру (или среде выполнения). Браузер выполняет их в фоновых потоках. Как только операция завершается, результат возвращается в очередь задач.
Здесь важно различать два типа очередей:
- Task Queue (очередь макрозадач): сюда попадают события от DOM, таймеры (
setTimeout,setInterval), I/O операции и колбэки от Web Workers. - Microtask Queue (очередь микрозадач): сюда попадают промисы (
Promise.then), мутации наблюдателей (MutationObserver) и кастомный планировщикqueueMicrotask.
Алгоритм работы цикла прост:
- Выполнить весь синхронный код из Call Stack.
- После каждого шага (или после выполнения каждой макрозадачи) проверить Microtask Queue. Если она не пуста, выполнить ВСЕ задачи из нее рекурсивно, пока очередь не станет пустой.
- Только после полного опустошения микрозадач взять одну задачу из Task Queue и положить ее в Call Stack.
- Повторять шаги 2-3 бесконечно.
Микрозадачи vs Макрозадачи: сравнение
Главное отличие - приоритет. Микрозадачи считаются «более срочными». Почему так сделано? Потому что они часто связаны с состоянием данных, которое должно быть согласованным для следующего рендера или логического шага. Макрозадачи обычно связаны с внешними событиями, которые могут подождать один кадр.
| Характеристика | Микрозадачи (Microtasks) | Макрозадачи (Macrotasks) |
|---|---|---|
| Приоритет выполнения | Высокий (выполняются сразу после текущего стека) | Низкий (ждут своей очереди в цикле) |
| Источники | Promise.then/catch, queueMicrotask, MutationObserver |
setTimeout, setInterval, I/O, DOM events, MessageChannel |
| Влияние на UI | Минимальное, если не блокируют поток долго | Можут вызывать лаги, если выполняются слишком часто |
| Глубина рекурсии | Не ограничена явно, но может вызвать переполнение стека при бесконечном добавлении новых микрозадач | Каждая новая задача ждет следующего цикла |
Практический пример: порядок вывода
Давайте разберем классический пример, который часто встречается на собеседованиях, но который лучше всего иллюстрирует механику:
console.log('1: Start');
setTimeout(() => {
console.log('2: Timeout');
}, 0);
Promise.resolve()
.then(() => {
console.log('3: Promise');
});
console.log('4: End');
Что произойдет? Давайте проследим шаг за шагом:
- Движок видит
console.log('1: Start'). Выполняет. Вывод: 1: Start. - Движок видит
setTimeout. Функция кладется в Task Queue. Таймер запускается, но колбэк еще не вызывается. - Движок видит
Promise.resolve(). Промис уже выполнен (resolved). Колбэк.then()кладется в Microtask Queue. - Движок видит
console.log('4: End'). Выполняет. Вывод: 4: End. - Call Stack пуст. Event Loop проверяет Microtask Queue. Там есть задача «3: Promise».
- Выполняется «3: Promise». Вывод: 3: Promise.
- Microtask Queue снова проверяется. Она пуста.
- Event Loop берет первую задачу из Task Queue. Это «2: Timeout».
- Выполняется «2: Timeout». Вывод: 2: Timeout.
Итоговый порядок: 1, 4, 3, 2. Многие разработчики ошибочно думают, что таймер с нулевой задержкой выполнится раньше промиса, потому что он «быстрее». Но приоритет определяется типом очереди, а не временем ожидания.
Ловушки и частые ошибки
Одна из самых опасных ситуаций - бесконечный цикл микрозадач. Если внутри обработчика .then() вы создаете новый промис и подписываетесь на него, вы можете никогда не дать браузеру обновить интерфейс.
// Опасный паттерн
function infiniteLoop() {
Promise.resolve().then(() => {
console.log('Still running');
infiniteLoop(); // Новая микрозадача добавляется мгновенно
});
}
infiniteLoop();
Пока этот код крутится, браузер не сможет отрисовать новые кадры, обработать клики или скролл. Для таких случаев существуют requestAnimationFrame (который является макрозадачей) или явная пауза через await new Promise(r => setTimeout(r, 0)).
Еще одна тонкость: async/await. Код после первого await фактически превращается в цепочку промисов. Поэтому код после await выполняется как микрозадача, если исходный промис уже был резолвлен ранее. Это делает async/await более предсказуемым, чем вложенные колбэки, но правила приоритета остаются теми же.
Как это влияет на производительность
Если вы обрабатываете большие массивы данных, не делайте это в одном гигантском синхронном блоке. Лучше разбить работу на чанки и отдавать управление Event Loop через микрозадачи или таймеры. Например, вместо обработки 10,000 элементов за раз, можно обрабатывать по 100 элементов, используя Promise.resolve().then(processChunk) между итерациями. Это позволит браузеру успеть отрисовать прогресс-бар или ответить на действия пользователя.
Инструменты вроде Chrome DevTools позволяют визуализировать эти процессы. Во вкладке Performance вы увидите, как время тратится на Scripting, Rendering и другие этапы. Если вы видите длинные желтые полосы (Scripting), проверьте, не блокируете ли вы главный поток тяжелыми вычислениями или бесконечными микрозадачами.
Частые вопросы
Всегда ли микрозадачи выполняются быстрее макрозадач?
Да, в рамках одного цикла Event Loop. Все микрозадачи, накопившиеся в очереди, выполнятся до того, как будет взята следующая макрозадача. Однако, если макрозадача была добавлена в очередь раньше, чем появилась первая микрозадача, она все равно выполнится первой, так как Event Loop сначала опрашивает Task Queue, если Microtask Queue пуста в момент проверки.
Что такое queueMicrotask и зачем он нужен?
queueMicrotask - это глобальная функция, которая позволяет добавить функцию в очередь микрозадач напрямую, без создания промиса. Это полезно, когда вам нужно выполнить код после текущего стека, но до следующего рендера или макрозадачи, и вы хотите избежать накладных расходов на создание объекта Promise.
Как async/await взаимодействует с очередями?
Функция async возвращает промис. Оператор await приостанавливает выполнение функции до разрешения промиса. Когда промис разрешается, продолжение выполнения кода помещается в очередь микрозадач. Таким образом, код после await имеет приоритет над макрозадачами, но ниже, чем код, выполняемый синхронно до await.
Можно ли заблокировать Event Loop навсегда?
Технически да, если использовать бесконечный цикл while(true) без выхода или бесконечную цепочку микрозадач. В браузере это приведет к зависанию вкладки. В Node.js это заблокирует процесс, сделав его неспособным обрабатывать новые запросы. Единственный способ остановить такой код - перезапустить среду выполнения.
Есть ли разница в порядке выполнения между браузерами?
Для основных механизмов (Promises, setTimeout) стандарт ECMA-262 определяет поведение, поэтому порядок должен быть одинаковым во всех современных движках (V8, SpiderMonkey, JavaScriptCore). Однако реализация Web APIs (например, точное время срабатывания таймеров) может немного отличаться из-за особенностей планирования ОС и браузера, но логика очередей остается неизменной.