Event Loop (цикл событий) в JavaScript



Из статьи вы узнаете про цикл событий, Call Stack, Web API, Callback Queue, микро- и макро-задачи, однопоточность в JS, асинхронность.
На одном из собеседований мне сказали: "ты первый кандидат, который сумел нормально рассказать про event loop". Хочу исправить это положение, и помочь другим понять непростую тему событийного цикла. Тема комплексная, поэтому начну с понятной иллюстрации.
Статья жирная, поэтому запасайтесь попкорном. Рекомендую параллельно пробовать примеры в консоли браузера. Если что-то непонятно — пишите моему ассистенту. Если столкнулись с темой впервые — скорее всего, придется перечитать несколько раз.
Event Loop — пример из жизни
Представьте, что вы подошли к двери своего дома и у вас в голове есть список из пяти задач с упрощенной оценкой времени на выполнение:
- Открыть дверь ключом (почти мгновенно)
- Вскипятить чайник (5 минут)
- Перед уходом из дома вы ставили в микроволновку размораживаться мясо — нужно его достать (почти мгновенно)
- Переодеться в домашнее (почти мгновенно)
Если бы вы были движком JavaScript, вы решали бы эти задачи определенным образом с помощью событийного цикла. Он помогает вам управлять задачами, когда их несколько, а некоторые из них занимают время.
Вот что произошло в первой итерации цикла:
- Задача "Открыть дверь" попала в Call Stack (то, на чем ваше внимание прямо сейчас). Вы сразу ее сделали и забыли.
- Задача "Вскипятить чайник" попала в Call Stack. Она занимает время, но вы можете поручить ее бытовой технике, которая есть у вас дома (также, как у браузера есть Web API, чтобы помогать с затратными по времени задачами). От вас требуется лишь нажать кнопку на чайнике.
- Задача "Достать мясо из микроволновки" ждет в очереди микро-задач, потому что вы еще сделали не все "синхронные" задачи
- Переодеться в домашнее попала в Call Stack и вы сразу её выполнили.
- Вы услышали, что вскипел чайник. Теперь вам нужно выполнить функцию "заварить чай". Эта задача попадает в очередь макро-задач.
- Вы сделали все синхронные задачи. У вас теперь две очереди задач — микро- и макро-задачи. В конце текущего цикла вы успеваете сделать микро-задачу "достать мясо из микроволновки", ведь это секундное дело.
На этом первая итерация закончилась. Теперь вторая итерация цикла, в которой вы выполняете задачи из очереди макро-задач:Там всего одна задача — завариванию чая. ть чай. Вы ценитель китайского чая, поэтому следущий цикл вы посвятите одной макро-задаче — завар
- "Заварить чай" попадает в Call Stack, вы занимаетесь ее выполнением.
- Пока вы завариваете чай, на телефон приходит сообщение. Проверить его — секундное дело, поэтому вы делаете эту задачу сразу после заваривания чая в текущей (второй) итерации цикла.
В этом бытовом примере я проиллюстрировал, как работает событийный цикл в JS. Есть список из нескольких задач. Часть из них вы можете сделать прямо сейчас, ча какую передать бытовой технике. В каком порядке сделать задачи, и что войдет в текущую итерацию. сть — занимают время, нужно ждать. Событийный цикл определил, какую задачу сделать прямо
Если вы поняли пример выше — вы поняли, как работает Event Loop. Дальше остается лишь посмотреть, как это работает в JavaScript.
Что такое цикл событий в JavaScript
Цикл событий в JavaScript - это бесконечный цикл, который отслеживает и выполняет задачи в порядке очереди (FIFO: First-In-First-Out)

Очередь: кто первый подошел — тот первый получит услугу и покинет очередь. Event Loop — система с набором правил о том, кто в какую очередь встает, как и где обрабатывается.
Что значит "однопоточность" или single-threaded в контексте JavaScript
В моменте у нас может быть множество задач:
- выполнить синхронные операции чтения и записи — присвоить значения переменным, выполнить синхронные функции, и т.д.
- обработать счетчики
setTimeout
и интервалыsetInterval
- а в это время пользователь яростно жмет на кнопки с обработчиками и заполняет формы с отправкой на сервер
Как выполнить все эти задачи? Как расставить приоритеты? Как сделать так, чтобы пользователь получал данные как можно быстрее?
В JavaScript выполнение кода является однопоточным (single-threaded), то есть одновременно может выполняться только одна задача. Рассмотрим пример:
console.log('first task');
console.time();
// блокирующая операция
for (let i=0; i <= 1000000; i++){
i === 1000000 && console.log('Hey, everyone is waiting for me')
};
console.timeEnd();
console.log('next task');
/*
Результат в консоли:
'first task'
'Hey, everyone is waiting for me'
default: 4 ms
'next task'
*/
- JS выполняет первую функцию и выводит в консоль “first task”
- JS выполняет цикл в течение 4мс, и выводит в консоль 'Hey, everyone is waiting for me'.
- JS выполняет последнюю функцию и выводит 'next task'
Так работает однопоточность. Каждая задача выполняется последовательно друг за другом. Следующая задача не начинается, пока не выполнена предыдущая. В нашем примере пользователь будет ждать, пока выполнится цикл, прежде чем увидит результат последней функции.
Event Loop позволяет однопоточному JS выполнять неблокирующие операции ввода/вывода (I/O), передавая их ядру системы (kernel), когда это возможно.
console.log('first task');
// не блокирующая операция, выполняется в фоне
setTimeout(()=>console.log('Hey, noone is waiting for me!'), 2000)
console.log('next task');
/*
Результат в консоли:
'first task'
'next task'
'Hey, noone is waiting for me!'
*/
Благодаря событийному циклу операция, занимающая значительное время, не блокирует следующие задачи и выполняется в фоне.
Call Stack, Web Api, Callback Queue
Когда интерпретатор/движок JS видит строчку с setTimeout, функция попадает в Call Stack. Call Stack отслеживает, какие задачи должны быть выполнены и какая задача выполняется в данный момент.

Call Stack можно увидеть в консоли своего браузера, если выбрать строку в качестве брейкпоинта.
setTimeout
— асинхронная функция, которая является частью Web API. В контексте JavaScript, Web APIs — программный интерфейс веб-браузера, который позволяет взаимодействовать с базовой операционной системой для выполнения определенных операций.
Некоторые из наиболее популярных функций JavaScript Web API:
- setTimeout и clearTimeout,
- setInterval и clearInterval,
- fetch, XMLHttpRequest,
- IndexedDB;
Когда setTimeout попадает в Call Stack интерпретатор понимает, что это асинхронная функция Web API, которая может быть выполнена параллельно, не блокируя синхронный код. Счетчик уходит в фон, Call Stack освобождается, можно выполнить следующую синхронную задачу.
Когда счетчик дойдет до нуля — setTimeout вернет функцию в Callback Queue. Это очередь, в которой ждут своей очереди макро-задачи. Как только движок выполнит весь синхронный код текущей макро-задачи, он возьмет из очереди нашу колбэк функцию и выполнит ее.

На данный момент может в голове может возникнуть путаница, как это всё работает вместе. Не переживайте — я покажу всё на примерах. Сейчас достаточно общего знакомства с темой.
Разбор примера с setTimeOut
Посмотрите пример ниже. В каком порядке выполнятся задачи и будет ли пользователь ждать 3 секунды, прежде чем увидит в консоли сообщение “Request resolved”?
console.log('Hello! Processing your request');
setTimeout(function setNumber() {
console.log("Your number: 150");
}, 3000);
console.log('Request resolved, say your number to get results');
Первая строчка — это синхронная функция. Она попадает в Сall stack и сразу же выполняется. В консоль попадает сообщение 'Hello! Processing your request'

Затем, в Call Stack попадает функция setTimeout(). Эта функция относится к Web API браузера, работа будет происходить параллельно в фоне (как в примере со стиральной машинкой).

Интерпретатор JS читает код и передает счетчик на выполнение ОС, освобождая Call Stack.

Call Stack свободен, можно перейти к чтению последней строчки — console.log(). Параллельно в фоне идет обратный отсчет в три секунды.

setTimeout() счетчик дошел до нуля, колбэк функция setNumber() попадает в очередь. Пока Call Stack занят синхронными операциями, функция хранится в очереди Callback Queue. Если бы там было 100 асинхронных функций — они бы все встали в очередь и ждали, пока выполнится весь синхронный код.

Синхронный console.log() выполнился, Call Stack свободен. Теперь можно брать задачи из очереди Callback Queue

Чтобы выполнить функцию setNumber(), нужно обработать вложенный в нее console.log(). Образуется стопка (call stack), вложенные функции накладываются слой за слоем. Больше примеров с call stack можете найти в моей статье про рекурсию.

Стопка начинает схлопываться, как только мы одна из функций может быть выполнена. В нашем случае, выполняется console.log(), а затем и setNumber()
Теперь у нас свободен Call Stack, нет задач, связанных с Web API, очередь функций обратного вызова Callback Queue пуста. Событийный цикл ждет поступления новых задач.
Разбор примера с пользовательскими событиями
Рассмотрим чуть более сложный пример, в котором добавляются пользовательские события:
// sync
const button = document.getElementById('btn');
// Web API
button.addEventListener("click", () => {
console.log('clicked button')
});
// sync
console.log("Hi!");
// Web API
setTimeout(function timeout() {
console.log("Click the button!");
}, 5000);
// sync
console.log("Welcome to loupe.");
Вот как будет отработает этот код:
- Движок JS прочитает обработчик кликов, передаст на обработку Web API, где он будет висеть в фоне.
- JS прочитает и сразу выполнит console.log("Hi!"), т.к. это синхронная операция;
- JS прочитает setTimeout и и передаст его Web API, где он будет висеть 5 секунд.
- JS прочитает и сразу выполнит console.log("Welcome to loupe.");
- Теперь движок займется обработкой колбэк функций в очереди.
- Происходит несколько событий. 1) Пользователь нажал на кнопку, 2) закончился таймер, 3) пользователь еще раз нажал на кнопку. Все эти события попадут в Callback Queue в порядке очереди, в таком же порядке будут выполняться и исчезать из очереди. Аналогично тому, как люди подходят к кассе, образуется очередь, кассир рассчитывает первого покупателя, потом второго, третьего и так далее.
Поиграйтесь с этим примером в интерактивной среде, чтобы лучше понять, как это работает:
Loop, built by Philip Roberts from &yet.
Микро и макро задачи
Итак, из предыдущих разделов стало понятно следующее:
- В первую очередь выполняются синхронные операции скрипта, строчка за строчкой.
- Когда event loop будет свободен от выполнения синхронных операций — он начнет разбирать колбэк-функции из Callback Queue, которые попали туда после обработки Web API.
Оба пункта — примеры макро-задач. Помимо них есть еще микро-задачи, которые образуют свою собственную очередь.
Разберем на примере. В каком порядке будет выполнен следующий код?
// script.js
// синхронная операция, выполняется сразу
console.log("first task");
// ???
Promise.resolve()
.then(() => console.log("promise 1"));
// Асинхронная функция Web API, выполняется в фоне, колбэк попадает в очередь, выполняется после синхронного кода
setTimeout(() => console.log("timeout"));
// ???
Promise.resolve()
.then(() => console.log("promise 2"));
// синхронная операция, выполняется сразу
console.log("last task");
В этом примере:
- выполнение скрипта — это макрозадача
- setTimeout — это макрозадача
- Обработчик разрешенного промиса (Promise's resolve handler) — это микрозадача
Макрозадачи образуют одну очередь, а микрозадачи — другую. На каждой итерации событийного цикла выполняется одна макрозадача, затем все задачи из очереди микрозадач. После этого начинается новая итерация цикла.
// script.js — macro1
// macro1.sync1
console.log("first task");
// macro1.micro1
Promise.resolve()
.then(() => console.log("promise 1"));
// macro2
setTimeout(
// macro2.sync1
() => console.log("timeout")
);
// macro1.micro2
Promise.resolve()
.then(() => console.log("promise 2"));
// macro1.sync2
console.log("last task");
Вот как будут выглядеть две итерации цикла:
- Выполнится макрозадача1, затем — все микрозадачи в очереди. В рамках макрозадачи1 все синхронные операции будут выполнены, setTimout уйдет на обработку Web API, обработчики разрешенного промиса встанут в очередь и выполнятся сразу после последней синхронной операции скрипта.
- Во вторую итерацию цикла из Callback Queue console.log("timeout") попадет в Call Stack и выполнится.
((sync1 → sync2) → (micro1 → micro2)) → (macro2)

Результат выполнения скрипта:
// ИТЕРАЦИЯ 1 событийного цикла:
/*
Синхронный код:
first task
last task
*/
/*
Очередь микро-задач:
promise 1
promise 2
*/
// ИТЕРАЦИЯ 2 событийного цикла, макро-задача:
// timeout
Примеры макрозадач:
- setTimeout
- setInterval
- requestAnimationFrame
- I/O operations (reading or writing to the disk)
Примеры микрозадач:
- Promises resolve and reject handlers
- process.nextTick
- MutationObserver
- process.queueMicrotask
Пример на понимание обработчиков промиса
Разберем еще один пример с реального собеседования в крупнейшую IT-компанию РФ:
const p = new Promise(resolve => {
setTimeout(()=>resolve('data'), 3000)
});
p.then(() => {
p.then(() => {
console.log('A');
});
console.log('C');
});
p.then(() => {
console.log('B');
});
Что здесь происходит:
- Мы создали
Promise
и присвоили в качестве значения переменнойp
- Резолвим
Promise
с помощьюsetTimeout
с таймером на 3 секунды - Добавляем два обработчика разрешенного промиса с колбек-функциями
- В первой функции вложен еще один обработчик и вывод в консоль
'C'
. Во второй — вывод в консоль'B'
Прежде чем переходить к разбору подумайте и запишите, каким будет результат исполнения этого кода.
А теперь давайте разбираться. Что произойдет в первой итерации событийного цикла? setTimeout
уйдет на исполнение WebApi, начнется отсчет таймера. Интерпретатор зафиксирует, что у нас есть колбеки, которые нужно вызвать, когда разрешится Promise
.

Когда таймер истечет, функция ()=>resolve('data')
попадет в очередь и сразу исполнится во второй итерации событийного цикла, так как Call Stack
будет свободен.
Разрешение Promise
стриггерит обработчики этого события, они попадут в очередь:

Это микро-задачи, который выполнятся в конце третьей итерации событийного цикла. Сначала исполнится первый обработчик:

По итогу его исполнения, в консоль выведется 'C', а в очередь микро-задач попадет еще один колбек вложенного обработчика. В колстек попадет обработчик, который шел следующим в очереди:

По результату исполнения функции второго обработчика верхнего уровня в консоль выведется 'B'.
Затем из очереди в колстек попадает последний обработчик второго уровня, и в консоль выводится 'A'
. Результат в консоли: C -> B -> A
Домашнее задание
Теперь, когда я рассказал все, что нужно знать про Event Loop, попробуйте себя в роли интерпретатора и напишите, что и в каком порядке появится в консоли для приведенного ниже примера:
console.log('Начало скрипта');
setTimeout(()=> {
console.log('Таймаут');
},0);
let p = new Promise((resolve, reject)=> {
setTimeout(()=> {
console.log('Таймаут при создании промиса');
},0);
console.log('Создание промиса');
resolve();
});
p.then(()=>{
console.log('Обработка промиса');
});
console.log('Конец скрипта');
Подсказка — при создании экземпляра промиса интерпретатор выполняет код функции, которую мы передаем в качестве аргумента.
Итого
- Event Loop — это бесконечный цикл, который отслеживает и выполняет задачи в порядке очереди.
- Он позволяет однопоточному движку JS выполнять трудозатратные задачи асинхронно с помощью ядра системы, не блокируя синхронные операции
- Движок JS обрабатывает строчку за строчкой. Синхронные операции попадают в
Call Stack
и сразу выполняются. Асинхронные — попадают вCall Stack
, из него передаются наWeb API
, после выполнения попадают вCallback Queue
. - В рамках итерации цикла сначала выполняется макрозадача (например, синхронные операции скрипта), затем — очередь микрозадач. Пока есть задачи — цикл их выполняет, а когда их нет — ожидает новых задач.
- Макрозадачи — скрипт, setTimeout/setInterval, requestAnimationFrame, I/O операции (чтение или запись на диск)
- Микрозадачи — обработчик промиса, MutationObserver, queueMicrotask, process.nextTick
Бонус: ответ на последнюю задачу:
// sync1
console.log('Начало скрипта'); // 1
// macro1
setTimeout(()=> {
console.log('Таймаут'); // 5
},0);
let p = new Promise((resolve, reject)=> {
// macro2
setTimeout(()=> {
console.log('Таймаут при создании промиса'); // 6
},0);
// sync2
console.log('Создание промиса'); // 2
resolve();
});
// micro1
p.then(()=>{
console.log('Обработка промиса'); // 4
});
// sync3
console.log('Конец скрипта'); // 3