DevSurge 💦

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

Cover Image for Event Loop (цикл событий) в JavaScript
Mark Nelyubin
Mark Nelyubin

Из статьи вы узнаете про цикл событий, Call Stack, Web API, Callback Queue, микро- и макро-задачи, однопоточность в JS, асинхронность.

На одном из собеседований мне сказали: "ты первый кандидат, который сумел нормально рассказать про event loop". Хочу исправить это положение, и помочь другим понять непростую тему событийного цикла. Тема комплексная, поэтому начну с понятной иллюстрации.

Статья жирная, поэтому запасайтесь попкорном. Рекомендую параллельно пробовать примеры в консоли браузера. Если что-то непонятно — пишите моему ассистенту. Если столкнулись с темой впервые — скорее всего, придется перечитать несколько раз.

Event Loop — пример из жизни

Представьте, что вы подошли к двери своего дома и у вас в голове есть список из пяти задач с упрощенной оценкой времени на выполнение:

  1. Открыть дверь ключом (почти мгновенно)
  2. Вскипятить чайник (5 минут)
  3. Перед уходом из дома вы ставили в микроволновку размораживаться мясо — нужно его достать (почти мгновенно)
  4. Переодеться в домашнее (почти мгновенно)

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

Вот что произошло в первой итерации цикла:

  1. Задача "Открыть дверь" попала в Call Stack (то, на чем ваше внимание прямо сейчас). Вы сразу ее сделали и забыли.
  2. Задача "Вскипятить чайник" попала в Call Stack. Она занимает время, но вы можете поручить ее бытовой технике, которая есть у вас дома (также, как у браузера есть Web API, чтобы помогать с затратными по времени задачами). От вас требуется лишь нажать кнопку на чайнике.
  3. Задача "Достать мясо из микроволновки" ждет в очереди микро-задач, потому что вы еще сделали не все "синхронные" задачи
  4. Переодеться в домашнее попала в Call Stack и вы сразу её выполнили.
  5. Вы услышали, что вскипел чайник. Теперь вам нужно выполнить функцию "заварить чай". Эта задача попадает в очередь макро-задач.
  6. Вы сделали все синхронные задачи. У вас теперь две очереди задач — микро- и макро-задачи. В конце текущего цикла вы успеваете сделать микро-задачу "достать мясо из микроволновки", ведь это секундное дело.

На этом первая итерация закончилась. Теперь вторая итерация цикла, в которой вы выполняете задачи из очереди макро-задач:Там всего одна задача — завариванию чая. ть чай. Вы ценитель китайского чая, поэтому следущий цикл вы посвятите одной макро-задаче — завар

  1. "Заварить чай" попадает в Call Stack, вы занимаетесь ее выполнением.
  2. Пока вы завариваете чай, на телефон приходит сообщение. Проверить его — секундное дело, поэтому вы делаете эту задачу сразу после заваривания чая в текущей (второй) итерации цикла.

В этом бытовом примере я проиллюстрировал, как работает событийный цикл в JS. Есть список из нескольких задач. Часть из них вы можете сделать прямо сейчас, ча какую передать бытовой технике. В каком порядке сделать задачи, и что войдет в текущую итерацию. сть — занимают время, нужно ждать. Событийный цикл определил, какую задачу сделать прямо

Если вы поняли пример выше — вы поняли, как работает Event Loop. Дальше остается лишь посмотреть, как это работает в JavaScript.

Что такое цикл событий в JavaScript

Цикл событий в JavaScript - это бесконечный цикл, который отслеживает и выполняет задачи в порядке очереди (FIFO: First-In-First-Out)
Article Image

Очередь: кто первый подошел — тот первый получит услугу и покинет очередь. 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'
*/
  1. JS выполняет первую функцию и выводит в консоль “first task”
  2. JS выполняет цикл в течение 4мс, и выводит в консоль 'Hey, everyone is waiting for me'.
  3. 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 отслеживает, какие задачи должны быть выполнены и какая задача выполняется в данный момент.

Article Image

Call Stack можно увидеть в консоли своего браузера, если выбрать строку в качестве брейкпоинта.

setTimeout — асинхронная функция, которая является частью Web API. В контексте JavaScript, Web APIs — программный интерфейс веб-браузера, который позволяет взаимодействовать с базовой операционной системой для выполнения определенных операций.

Некоторые из наиболее популярных функций JavaScript Web API:

Когда setTimeout попадает в Call Stack интерпретатор понимает, что это асинхронная функция Web API, которая может быть выполнена параллельно, не блокируя синхронный код. Счетчик уходит в фон, Call Stack освобождается, можно выполнить следующую синхронную задачу.

Когда счетчик дойдет до нуля — setTimeout вернет функцию в Callback Queue. Это очередь, в которой ждут своей очереди макро-задачи. Как только движок выполнит весь синхронный код текущей макро-задачи, он возьмет из очереди нашу колбэк функцию и выполнит ее.

Article Image

На данный момент может в голове может возникнуть путаница, как это всё работает вместе. Не переживайте — я покажу всё на примерах. Сейчас достаточно общего знакомства с темой.

Разбор примера с 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'

Article Image

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

Article Image

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

Article Image

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

Article Image

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

Article Image

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

Article Image

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

Article Image

Стопка начинает схлопываться, как только мы одна из функций может быть выполнена. В нашем случае, выполняется 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.");

Вот как будет отработает этот код:

  1. Движок JS прочитает обработчик кликов, передаст на обработку Web API, где он будет висеть в фоне.
  2. JS прочитает и сразу выполнит console.log("Hi!"), т.к. это синхронная операция;
  3. JS прочитает setTimeout и и передаст его Web API, где он будет висеть 5 секунд.
  4. JS прочитает и сразу выполнит console.log("Welcome to loupe.");
  5. Теперь движок займется обработкой колбэк функций в очереди.
  6. Происходит несколько событий. 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, затем — все микрозадачи в очереди. В рамках макрозадачи1 все синхронные операции будут выполнены, setTimout уйдет на обработку Web API, обработчики разрешенного промиса встанут в очередь и выполнятся сразу после последней синхронной операции скрипта.
  2. Во вторую итерацию цикла из Callback Queue console.log("timeout") попадет в Call Stack и выполнится.

((sync1 → sync2) → (micro1 → micro2)) → (macro2)

Article Image

Результат выполнения скрипта:

// ИТЕРАЦИЯ 1 событийного цикла:
  /*
  Синхронный код:
  first task
  last task
  */
  /*
  Очередь микро-задач:
  promise 1
  promise 2
  */

// ИТЕРАЦИЯ 2 событийного цикла, макро-задача:
  // timeout

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

  • setTimeout
  • setInterval
  • requestAnimationFrame
  • I/O operations (reading or writing to the disk)

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

Пример на понимание обработчиков промиса

Разберем еще один пример с реального собеседования в крупнейшую 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.

Article Image

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

Разрешение Promise стриггерит обработчики этого события, они попадут в очередь:

Article Image

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

Article Image

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

Article Image

По результату исполнения функции второго обработчика верхнего уровня в консоль выведется '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

Другие материалы

Cover Image for TypeScript Utility Types — вспомогательные типы и области их применения

TypeScript Utility Types — вспомогательные типы и области их применения

что такое Utility Types в TypeScript, расскажу про основные вспомогательные типы, и покажу, как применять их на реальных проектах.

Mark Nelyubin
Mark Nelyubin
Cover Image for Принципы SOLID с примерами на JS и Vue

Принципы SOLID с примерами на JS и Vue

Расскажу про принципы SOLID с актуальными примерами на JavaScript, Vue, React.

Mark Nelyubin
Mark Nelyubin