26 февраля 2020

Цикл событий: как выполняется асинхронный JavaScript-код в Node.js

Frontend-разработчик в Foquz. https://www.cat-in-web.ru/
Разбираемся, как работает цикл событий Node.js, зачем там коллбэки и в каком порядке они выполняются.
Цикл событий: как выполняется асинхронный JavaScript-код в Node.js

Вы наверняка слышали о знаменитом цикле событий Node.js и о том, как ему удается обеспечивать невероятную скорость без блокировок, располагая всего лишь одним потоком выполнения кода. Ввод-вывод в Node.js событийно управляемый, и все действия выполняются в форме коллбэков – функций обратного вызова. Для организации приложения важно понимать, в каком порядке цикл событий эти коллбэки запускает. Давайте разбираться.

Фазы цикла событий

Цикл событий состоит из нескольких фаз, которые повторяются на каждой итерации одна за другой. В этой статье мы заглянем одним глазом на нижние уровни архитектуры Node.js и посмотрим, что это за фазы и какой код они выполняют.

Схематическое изображение цикла событий в Node.js
Схематическое изображение цикла событий в Node.js

Существует ошибочное представление, что в Node есть только одна глобальная очередь коллбэков. На самом деле, каждая фаза имеет собственную очередь/кучу.

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

Таймеры

Функции обратного вызова таймеров (setTimeout, setInterval) хранятся в куче до того момента, пока не истечет их срок действия. Если в очереди есть несколько таких «просроченных» коллбэков, цикл событий начинает вызывать их в порядке возрастания задержки, пока они не кончатся.

Выполнение таймеров контролируется в фазе опроса, до которой мы очень скоро доберемся. Если цикл долго задерживается в poll-фазе (блокировка), выполнение функций таймеров может быть задержано.

I/O коллбэки

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

Например, вы пишете Node-сервер. Порт, на котором вы хотите запустить процесс, уже используется другим процессом. Node выдаст ошибку ECONNREFUSED. Некоторые *nix-системы могут ожидать получения сообщения об ошибке. Такие вызовы помещаются в очередь этой фазы цикла событий.

Ожидание/Подготовка

На этой фазе для нас не происходит ничего интересного.

Опрос

Цикл событий проверяет, появились ли в очереди новые асинхронные коллбэки. Здесь выполняются почти все наши функции обратного вызова кроме setTimeout, setInterval, setImmediate и close-функций.

Если в начале этой фазы в очереди уже есть обратные вызовы, все они будут выполнены по порядку (за раз выполняется не более определенного числа коллбэков).

Если очередь пуста, возможно несколько вариантов:

  1. Если в очереди setImmediate что-то есть, то цикл событий сразу завершит фазу опроса и перейдет к фазе проверки. Здесь он последовательно выполнит все setImmediate-коллбэки.
  2. Если очередь setImmediate пуста, то будет проверена очередь таймеров. Если какой-то таймер уже завершился, то цикл перейдет в первую фазу (timers) для выполнения функций обратного вызова.
  3. Если же нет ни событий setImmediate, ни готовых таймеров, цикл событий останется в poll-состоянии и будет ожидать добавления в очередь новых коллбэков.

Проверка/setImmediate

К этой фазе цикл событий приходит, когда в фазе опроса не осталось никаких обратных вызовов. Здесь выполняются коллбэки функции setImmediate.

Close-коллбэки

Последними в цикле выполняются функции обратного вызова, связанные с внезапными close-событиями, например, socket.on('close', fn) или process.exit().

Микротаски

Помимо всего этого, в Node есть еще одна очередь микрозадач. В нее помещаются коллбэки метода process.nextTick(), имеющие максимальный приоритет вне зависимости от фазы цикла событий.

Примеры

setTimeout vs setImmediate

Начнем с простых таймеров (на самом деле, они только кажутся простыми):

        function main() {
  setTimeout(() => console.log('1'), 0);
  setImmediate(() => console.log('2'));
}

main();

    

Ожидаемый вывод этого фрагмента:

        1
2

    

Цикл событий начинается с фазы таймеров, выполняет обратный вызов для setTimeout, переходит к следующим фазам, в которых нет никаких коллбэков, достигает фазы проверки и выполняет функцию обратного вызова для setImmediate.

Вы можете справедливо возмутиться, что вызов setTimeout с задержкой в 0 секунд не выполняется сразу же. А значит первый коллбэк не выполнится в фазе таймеров.

Может быть, наш код будет выводить такой результат?

        2
1

    

Если вы запустите этот фрагмент в Node несколько раз, то увидите, что возможны оба варианта.

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

Существует неправильное представление, что setTimeout(fn, 0) всегда выполняется до setImmediate. Как мы только что видели, это далеко не всегда так. setTimeout всегда имеет небольшую задержку (4-20мс). Если она успеет истечь до наступления фазы таймеров (после регистрации коллбэков), то вызов будет выполнен. Иначе сначала вызовется функция, связанная с setImmediate. Это поведение невозможно предсказать – оно зависит от количества коллбэков, фазы цикла и пр.

I/O-коллбэки и сдвиг цикла

Однако если мы немного перепишем код:

        const fs = require('fs');

function main() {
  fs.readFile('./xyz.txt', () => {
    setTimeout(() => console.log('1'), 0);
    setImmediate(() => console.log('2'));
  });
}

main();

    

Вывод всегда будет следующим:

        2
1

    

Что здесь происходит?

  1. Когда мы вызываем функцию main(), цикл событий изначально выполняется без фактического вызова коллбэков. Он видит функцию fs.readFile и регистрирует ее обратный вызов в очереди I/O callbacks. После этого цикл переходит к реальному выполнению кода.
  2. Он начинает с фазы таймеров и ничего там не находит.
  3. В фазе I/O коллбэков тоже нет выполненных вызовов.
  4. Когда операция чтения файла будет завершена, цикл событий выполнит ее коллбэк (в I/O фазе).
  5. Затем он перейдет в фазу проверки (setImmediate) и лишь затем – на новую итерацию, в фазу таймеров. Таким образом в I/O-коллбэках setImmediate всегда выполняется раньше, чем setTimeout(fn, 0).

Микротаски

        function main() {
  setTimeout(() => console.log('1'), 50);
  process.nextTick(() => console.log('2'));
  setImmediate(() => console.log('3'));
  process.nextTick(() => console.log('4'));
}

main();

    
Коллбэки метода process.nextTick() – это микрозадачи, которые имеют приоритет над всеми фазами. Они выполняются сразу же после того, как цикл событий завершит текущую операцию. То есть после каждого действия цикл проверяет очередь микротасков, и если в ней что-то есть – выполняет сразу все.

Итак, как работает этот код:

  1. Регистрирует коллбэки по соответствующим очередям.
  2. Выполняет два микротаска.
  3. Переходит в фазу таймеров, но вызов setTimeout с задержкой в 50 мс еще не готов.
  4. Выполняет коллбэк setImmediate в фазе проверки.
  5. Обратный вызов setTimeout выполняется уже на следующей итерации.
        2
4
3
1

    

Асинхронные микротаски

А что произойдет, если в метод process.nextTick передать асинхронную функцию?

        function main() {
  setTimeout(() => console.log('1'), 50);
  process.nextTick(() => console.log('2'));
  setImmediate(() => console.log('3'));
  process.nextTick(() => setTimeout(() => {
    console.log('4');
  }, 1000));
}

main();

    

Результат будет таким:

        2
3
1
4

    

Разберемся, откуда что взялось:

  1. Коллбэки регистрируются в соответствующих очередях.
  2. Выполняется первый синхронный микротаск – в консоль выводится 2.
  3. Начинает выполняться второй микротаск. Коллбэк setTimeout из него помещается в очередь фазы таймеров.
  4. После этого цикл событий начинает работать в обычном режиме, начиная с фазы таймеров.
  5. Время первого setTimeout (50 мс) еще не истекло, двигаемся дальше.
  6. Выполняется коллбэк setImmediate в фазе проверки – в консоль выводится 3.
  7. Начинается новая итерация, цикл возвращается в фазу таймеров. Тут остались две функции обратного вызова setTimeout, которые и выполнятся по очереди, выведя в консоль 1 и 4.

Все вместе

Разобравшись с фазами цикла событий и очередью микротасков, мы можем испытать свои знания на новом комплексном примере:

           const fs = require('fs');

   function main() {
    setTimeout(() => console.log('1'), 0);
    setImmediate(() => console.log('2'));
 
    fs.readFile('./xyz.txt', (err, buff) => {
     setTimeout(() => {
      console.log('3');
     }, 1000);

     process.nextTick(() => {
      console.log('process.nextTick');
     });

     setImmediate(() => console.log('4'));
    });
 
    setImmediate(() => console.log('5'));

    setTimeout(() => {
     process.on('exit', (code) => {
      console.log(`close callback`);
     });
    }, 1100);
   }

   main();

    

На гифке представлена схема работы цикла событий для этого примера:

Номера очередей на картинке – это номера строк обратных вызовов в коде
Номера очередей на картинке – это номера строк обратных вызовов в коде

Этот фрагмент кода выведет следущий результат:

        1
2
5
process.nextTick
4
3
close callback

    

Или вот такой – вспоминаем первый пример:

        2
5
1
process.nextTick
4
3
close callback

    

Определения

Микрозадачи

В Node.js (а точнее в движке V8) существует понятие микрозадач. Они являются именно частью движка, а не частью цикла событий. Кроме process.nextTick() к ним относится, например, метод Promise.resolve().

Микрозадачи имеют приоритет перед всеми другими задачами. Как только что-то попадает в очередь микрозадач – оно сразу же выполняется (после завершения текущей операции).

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

Макрозадачи

Такие задачи, как setTimeout, setInterval, setImmediate, requestAnimationFrame, I/O, рендеринг пользовательского интерфейса или другие функции обратного вызова относятся к макрозадачам. У них нет никаких приоритетов, а выполнение определяется фазой цикла событий.

Итерация (tick) цикла событий

Один тик цикла соответствует проходу по всем фазам и возвращение к началу. Он характеризуется частотой (количество итераций в единицу времени) и длительностью (время затраченное на одну итерацию).

***

Если вы тоже любите Node.js, у нас есть для вас несколько материалов:

Источники

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ