Как работает JavaScript Event Loop?

mr. Cooper 1 час назад Веб-разработка
Как работает JavaScript Event Loop?

setTimeout() поставили первым. Promise.then() - вторым. Но результат получается обратным:

//javascript

console.log("start");

setTimeout(() => {
    console.log("timeout");
}, 0);

Promise.resolve().then(() => {
    console.log("promise");
});

console.log("end");

В консоли:

//text

start
end
promise
timeout

Если раньше с Event Loop не приходилось разбираться всерьёз, такой результат кажется странным. Таймер зарегистрирован раньше Promise. Задержка у него вообще нулевая. Почему тогда promise появляется раньше?

Потому что Event Loop не является одной очередью, из которой JavaScript просто берёт задачи по порядку поступления.

У него есть как минимум две принципиально разные категории работы - tasks и microtasks. И у microtasks есть особенность, из-за которой они получают возможность выполниться раньше следующей обычной задачи.

Именно на этом месте обычно и ломается слишком простая картинка «есть Call Stack, есть очередь, есть Event Loop».

Сначала выполняется то, что уже запущено

Начнём с более скучного примера:

//javascript

console.log("1");

console.log("2");

console.log("3");

Здесь никакого Event Loop почти не видно. JavaScript выполняет инструкции последовательно:

//text

1
2
3

Выполнение текущего кода нельзя просто прервать посередине, чтобы внезапно запустить другую функцию.

Например:

//javascript

function first() {
    console.log("A");

    second();

    console.log("B");
}

function second() {
    console.log("C");
}

first();

Результат предсказуем:

//text

A
C
B

Когда second() начала выполняться, JavaScript не переключился куда-то ещё посреди этой функции. Сначала она завершилась, затем управление вернулось в first().

Это свойство называют run-to-completion: текущая работа должна завершиться прежде, чем начнётся следующая. В модели JavaScript это одна из причин, по которой можно довольно точно рассуждать о порядке выполнения кода.

Проблема начинается, когда программе приходится ждать того, чего JavaScript сам контролировать не может.

Например, ответа сервера.

JavaScript не ждёт сервер

Допустим, приложение отправляет запрос:

//javascript

const response = await fetch("/api/users");

На уровне исходного кода кажется, будто выполнение остановилось на await.

Но это не означает, что JavaScript-поток сидит и ничего не делает, пока сервер готовит ответ.

Сетевой запрос выполняется через API среды выполнения. После того как браузер получил результат, код, который должен продолжить работу с этим результатом, будет запланирован для последующего выполнения.

Поэтому:

//javascript

console.log("До");

fetch("/api/users");

console.log("После");

выведет:

//text

До
После

А обработчик результата выполнится позже.

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

Но здесь появляется следующий вопрос: в какую именно очередь попадёт эта работа?

У Event Loop нет одной большой очереди

Для объяснения Event Loop часто рисуют что-то вроде:

//text

Call Stack
    ↓
  Queue
    ↓
Event Loop

Для первого знакомства схема удобная. Для реального кода - слишком грубая.

В браузере есть task queues и отдельная microtask queue. Более того, HTML Standard специально не описывает task queues как простую единую FIFO-очередь: у разных источников задач могут быть свои очереди, а выбор очереди зависит от правил среды выполнения.

На практике разработчику особенно важно различать две вещи.

setTimeout() приводит к постановке callback в область обычных задач.

Promise.then() и queueMicrotask() работают через microtask queue.

И вот здесь возвращается наш первый пример.

//javascript

console.log("start");

setTimeout(() => {
    console.log("timeout");
}, 0);

Promise.resolve().then(() => {
    console.log("promise");
});

console.log("end");

Сначала JavaScript выполняет синхронный код:

//text

start
end

После этого текущая работа заканчивается.

В microtask queue уже находится callback от Promise. Поэтому он выполняется:

//text

promise

И только затем Event Loop переходит к следующей подходящей обычной задаче:

//text

timeout

Получаем:

//text

start
end
promise
timeout

Вот почему 0 в setTimeout(..., 0) не означает «выполнить раньше Promise».

Что на самом деле означает 0 в setTimeout

Это ещё одна причина, почему Event Loop часто понимают неправильно.

//javascript

setTimeout(() => {
    console.log("hello");
}, 0);

Ноль не означает:

«Запусти callback немедленно».

Он задаёт минимальную задержку перед тем, как таймер сможет привести к постановке соответствующей задачи. Когда эта задача действительно получит возможность выполниться, зависит от состояния Event Loop и других работ, которые уже находятся в системе.

Например:

//javascript

setTimeout(() => {
    console.log("timeout");
}, 0);

for (let i = 0; i < 5_000_000_000; i++) {
    // тяжёлая работа
}

Таймер не прервёт цикл через ноль миллисекунд.

Сначала должен закончиться текущий JavaScript-код.

И это логично: иначе любой внешний callback мог бы вмешаться в состояние программы посреди выполнения функции. Модель run-to-completion как раз гарантирует обратное - текущая работа заканчивается целиком.

Поэтому setTimeout(..., 0) правильнее воспринимать не как команду «сделай сейчас», а как просьбу выполнить эту работу настолько рано, насколько позволят правила планирования и уже выполняющийся код.

Почему Promise оказывается раньше таймера

Теперь можно посмотреть на тот же пример чуть внимательнее:

//javascript

setTimeout(() => {
    console.log("A");
}, 0);

Promise.resolve().then(() => {
    console.log("B");
});

Получаем:

//text

B
A

Promise здесь не «быстрее таймера».

Это вообще не вопрос скорости.

Callback Promise попал в microtask queue, а callback таймера - в обычную task queue. После завершения текущей задачи среда выполняет накопившиеся microtasks прежде, чем перейти к следующей обычной задаче.

Разница небольшая на вид, но огромная по последствиям.

Представим:

//javascript

console.log("A");

queueMicrotask(() => {
    console.log("B");
});

setTimeout(() => {
    console.log("C");
}, 0);

console.log("D");

Результат:

//text

A
D
B
C

Причём queueMicrotask() здесь не создаёт отдельный поток. Это просто способ сказать среде: «выполни эту небольшую работу после завершения текущего JavaScript, но до перехода к следующей обычной задаче». Именно так этот механизм и описывается в документации платформы.

Самая неприятная особенность microtasks

С microtasks есть одна деталь, которая гораздо важнее красивой схемы с двумя очередями.

Microtask может добавить новую microtask.

//javascript

queueMicrotask(() => {
    console.log("A");

    queueMicrotask(() => {
        console.log("B");
    });
});

setTimeout(() => {
    console.log("C");
}, 0);

Результат:

//text

A
B
C

Почему B не переносится после C?

Потому что после завершения текущей задачи Event Loop продолжает обрабатывать microtask queue, пока она не станет пустой. Если одна microtask добавила другую, новая тоже попадёт в этот процесс.

И это уже может стать проблемой.

Например:

//javascript

function again() {
    queueMicrotask(again);
}

again();

Здесь каждая microtask создаёт следующую.

Очередь не успевает опустеть.

В результате браузер может продолжать выполнять microtasks и не получить нормальной возможности перейти к другим задачам, включая работу, связанную с интерфейсом и rendering. HTML Standard отдельно предупреждает о том, что большое количество microtasks способно мешать браузеру выполнять собственную работу.

Получается любопытная вещь: для блокировки страницы необязательно писать очевидный while (true).

Можно создать проблему асинхронным на вид кодом.

Где здесь Call Stack

Теперь можно вернуться к знаменитому Call Stack.

Для такого кода:

//javascript

function one() {
    two();
}

function two() {
    console.log("Hello");
}

one();

стек вызовов примерно выглядит так:

//text

two()
one()
global code

Когда two() заканчивается, она исчезает из стека.

Затем завершается one().

После этого заканчивается текущая работа.

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

И Event Loop нужен именно для того, чтобы связать эти состояния во времени.

Условно:

//text

        выполняется JavaScript
                 │
                 ▼
            Call Stack
                 │
                 │ стек пуст
                 ▼
           Microtasks
                 │
                 │ очередь пуста
                 ▼
          следующая Task

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

А что происходит с интерфейсом?

Здесь Event Loop перестаёт быть просто теоретической конструкцией.

Представим обычную страницу. Пользователь нажимает кнопку:

//javascript

button.addEventListener("click", () => {
    console.log("clicked");
});

Нажатие пользователя должно привести к выполнению обработчика как части работы, которую браузер планирует через свою модель задач.

Но если обработчик сделать таким:

//javascript

button.addEventListener("click", () => {
    const start = Date.now();

    while (Date.now() - start < 5000) {
        // пять секунд тяжёлой синхронной работы
    }
});

страница на эти пять секунд фактически перестанет нормально реагировать.

Проблема не в том, что Event Loop сломался.

Он как раз делает то, что должен: текущая задача выполняется до конца.

Проблема в том, что текущая задача слишком большая.

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

Поэтому Event Loop - это ещё и практическая причина, по которой фронтенд-разработчику приходится думать о размере синхронной работы.

Event Loop не делает JavaScript многопоточным

Здесь часто появляется другая ошибка.

Если JavaScript умеет одновременно ждать сеть, принимать события и выполнять callbacks, легко решить, что Event Loop каким-то образом превращает один поток в несколько.

Нет.

Асинхронность не равна параллельности.

Если выполнить:

//javascript

function calculate() {
    for (let i = 0; i < 10_000_000_000; i++) {
        // тяжёлая работа
    }
}

calculate();

Event Loop не сможет взять эту функцию и продолжить её выполнение параллельно с обработчиком клика.

Текущая JavaScript-работа должна завершиться.

Для настоящего параллельного выполнения в браузере существуют другие механизмы, например Web Workers. У worker есть собственный агент и собственный event loop.

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

Это хороший пример того, почему популярные картинки про Event Loop полезны только до определённого момента.

И всё-таки зачем всё это нужно?

Если отбросить термины, проблема очень простая.

Браузеру постоянно приходится совмещать несколько видов работы:

  • выполнять JavaScript;

  • реагировать на действия пользователя;

  • ждать завершения сетевых операций;

  • обрабатывать события;

  • обновлять интерфейс;

  • выполнять отложенные callbacks.

При этом нельзя позволить одному небольшому JavaScript-фрагменту навсегда занять возможность выполнять всё остальное.

Event Loop и связанные с ним очереди дают среде правила, по которым эта работа может продолжаться.

Сценарий выглядит примерно так:

//text

JavaScript-код
      │
      ▼
текущая задача
      │
      ▼
стек опустел
      │
      ▼
обработка microtasks
      │
      ▼
браузер получает возможность
продолжить другую работу
      │
      ▼
следующая задача

Это не буквальная схема внутреннего устройства Chrome, Firefox или Safari. Реальная модель сложнее: у Event Loop есть разные источники задач, правила выбора runnable-задач, microtask checkpoints и отдельные этапы, связанные с rendering. HTML Standard описывает эту модель значительно подробнее, чем принято показывать в учебных диаграммах.

Но для повседневной разработки из неё можно вынести несколько действительно полезных вещей.

setTimeout(fn, 0) не означает «выполнить fn сейчас».

Promise.then() выполняется через microtask queue и поэтому обычно оказывается раньше callback таймера, если оба запланированы в рамках одной текущей работы.

Microtasks выполняются до опустошения очереди, поэтому бесконтрольное добавление новых microtasks способно задержать другие задачи.

Долгий синхронный JavaScript блокирует обработку следующей работы, даже если весь остальной код написан с Promise, async/await и fetch.

И, пожалуй, самое полезное изменение в понимании Event Loop происходит именно здесь.

Это не механизм, который «делает JavaScript асинхронным».

JavaScript не начинает выполнять несколько строк одновременно.

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

Поэтому в следующий раз, когда setTimeout(..., 0) внезапно окажется после Promise.then(), искать причину в скорости выполнения уже не придётся.

Они просто оказались в разных очередях и получили разные условия для запуска.

Комментарии

Пока нет комментариев. Будьте первым, кто напишет.

Чтобы оставить комментарий, войдите в аккаунт.

Похожие статьи