Как работает Node.js
- среда, 28 мая 2025 г. в 00:00:06
После прочтения этой статьи вы хорошо поймете следующее:
Понять, как JS работает "под капотом" в браузере (см. эту короткую 15-минутную статью), проще, чем понять устройство Node.js. В браузере нет сложных фаз и многослойных механизмов, как в Node.js.
Но почему в Node.js все устроено сложнее? Зачем столько фаз, которые так непросто понять?
Дело в том, что в браузере JS решает более простые задачи — обработка действий пользователя, таймеров и промисов. Для этого достаточно использовать обычную очередь задач (task queue) и очередь микрозадач (microtask queue), которые работают в связке с Web API.
Node.js — это не просто JS, а целая среда выполнения, предназначенная для работы с JS в любом месте и в любых условиях! В отличие от браузеров, здесь нет Web API, которые могли бы помочь. Эта среда должна работать на наших серверах, поэтому она также работает с файлами, сетевыми запросами и низкоуровневыми системными вызовами (syscalls). Из-за этого Node.js необходим более сложный цикл событий с несколькими фазами, позволяющий правильно обрабатывать все типы операций.
Node.js должен обеспечивать работу JS в любых условиях и при этом с высокой производительностью. Поэтому, помимо задач, которые обычно выполняются в браузерах, ему нужно обрабатывать дополнительные серверные события, которыми важно правильно управлять.
В то время как в браузерах такие вещи, как запросы fetch
, передаются в Web API, в Node.js все немного по-другому. В Node.js эквивалентом концепции "Web API" является библиотека на C/C++ под названием "libuv". Они не идентичны, но можно считать, что обе позволяют сделать JS асинхронным.
В этой статье я часто буду использовать термин "I/O". I/O означает ввод-вывод (Input-Output), и это относится к любым операциям, при которых данные поступают в систему или выходят из нее — например, чтение файла, отправка запроса или получение ответа.
Web API в браузерах:
libuv в Node.js:
Основное отличие: в то время как Web API работает в пределах браузера, libuv напрямую обращается к ресурсам операционной системы. Оба управляют асинхронными операциями, но действуют в разных средах.
Обработка запросов:
В обоих случаях основной поток JS не блокируется, но запросы обрабатываются на разных уровнях. В браузере процесс управляется его собственными ресурсами и процессами, а в Node.js — более прямым способом, с использованием механизмов операционной системы.
И снова, в обоих случаях операционная система является основным механизмом, который отслеживает и уведомляет о завершении операций I/O. Операционная система обнаруживает сетевые ответы, операции с файлами и другие события I/O с помощью аппаратных прерываний (hardware interrupts) и системных вызовов.
Теперь у вас есть общее представление о том, что Node.js работает иначе, чем браузер. Если что-то пока не совсем ясно — не переживайте, мы разберем все поэтапно, начиная с самых основ.
Среда выполнения Node.js — это программа на C++, которая принимает на вход JS-файл, читает его и интерпретирует. Код JS выполняется построчно, поочередно, пока не завершится.
Но в реальности все гораздо сложнее. JS-код не просто читается и выполняется построчно. Бывают ситуации, которые требуют особого подхода. Например, мы можем захотеть выполнить часть кода только после того, как пройдет какое-то время (например, X мс). Пока это время не истечет, нужно подождать. Или, допустим, мы читаем файл из внешнего источника — код должен дождаться окончания этого процесса, прежде чем продолжить выполнение. Или другой пример — наш код слушает входящие подключения на определенном порту. Пользователь может подключиться в любой момент, и мы не знаем, когда он отправит запрос. Но как только это произойдет, нужно сразу среагировать.
В таких случаях простое последовательное выполнение строк кода уже не подходит.
Поэтому нам нужно что-то вроде цикла, который будет проверять, происходят ли какие-то события. Когда они происходят, код должен немедленно выполняться.
Вот почему существует так называемый цикл событий (event loop).
Прежде чем углубляться в цикл событий, нужно понять еще один термин — callback. Функции обратного вызова — это функции, которые вызываются при наступлении определенного события. Например, когда устанавливается таймер, вместе с ним указывается обработчик, который будет выполнен после завершения отсчета. Или, скажем, нужно прочитать файл, и после того как чтение завершится, нужно вызвать определенную функцию. Название callback буквально означает "обратный вызов" — функция "вызывается" в тот момент, когда событие произошло.
Цикл событий — это цикл с разными фазами. Каждая фаза имеет очередь функций обратного вызова, и код завершится, когда в этих очередях не останется функций. Пока не беспокойтесь о деталях — мы подробно разберем каждый этап цикла событий, и все станет ясно.
Цикл событий — это механизм, который позволяет Node.js выполнять неблокирующие операции I/O, несмотря на то, что JS изначально однопоточный. Это достигается за счет передачи сложных операций ядру операционной системы, когда это возможно.
При запуске основного кода он выполняется построчно. Если встречается асинхронная операция (например, чтение файла, сетевое соединение или таймер), Node.js просто регистрирует соответствующий обработчик, но не выполняет его сразу. Этот обработчик будет вызван позже, когда соответствующее событие произойдет. Причем обработчики могут регистрировать другие обработчики, создавая цепочку зависимостей. Например, мы можем попросить прочитать файл через 5 секунд, а когда обработчик выполнится, он может запланировать еще один обработчик. Поэтому Node.js продолжает выполнять код до тех пор, пока очереди обработчиков не опустеют.
Процесс выглядит так: выполнение -> проверка очереди обработчиков -> выполнение -> проверка очереди обработчиков… и т.д., пока не останется незавершенных обработчиков.
Важно помнить, что цикл событий начинается только после завершения выполнения начального кода. Выполнение этого начального кода тоже можно считать отдельной фазой.
Теперь давайте разберемся, какие именно фазы проходит код и что происходит на каждом этапе.
Сначала выполняется код основного модуля (main module). На этом этапе еще нет цикла событий — это начальная фаза.
Начальная фаза выполняется только один раз в самом начале работы программы.
В основном модуле весь код выполняется синхронно. Обработчики еще не выполняются, а сам цикл событий еще не запущен. Мы можем зарегистрировать обработчики, но они не будут выполнены на этом этапе. Основной модуль = начальная фаза.
const x = 1;
const y = x + 1;
setTimeout(() => console.log("Должно выполниться через 1 мс"), 1);
for (let i = 0; i < 10000000; i++);
console.log("Когда это сообщение будет выведено?");
Как вы думаете, какой будет результат? Если вы внимательно читали статью, то, возможно, уже догадались.
Когда это сообщение будет выведено?
Должно выполниться через 1 мс
Это основной модуль, и на этом этапе обработчики не выполняются. Это начальная фаза и начальное выполнение кода.
Чаще всего (при использовании require
) другие модули загружаются еще до начала выполнения начальной фазы. Если эти модули зависят от других, их загрузка будет ждать загрузки всех необходимых субмодулей.
Очень важно, чтобы начальная фаза была как можно короче. Чем быстрее начнется выполнение кода, тем лучше производительность. Поэтому использовать слишком много модулей в коде — не лучшая практика.
Технически, поскольку цикл событий еще не стартовал на начальной фазе (основном модуле), можно не считать ее "фазой". Но теперь цикл заработает! И первая фаза цикла — это фаза таймеров. С ее началом мы переходим к циклу событий, о котором говорили ранее. Теперь рассмотрим каждый шаг, и это будут следующие этапы:
Каждый из этих блоков мы будем называть "фазой" цикла событий.
Каждая фаза имеет свою очередь обработчиков в порядке FIFO (first in — first out — первым вошел — первым вышел). Когда цикл событий входит в очередную фазу, он выполняет операции, связанные с этой фазой, и затем выполняет все обработчики из очереди до тех пор, пока она не станет пустой или не будет достигнут лимит на количество выполняемых обработчиков. Когда очередь пуста или лимит достигнут, цикл событий переходит к следующей фазе.
Такой многократный подход позволяет эффективно приоритизировать различные типы асинхронных операций. Это особенно важно для серверных приложений, которые обрабатывают множество операций I/O, таких как доступ к файловой системе, сетевые запросы и запросы к базам данных. В то время как более простая модель браузера подходит для взаимодействия с пользовательским интерфейсом, модель Node.js с ее фазами оптимизирована для серверных задач, где различные типы операций I/O требуют разного приоритета обработки.
Перед тем как подробно разбирать каждую фазу, стоит взглянуть на общий обзор фаз цикла событий, чтобы понять, как все устроено в целом.
Цикл событий в Node.js постоянно проходит через шесть основных фаз в строго определенном порядке. Этот непрерывный процесс обеспечивает корректное выполнение всех видов асинхронных операций: на каждой фазе цикл событий проверяет наличие ожидающих задач, обрабатывает их и только затем переходит к следующей фазе, возвращаясь к началу после завершения полного цикла.
Последовательность этих фаз играет ключевую роль. Это не просто случайно выбранные этапы — каждая фаза имеет свое четко определенное место по вполне конкретным причинам. В процессе изучения вы поймете, почему одни фазы идут раньше, а другие — позже.
setTimeout()
и setInterval()
.Эта фаза является одной из самых длительных, так как именно здесь Node.js активно ожидает и обрабатывает внешние события, такие как сетевые запросы, операции с файлами и др.
setImmediate()
. Эта функция позволяет запустить код сразу после завершения фазы poll, но до начала следующего цикла. Это удобно, когда нужно выполнить задачу после всех завершенных операций I/O, но перед запуском новых таймеров.close
сокета будет выполнен на этом этапе.Не волнуйтесь, если пока не все понятно. В дальнейшем мы подробно разберем каждую фазу. А когда дочитаете статью до конца, обязательно вернитесь к этому разделу — вы будете приятно удивлены, сколько нового успели понять.
Фаза таймеров начинается сразу после начальной фазы. Именно здесь цикл событий фактически инициализируется. Эту фазу обрабатывает встроенная библиотека libuv.
На этой фазе обработчики таймеров планируются и сортируются по заданной задержке.
Однако важно понимать, что точность этих таймеров не всегда идеальна. Они могут быть задержаны операционной системой или другими фазами цикла. Например, если в начальной фазе выполняется тяжелая операция, таймеру придется дождаться ее завершения, даже если время ожидания было установлено всего в 10 мс. На практике такой таймер может сработать через секунду или даже позже. Поэтому говорят, что таймеры в Node.js не гарантируют точное время выполнения.
Давайте посмотрим на простой пример, чтобы понять, как это работает. Начнем с базовой функции setTimeout
:
setTimeout(timerCallback, 100, "100 ms", 100);
Когда мы используем функцию setTimeout
в Node.js, первым аргументом передается функция, которая будет выполнена после указанного времени задержки (второй аргумент, в мс). Третий и последующие аргументы передаются в функцию timerCallback
в качестве параметров:
const timerCallback = (a, b) =>
console.log(`Timer ${a} delayed for ${Date.now() - start - b}`);
const start = Date.now();
setTimeout(timerCallback, 500, "500 ms", 500);
setTimeout(timerCallback, 0, "0 ms", 0);
setTimeout(timerCallback, 1, "1 ms", 1);
setTimeout(timerCallback, 1000, "1000 ms", 1000);
for (let i = 0; i <= 1000000000; i++);
Аргументы "a" и "b" будут соответствовать третьему и четвертому параметрам функции setTimeout
.
В этом примере мы пытаемся узнать, насколько сильно задержались наши таймеры. Сначала мы сохраняем текущее время в переменную start
, чтобы зафиксировать момент начала работы начальной фазы. Затем внутри функции timerCallback
мы используем Date.now()
, чтобы узнать точное время, когда функция была выполнена.
Логика простая: если мы вычтем start
из значения Date.now()
в момент выполнения функции, то должны получить 500 мс
для первого таймера, 0 мс
для второго, 1 мс
для третьего и 1000 мс
для четвертого, так как они должны сработать через заданное количество мс (второй аргумент setTimeout
). То есть: время выполнения функции — время начала = время, прошедшее до выполнения функции.
При этом значение b показывает задержку в мс, выражение Date.now() - start - b
в идеале должно быть равно нулю.
Но это только в теории. На практике таймеры почти всегда задерживаются. Причины могут быть разные: операционная система, другие фазы цикла событий или даже сама начальная фаза.
Не забывайте, что перед запуском цикла событий происходит начальная фаза, в которой функции регистрируются, но не вызываются. И если в этой фазе выполняются тяжелые операции (например, большой цикл for
), они могут значительно сдвинуть момент запуска таймеров.
Поэтому реальный результат будет выглядеть примерно так:
Таймер 0 мс задержан на 437 мс
Таймер 1 мс задержан на 442 мс
Таймер 500 мс задержан на 2 мс
Таймер 1000 мс задержан на 2 мс
Можно сказать, что наша тяжелая операция в начальной фазе заняла примерно 437 мс
, так как таймер, который должен был сработать сразу (через 0 мс
), сработал только через 437 мс
. Из-за этого задержался и второй таймер с задержкой в 1 мс
, ведь Node.js однопоточный, и все задачи выполняются последовательно.
Однако его задержка составила не 438 мс
, как можно было бы ожидать, а 442 мс
— даже на несколько мс больше. Это еще раз подтверждает, что таймеры в Node.js не всегда точны.
Теперь переходим к следующей фазе цикла событий — Pending Callbacks.
Эта фаза отвечает за выполнение функций обратного вызова, которые были отложены на предыдущих итерациях цикла, чаще всего связанных с операциями I/O (например, обработка ошибок TCP).
На этом этапе происходит следующее:
По сути, эта фаза выполняет роль "очистки" — здесь Node.js обрабатывает задачи, которые не удалось выполнить в предыдущих циклах.
Как уже упоминалось, на каждой итерации цикла событий эта фаза занимается отложенными задачами, перенесенными с предыдущих итераций. То есть, если обработчик был отложен, он будет выполнен на фазе Pending Callbacks следующей итерации.
Представьте, что вы пишете веб-сервер, который должен обрабатывать множество подключений:
const http = require("http");
const fs = require("fs");
// Создаем сервер
const server = http.createServer((req, res) => {
// Чтение файла (операция I/O)
fs.readFile("large-file.txt", (err, data) => {
if (err) {
// Если произошла ошибка, эта функция обратного вызова может быть
// отложена до фазы Pending Callbacks (будет выполнена на следующей итерации)
console.error("Error reading file:", err);
res.statusCode = 500;
res.end("Server error");
return;
}
res.statusCode = 200;
res.end(data);
});
});
// Обработка ошибок TCP-соединения
server.on("error", (err) => {
// Эта функция обратного вызова, скорее всего, будет обработана на фазе Pending Callbacks
console.error("Server error:", err);
});
server.listen(3000, () => {
console.log("Server running on port 3000");
});
В этом примере, если при запуске сервера произойдет ошибка TCP или при чтении файла возникнут проблемы, соответствующие обработчики могут выполниться не сразу, а на фазе Pending Callbacks.
Важно понимать суть этой фазы:
Если возникает ошибка, ее обработчик не выполняется мгновенно, а откладывается до следующей итерации цикла событий.
Но что если бы ошибки обрабатывались сразу? Это важный вопрос, связанный с архитектурными особенностями Node.js. Если бы мы пытались обрабатывать все ошибки мгновенно, в их исходной фазе (например, в фазе Poll), а не откладывали их на фазу Pending Callbacks, возникло бы несколько серьезных проблем:
Но важно понимать, что когда мы откладываем выполнение функции обратного вызова на фазу Pending Callbacks, это не значит, что время на ее обработку исчезает — мы просто переносим ее на другой этап цикла событий. Зачем же тогда это нужно? Ведь такая задержка все равно может блокировать цикл событий при следующей итерации, не так ли?
Перенос части обработчиков на фазу Pending Callbacks позволяет Node.js контролировать момент выполнения потенциально тяжелых операций в цикле событий. Это обеспечивает более предсказуемую работу системы. Фаза Pending Callbacks находится сразу после таймеров и перед фазой Poll, поэтому:
Node.js и библиотека libuv — основа цикла событий Node.js, обеспечивающая кроссплатформенный доступ к асинхронным операциям I/O. Они устанавливают лимиты на количество отложенных функций обратного вызова, которые обрабатываются за одну итерацию. Если их слишком много, часть переносится на следующую итерацию. Это важно, потому что:
// Внутри libuv может происходить что-то вроде
while (pendingQueue.length > 0 && processedCallbacks < MAX_CALLBACKS_PER_ITERATION) {
const callback = pendingQueue.shift();
callback();
processedCallbacks++;
}
Это не дает фазе Pending Callbacks полностью занять одну итерацию цикла событий.
Таким образом, каждая фаза имеет свой механизм контроля, который не позволяет блокировать цикл событий. Если "определенные операции" выполняются на "правильной фазе", это значительно облегчает управление процессом и повышает общую производительность.
Существуют исключения из общего порядка обработки функций обратного вызова при работе с TCP-соединениями в Node.js. Иногда обработчики ошибок могут выполняться раньше, чем обработчики успешного выполнения, и это связано с особенностями работы цикла событий.
Если подключение неудачное, оно часто завершается очень быстро — например, если сервер не существует или порт закрыт, система определяет это практически мгновенно. В таком случае обработчик ошибок может выполниться сразу, минуя фазу Pending Callbacks.
С другой стороны, в некоторых ситуациях обработчики ошибок могут срабатывать позже (после обработчиков успешного выполнения). Это часто происходит при ошибках времени ожидания: если попытка подключения не получает ответа, необходимо дождаться истечения таймаута (который может длиться несколько секунд), прежде чем будет вызван обработчик ошибки. В таких случаях вы увидите, что обработчики успешного выполнения срабатывают раньше обработчиков ошибок.
Это фаза, которая идет сразу после Pending Callbacks — третья (четвертая с учетом начальной) фаза в цикле событий Node.js. Она служит внутренним нуждам самой платформы и не предназначена для прямого взаимодействия с пользователем.
Фаза состоит из двух этапов: Idle и Prepare.
Idle — это этап "простоя", когда Node.js выполняет фоновые задачи. Здесь особо нечего обсуждать, так как он просто запускается на каждой итерации цикла.
Этап Prepare — более важный. Он происходит сразу после Idle и перед фазой Poll. На этом этапе цикл событий готовится к предстоящим событиям и выполняет определенные запланированные обработчики.
Основная задача Prepare — выполнить те обработчики, которые должны сработать до начала опроса I/O на фазе Poll. Это ключевой момент, позволяющий Node.js подготовиться к потенциальному ожиданию I/O без блокировок.
Функции обратного вызова на этом этапе — внутренние, а не пользовательские. Например, здесь запускаются таймеры или настраиваются сетевые обработчики. Этот этап гарантирует, что все подготовлено, прежде чем цикл событий перейдет к ожиданию новых событий.
Пример запуска TCP-сервера:
const net = require('net');
const server = net.createServer((socket) => {
socket.end('Hello world\n');
});
server.listen(3000);
Перед тем как цикл событий перейдет к фазе Poll, Node.js может выполнить некоторые внутренние настройки на этапе Prepare. Это необходимо для того, чтобы сервер был готов принимать новые подключения. Речь идет не о пользовательских функциях обратного вызова, а о внутренних операциях, которые Node.js использует для управления базовым поведением системы.
Эта фаза, вероятно, самая важная в цикле событий Node.js, поскольку именно здесь обрабатывается основной поток I/O.
На фазе Poll происходит два ключевых процесса:
Например, когда пользователь заходит на ваш сайт или когда вы читаете файл, Node.js отправляет эти запросы операционной системе и ждет их завершения. Фаза Poll — это момент, когда Node.js проверяет результаты этих операций.
onRead
: "логирует" данные, когда файл был прочитанonConnected
: выполняет действия при установлении соединенияonListen
: запускает определенный код, когда сокет начинает прослушивать подключенияДинамические импорты (использующие import()
) также обрабатываются на этой фазе.
Есть важный момент, который нужно понять: Node.js может блокироваться на фазе Poll. Это означает, что цикл событий может "застревать" здесь в ожидании I/O событий.
Когда приложению больше нечего обрабатывать, оно остается на фазе Poll, ожидая новых обработчиков I/O. Такая блокировка продолжается до тех пор, пока не произойдет одно из следующих событий:
setImmediate()
Такое поведение — намеренная особенность Node.js. Оно позволяет эффективно "ждать" новые задачи, не тратя лишние ресурсы на постоянное прохождение всех фаз цикла событий, когда делать нечего.
При этом важно понимать, что блокировка на фазе Poll не означает, что все приложение "замораживается". Это скорее "правильная блокировка" — эффективное ожидание следующей задачи.
Нужно быть осторожными с операциями, которые сильно нагружают процессор и выполняются в обратных вызовах этой фазы. Например:
// Это может заблокировать весь цикл событий, если файл слишком большой
fs.readFile('large-file.txt', (err, data) => {
// Ресурсоемкая операция в обработчике
const result = performComplexCalculation(data);
console.log(result);
});
Для ресурсоемких задач можно использовать:
- рабочие потоки (worker threads) — это выходит за рамки данной статьи, но вы можете изучить их самостоятельно
- дочерние процессы (child processes) — тоже на самостоятельное изучение
- разделение работы на небольшие части с помощью
setImmediate()
— о ней мы поговорим в следующем разделе
На фаза Poll Node.js большую часть времени обрабатывает операции I/O, такие как:
В отличие от фаз Idle и Prepare, фазу Check мы, как пользователи, можем контролировать — именно здесь можно запланировать выполнение функций обратного вызова. Эта фаза запускается сразу после Poll, то есть сразу после операций I/O.
Для планирования обработчиков на фазу Check используется функция setImmediate()
. Ее применяют, когда нужно получить предсказуемый порядок выполнения — функция сработает сразу после фазы Poll.
Допустим, у нас есть такой код:
const readFileCallback = (err, data) => {
console.log(`readFileCallback ${data}`);
};
const f = "test.txt";
fs.readFile(f, readFileCallback);
setImmediate(() => console.log("setImmediate called!"));
Разберем, что происходит при выполнении этого кода.
Сначала идет начальная фаза, верно? На этой фазе мы регистрируем все необходимое, но файл еще не читается, и функция обратного вызова не вызывается — это происходит на фазе Poll. Т.е. мы планируем операции (чтение файла и вызов обработчика) к выполнению на фазе Poll.
Мы все еще находимся в начальной фазе и продолжаем разбирать код и распределять задачи по соответствующим фазам.
Встречается функция setImmediate()
. Что она делает? Она планирует выполнение своего обработчика на фазу Check.
Начальная фаза завершается, переходим к фазе Timers — там ничего не происходит, т.к. таймеров у нас нет. Переходим к фазе Pending Callbacks — тоже ничего нет, двигаемся дальше. Затем следуют фазы Idle и Prepare — Node.js выполняет внутренние задачи, к нам это не относится.
Наконец, наступает фаза Poll.
На фазе Poll происходят две основные вещи: сначала запускается чтение файла, затем должен выполниться соответствующий обработчик. Но прежде чем вызвать обработчик, нужно инициировать операцию чтения файла. После запуска операции чтения Node.js сразу же переходит к следующей фазе, потому что обработчик, запланированный через setImmediate()
, ожидает выполнения в фазе Check. Node.js не хочет тратить время и блокировать цикл событий, дожидаясь завершения чтения файла и выполнения обработчика.
Поэтому цикл событий переходит в фазу Check, где выполняется обработчик setImmediate()
и выводится сообщение setImmediate called!
. После этого цикл возвращается к фазам Timers и Poll. Когда мы снова попадем в фазу Poll, предположим, что чтение файла уже завершилось. Тогда на этой второй итерации цикла вызывается обработчик, связанный с завершением операции чтения файла.
Вот что происходит на самом деле:
Node.js переходит на фазу Poll и обращается к операционной системе с запросом: "Какие операции I/O завершились?". Для каждой завершенной операции он сразу же выполняет связанный с ней обработчик. Если в фазе Poll нет ожидающих обработчиков, НО при этом есть запланированные через setImmediate()
задачи, то Node.js выйдет из фазы Poll, чтобы обработать их.
Таким образом, в фазе Poll Node.js выполняет две основные задачи: проверяет завершенные операции I/O и запускает соответствующие обработчики. Но важно понимать, что он не ждет завершения каждой операции I/O по отдельности, а просто периодически проверяет, что уже завершилось с момента последней проверки.
Есть важный момент! Если файла не существует, то мы не сможем инициировать операцию чтения, и в этом случае функция обратного вызова будет выполнена немедленно. Это значит, что setImmediate()
не будет выполнен первым. И это логично: если файл отсутствует, то тяжелой операции чтения не будет, а значит, нам не нужно блокировать цикл событий, ожидая завершения чтения. В этом случае нет необходимости сначала переходить к фазе Check.
Попробуйте выполнить приведенный ниже код и посмотрите, каким будет результат.
Файл test.txt
существует:
const fs = require(`fs`);
const readFileCallback = (err, data) => {
console.log(`readFileCallback ${data}`);
};
const f = "test.txt";
fs.readFile(f, readFileCallback);
setImmediate(() => console.log("setImmediate called!"));
Вывод будет таким:
setImmediate called!
readFileCallback data
Файл test123.txt
не существует:
const fs = require(`fs`);
const readFileCallback = (err, data) => {
console.log(`readFileCallback ${data}`);
};
const f = "test123.txt";
fs.readFile(f, readFileCallback);
setImmediate(() => console.log("setImmediate called!"));
Вывод будет таким:
readFileCallback undefined
setImmediate called!
После того как Node.js обработал все таймеры, функции обратного вызова I/O, внутренние операции и функции setImmediate()
, остается еще один финальный этап — фаза Close Callbacks.
Эта фаза необходима для окончательной "уборки" перед завершением обработки событий. При закрытии сервера или разрыве соединения с сокетом Node.js необходимо корректно завершить все связанные с этим процессы.
Фаза Close Callbacks отвечает за выполнение следующих задач:
server.close()
socket.on('close', ...)
Например, если вы создали объект net.Socket
и добавили к нему обработчик события .on('close', ...)
, этот обработчик не выполнится сразу при закрытии соединения, а дождется этой особой фазы.
Рассмотрим пример:
const net = require("net");
const server = net.createServer((socket) => {
console.log("Клиент подключился");
socket.on("close", () => {
console.log("Сокет закрыт — этот код выполняется на фазе Close Callbacks");
});
// Закрываем сокет через 2 секунды
setTimeout(() => {
socket.end(); // Запускает событие 'close'
}, 2000);
});
server.listen(3000, () => {
console.log("Сервер запущен и слушает порт 3000");
});
Вот что происходит:
close
.on('close', ...)
Только на этой фазе выполняется функция, привязанная к .on('close', ...)
.
Таким образом, последний console.log()
не сработает сразу после вызова .end()
, а выполнится только тогда, когда Node.js дойдет до фазы Close Callbacks.
Мы рассмотрели все фазы, но осталось еще кое что, о чем мы не говорили. Это своего рода "скрытая" фаза — речь идет о process.nextTick()
.
Как видно на изображении, process.nextTick()
выполняется после каждой фазы. Звучит неожиданно, правда? На самом деле цикл событий выглядит примерно так: timers -> process.nextTick()
-> pending callbacks -> process.nextTick()
и т.д.
На самом деле, process.nextTick()
запускается даже раньше, чем Timers. Потому что перед циклом событий есть начальная фаза. И process.nextTick()
также срабатывает сразу после нее.
Возможно, вы заметили, что process.nextTick()
не был показан на первой схеме цикла событий, хотя это часть асинхронного API. Это потому, что технически process.nextTick()
не является частью цикла событий. nextTickQueue
обрабатывается сразу после завершения текущей операции, вне зависимости от текущей фазы цикла.
process.nextTick()
— очень мощный инструмент. Если вспомнить, перед циклом событий есть "начальная фаза" — основной модуль (main module). После ее завершения начинается сам цикл событий. Поэтому nextTick()
можно использовать для определения момента завершения начальной фазы. Можно было бы запустить таймеры, чтобы увидеть, когда стартует цикл событий, ведь Timers — это первая фаза цикла. Но таймеры могут работать с задержкой, а nextTick()
дает более точные результаты.
Рассмотрим несколько примеров.
console.log("start");
for (let i = 0; i < 1000000; i++);
console.log("end");
setTimeout(() => console.log("timer"), 0);
process.nextTick(() => console.log("nextTick"));
Какой будет результат?
start
end
nextTick
timer
Почему так? Потому что сразу после начальной фазы запускается обработчик nextTick()
, и только после этого начинается цикл событий, то есть фаза Timers.
let val;
function test() {
console.log(val);
}
test();
val = 1;
Весь этот код синхронный — ничего сложного. Сначала мы создаем переменную val
, но она еще не инициализирована. Затем, до того как присвоить val
значение 1
, мы вызываем функцию, которая выводит val
— а на тот момент val
все еще не определена, поэтому в консоль выводится undefined
. Но что, если исправить это очень интересным способом?
let val;
function test() {
console.log(val);
}
process.nextTick(test);
val = 1;
А теперь, как думаете, что произойдет? process.nextTick()
не выполнится в рамках начальной фазы, но сработает сразу после нее. Значит, он выполнится уже после того, как переменной val
будет присвоено значение 1
. Поэтому теперь в консоли появится 1
.
Есть две главные причины использовать process.nextTick()
:
И еще одна важная деталь — обещаю, что последняя: в Node.js промисы работают в очереди микрозадач (microtask queue), но с меньшим приоритетом, чем обработчики process.nextTick()
. Когда промис разрешается, его обработчики .then()
ставятся в очередь микрозадач, которая выполняется после всех nextTick-обработчиков, но до перехода цикла событий к следующим фазам (таймерам или операциям I/O). Такая приоритизация обеспечивает предсказуемый порядок выполнения цепочек промисов и сохраняет неблокирующую природу Node.js.
Если вы знакомы с тем, как JS работает в браузере, то для Node.js можно провести следующие аналогии:
process.nextTick()
в Node.jsВажное отличие Node.js: операции process.nextTick()
обрабатываются раньше других промисов из очереди микрозадач и проверяются в конце каждой фазы.
Думаю, теперь у вас есть полное понимание того, как работает Node.js.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩