- Основные определения
- Асинхронность в NodeJS
- Стек вызовов
- Цикл событий NodeJS
- Библиотека libuv в деталях
- Аргументы process.argv
NodeJS — асинхронная, управляемая событиями (event-driven) серверная среда выполнения JavaScript (runtime environment), построенная на JavaScript-движке V8, который используется в Google Chrome.
В NodeJS используется кроссплатформенная библиотека libuv
, нацеленная на асинхронный ввод-вывод. При помощи libuv
выполняются операции ввода-вывода, которые считаются слишком трудоёмкими для NodeJS.
Несмотря на то, что NodeJS является однопоточным, он может использовать возможности нескольких ядер при помощи дочерних процессов из модуля child_process
и модуля cluster
.
Событие (Event) — действие, сгенерированное пользователем или системой.
Примеры событий: успешное завершение операции, ошибка, нажатие на клавишу или кнопку мыши, истечение времени таймера и так далее.
/* действия пользователя */
input.onchange = event => { /* ... */ };
button.onclick = event => { /* ... */ };
/* успех и ошибки */
window.onload = event => { /* ... */ };
window.onerror = event => { /* ... */ };
Событийно-ориентированное программирование (Event-driven programming) — парадигма, в которой управление программой (flow control) определяется событиями.
В событийно-ориентированной программе помимо событий должны присутствовать слушатели событий (event listeners), которые будут реагировать на наступление событий какими-то действиями. В контексте JavaScript — вызовами функций обратного вызова.
NodeJS предоставляет класс EventEmitter
для создания пользовательских событий.
const EventEmitter = require('events');
class MyEmitter extends EventEmitter {}
const emitter = new MyEmitter();
/* слушатель события */
emitter.on('myevent', () => console.log('myevent occurred!'));
/* генерация события */
emitter.emit('myevent');
Стоит отметить, что метод EventEmitter
работает синхронно. Поэтому важно, чтобы подписка на событие on()
находилась в коде раньше его генерации emit()
, иначе событие не обработается.
Функция обратного вызова, колбэк (англ. callback, cb) — функция, которую передают аргументом в другую функцию.
Функция обратного вызова может быть вызвана в любое время, когда это будет необходимо.
Функции обратного вызова могут быть использованы как алгоритмы для решения задач, имеющих несколько способов решения. Одной из таких задач является сортировка.
Например, в JavaScript метод Array.prototype.sort(compareFunction)
принимает функцию обратного вызова compareFunction
, использующуюся как алгоритм сравнения двух элементов массива.
const compareFunction = (a, b) => a - b;
[1, 3, 2].sort(compareFunction); // [1, 2, 3]
Аналогично работают методы find
, filter
(поиск, фильтрация по заданному алгоритму) и многие другие.
Но чаще всего функции обратного вызова используются как какой-то набор инструкций, который нужно выполнить после совершения какого-то действия или наступления события. Использование колбэков позволяет убедиться, что код внутри них выполнится не раньше, чем асинхронная функция завершит своё выполнение.
Именно такой вариант использования колбэков является ключом к асинхронности в NodeJS.
Сам подход появился в функциональном программировании ещё задолго до появления NodeJS и назывался Continuation-passing Style (CPS) и подразумевал, что управление (какой-то набор инструкций) передавалось далее в форме продолжения (continuation). То есть одна функция могла принять другую функцию — функцию продолжения (continuation function). По завершению своей работы вместо возвращения стандартного результата она вызывала функцию продолжения, передавая ей результат в качестве аргумента.
Большинство методов NodeJS сейчас использует колбэки. Когда асинхронная, блокирующая операция поступает на вход и запускается, колбэк сохраняется в памяти и его выполнение откладывается, последующий код может продолжить своё выполнение. Как только основной код и асинхронная функция завершают своё выполнение, колбэк достаётся из памяти и выполняетсся в том же потоке, в котором выполнялся основной код. Такой подход весьма эффективен, поскольку значительно увеличивает пропускную способность и скорость NodeJS-приложений.
Пример асинхронной функции setTimeout(callback, delay)
, принимающей колбэк, который вызовется не ранее, чем через delay
миллисекунд.
setTimeout(() => console.log('Notes'), 300);
// выведется строка 'Notes' не ранее, чем через 300мс
Большинство асинхронных методов в NodeJS следуют идиоматическому (свойственному языку) паттерну Error-first callback (сперва ошибка), позволяющий довольно просто узнать, произошла ли ошибка при выполнении операции.
Если паттерн не используется, пользователь самостоятельно должен по аргументам колбэка выяснять, была ли ошибка или нет, что значительно сложнее.
Идея паттерна заключается в следующем
- Функция обратного вызова передаётся последним аргументом в метод, производящий некоторую асинхронную операцию.
- Если операция завершается ошибкой, первым аргументом функции обратного вызова станет экземпляр
Error
. - Если операция завершается успешно, первым аргументом станет
null
, а результат операции (данные) будет передан последующими аргументами аргументом (чаще всего только вторым аргументом).
const asyncFn = (param1, param2, /* ... */, callback) => {
if (/* error occurs */) {
return callback(new Error('reason'));
}
/* ... */
const data = { /* ... */ };
callback(null, data, /* ... */);
}
const callback = (err, data, /* ... */) => { /* ... */ };
asyncFn(1, 2, /* ... */, callback);
Обработка ошибок и данных на примере асинхронного чтения файла
const fs = require('fs');
const readFileCallback = (err, data) => {
if (err) {
console.error('Read file error occured: ', err);
/* обработать ошибку здесь */
} else {
console.log(data);
/* обработать данные здесь */
}
};
/* асинхронное чтение файла */
fs.readFile('/* ... */', readFileCallback);
Попытка выбросить ошибку err
из функции обратного вызова является распространённой ошибкой, поскольку к моменту вызова readFileCallback
код вокруг fs.readFile
завершит своё выполнение. Ошибка не попадёт в try..catch
и, скорее всего, приведёт к краху всего приложения.
const readFileCallback = (err, data) => {
if (err) {
throw err; // так лучше не делать
} else {
console.log(data);
}
};
try {
fs.readFile('/* ... */', readFileCallback);
} catch (err) {
/* ошибка сюда не попадёт */
console.log(err);
}
Аналогичная ситуация с данными data
. Их возврат при помощи return
ничего не даст. Функция fs.readFile
просто вызовет операцию чтения, но код не будет ждать её выполнения и пойдёт дальше. Поэтому возвращённое значение будет undefined
.
const readFileCallback = (err, data) => {
if (err) {
console.error('Read file error occured: ', err);
} else {
console.log(data);
return data;
}
};
const data = fs.readFile('/* ... */', readFileCallback);
console.log(data); // undefined
Как мы уже выяснили, использование кобэков помогает сделать код асинхронным.
Операции, которые должны выполниться после асинхронной функции, должны быть переданы в её аргументы колбэком, иначе правильная последовательность выполнения не гарантируется.
Если несколько асинхронных операций должны выполниться последовательно, появляется цепочка (каскад) вложенных колбэков, своеобразное ветвление кода.
Например, асинхронное последовательное создание, чтение, удаление файла выглядит следующим образом.
const fs = require('fs');
fs.writeFile('./input.txt', 'Hello!', 'utf8', (writeErr) => {
if (writeErr) {
console.error('White file error occured: ', writeErr);
} else {
fs.readFile('./input.txt', 'Hello!', 'utf8', (readErr, data) => {
if (readErr) {
console.error('Read file error occured: ', readErr);
} else {
console.log(data.toString());
fs.unlink('./input.txt', (removeError) => {
if (removeError) {
console.error('Remove file error occured: ', readErr);
}
});
}
});
}
});
Такое ветвление называют Callback Hell, а в других языках программирования можно встретить аналогичное понятие Pyramid of doom. Чем больше последовательных асинхронных операций, тем сложнее работать с таким кодом.
На сегодняшний день эффективным решением проблемы является использование Promise
и async..await
До их появляения разработчики пытались уменьшить негативное влияние Callback Hell, вынося функции обратного вызова в переменные или даже в отдельные модули.
Если вынести функцию не удаётся (например, в неё используются переменные из замыкания), можно использовать именованные функции вместо анонимных. Это особенно может упростить понимание кода, когда асинхронная функция принимает несколько функций обратного вызова.
fs.writeFile('./input.txt', 'Hello!', 'utf8', function writeFileCallback(err) {
/* ... */
});
Использование паттерна Error-first callback также помогает бороться с Callback Hell: в каждой функции обратного вызова обрабатывается именно свойственная ей ошибка. В противном случае довольно трудно было бы понять, где ошибка возникла.
Есть другой способ разрешения Callback Hell: промиссификация асинхронных функций и объединение их в цепочку промиссов (promise chaining).
Promise не заменяет функции обратного вызова совсем (они всё ещё передаются в then
и catch
), но код читать становится намного проще.
Например, промиссифицируем асинхронные функции создания, чтения и удаления файла.
const fs = require('fs');
/* промиссификация вручную */
const writeFile = (filePath, fileData, encoding) => new Promise((resolve, reject) => {
fs.writeFile(filePath, fileData, encoding, (err, data) => err ? reject(err) : resolve(data));
});
/* промиссификация с помощью promisify */
const promisify = fn => (...args) => new Promise((resolve, reject) => {
const callback = (err, data) => err ? reject(err) : resolve(data);
fn(...args, callback);
});
const readFile = promisify(fs.readFile);
const removeFile = promisify(fs.unlink);
writeFile('input.txt', 'Notes', 'utf-8')
.then(() => readFile('input.txt', 'utf-8');
.catch(writeErr => console.error('Write file error occured: ', writeErr))
.then(() => removeFile('input.txt')
.catch(readErr => console.error('Read file error occured: ', readErr))
.catch(removeErr => console.error('Remove file error occured: ', readErr));
Начиная с Node v11.0.0 можно использовать встроенные промиссы.
const fs = require('fs').promises;
fs.readFile('input.txt')
.then(() => { /* ... */ })
.catch(() => { /* ... */ });
Самым современным способом решения Callback Hell, который вообще исключает использование функций обратного вызова, является async..await
.
Эта конструкция позволяет писать асинхронный код в синхронном стиле, поскольку под капотом оборачивает его в промиссы.
await
дожидается выполнения промисса или цепочки промиссов (вызывает .then
до тех пор, пока не вернётся ошибка или примитивное значение). Использование await
доступно только внутри async
.
async
оборачивает результат выполнения функции или метода в промисс.
const asyncFn = async () => {
try {
const data = await readFile('input.txt');
await removeFile('input.txt');
} catch (e) {
console.log(e);
}
};
asyncFn(); // Promise
При использовании async..await
ошибка или данные так же, как и в случае с функциями обратного вызова, не могут быть возвращены в код, в котором была вызвана функция: в данном случае они не покидают промисс.
const asyncFn = async () => {
try {
const data = await readFile('input.txt');
await removeFile('input.txt');
return data;
} catch (e) {
console.log(e);
throw e;
}
};
asyncFn
.then(data => { /* ... */ })
.catch(err => { /* ... */ })
Ничего глобально не изменилось, код просто стал чище.
Стек вызовов (Call Stack, Stack) — вспомогательная структура данных в виде LIFO-очереди (Last In First Out), хранящая информацию о том, в каком месте в коде выполняется скипт в данный момент. С помощью стека вызовов можно узнать, какая функция выполняется в данный момент и в пределах какой функции она вызвана.
Поскольку JavaScript однопоточен, в нём имеется лишь один стек вызовов.
Если стек вызовов не пуст, это означает, что основной поток занят выполнением JavaScript-кода.
В стек вызовов помещается следующая информация
- Адрес места (строка и столбец), откуда функция была вызвана. Это позволяет продолжить с места вызова функции после её выполнения.
- Параметры и локальные переменные функции. Таким образом во вложенных функциях переменные доступны из замыкания.
Когда создаются локальные переменные с примитивными значениями и их функция удаляется из стека, они также удаляются из памяти.
Когда создаются локальные переменные с объектам, значения объектов созданяются в кучу (heap), а в переменной сохраняется лишь указатель, ссылка на них, по которой значение можно получить или изменить. Когда функция, в которой был создан объект, удаляется из стека, объект остаётся в памяти до тех пор, пока его не очистит сборщик мусора (garbage collector).
Стек вызовов является внутренней реализацией V8 и не доступен для JavaScript-разработчика.
Частично стек вызовов можно явно увидеть, когда в приложении возникает ошибка.
const foo = () => {
throw new Error('something wrong');
};
const bar = () => foo();
bar();
/*
Uncaught Error: something wrong
at foo (<anonymous>:2:9)
at bar (<anonymous>:5:19)
at <anonymous>:7:1
*/
У стека вызовов есть максимально допустимая глубина (~10000). Если она достигается (например, посредством бесконечной рекурсии), выбрасывается ошибка о переполнении стека (stack overflow
).
const foo = () => foo();
foo();
/*
Uncaught RangeError: Maximum call stack size exceeded
at foo (<anonymous>:1:13)
at foo (<anonymous>:1:19)
at foo (<anonymous>:1:19)
at foo (<anonymous>:1:19)
*/
Пусть имеется следующий скрипт.
/* main.js */
const bar = () => console.log('bar');
const baz = () => console.log('baz');
const foo = () => {
console.log('foo');
bar();
baz();
};
foo();
Когда начинает выполняться скрипт, он помещается в стек вызовов (скрипт по сути является одной большой функцией).
/* Call Stack */
- main()
Каждая вызванная в скрипте функция добавляется в стэк.
/* Call Stack */
- foo()
- main()
Если внутри неё вызываются другая функция, то она помещается в стек поверх предыдущей.
/* Call Stack */
- bar()
- foo()
- main()
Когда функция завершает своё выполнение, она удаляется из стека.
/* Call Stack */
- foo()
- main()
Далее аналогично, пока скрипт не завершит свою работу и стек не опустеет.
/* Call Stack */
- baz()
- foo()
- main()
/* Call Stack */
- foo()
- main()
/* Call Stack */
- main()
/* Call Stack */
Цикл событий (Event Loop) — механизм, позволяющий NodeJS выполнять неблокирующие операции ввода-вывода. Когда NodeJS встречает в запущенном скрипте асинхронную операцию ввода-вывода, он выгружает её в ядро системы и продолжает выполнение других операций в основном потоке. Когда асинхронная операция заканчивает своё выполнение, генерируется событие о том, что её колбэк может быть помещён в очередь на исполнение.
Поскольку большинство ядер многопоточны, они могут обрабатывать несколько задач одновременно в фоновом режиме.
Цикл событий выполняется в том же потоке, что и основной скрипт, но запускается только после завершения работы скрипта. Это позволяет циклу событий не блокировать основной поток при запуске проекта и даёт преимущество перед похожими технологиями в других языках.
Цикл событий не может работать, пока стек вызовов не пуст. Он ждёт, пока освободится стек вызовов и, если в очереди есть готовые к исполнению колбеки, берёт первый из них и помещает его в стек. Когда стек снова освобождается, в него помещается следующий колбек и так далее.
Как в браузере, так и в NodeJS цикл событий скрыт от программиста, является частью внутренней реализацией.
Один полный обход (итерация) цикла событий называется тиком (tick).
Во время выполнения основного скрипта вызываются асинхронные операции (таймеры, операции ввода-вывода), происходит подписка на события. Каждая такая операция имеет колбэк, который по завершению операции в зависимости от её типа помещается в характерную очередь. Каждая очередь представляет собой определённую фазу (phase) цикла событий.
Цикл событий не совсем является циклом, он состоит из набора фаз, которые повторяются по кругу.
Основные фазы тика цикла событий
- Таймеры (Timers) — колбэки истёкших
setTimeout
иsetInterval
. - Ожидающие колбэки (Pending callbacks) — колбэки системных операций (например, ошибки TCP).
- Опрос (Poll) — ожадание новых колбэков (таймеров, ожидающих колбэков) и их выполнение. Здесь выполняется большинство колбэков завершённых операции ввода-вывода (I/O).
- Проверка (Check) — колбэки
setImmediate
. - Закрывающие колбэки (Close callbacks) — колбэки закрывающихся соединений.
Все фазы выполняются последовательно.
Когда цикл событий останавливается на определённой фазе, он выполняет все её колбэки до тех пор, пока они не закончатся или не будет достигнут лимит их выполнения. Затем цикл событий переходи к следующей фазе.
Все операции, относящиеся к основным фазам, можно назвать задачами (Tasks).
Любая операция ввода-вывода может планировать другие операции ввода-вывода и события.
Перед каждой фазой последовательно выполняются две промежуточные (intermediate) фазы:
- Очередь для nextTick (nextTick Queue). Колбэки
process.nextTick
. - Очередь микрозадач (Microtasks Queue). Колбэки разрешённых (resolved)
Promise
.
Фактически, все операции, относящиеся к промежуточным фазам, можно назвать микрозадачами (microtasks). Они не совсем являются частью цикла событий, предоставляемого библиотекой libuv
, но являются частью NodeJS.
Переход к следующей фазе не происходит, пока имеются невыполненные микрозадачи.
NodeJS следит за выполнением незаконченных асинхронных операций и прогоняет цикл событий до тех пор, пока они все не завершат своё выполнение. Когда все операции завершили своё выполнение, NodeJS усыпляет цикл событий до наступления новых событий.
- Программист запускает приложение, точкой входа в которое является скрипт
app.js
. В этот момент создаётся поток (thread), в котором будет выполняться приложение и инициализируется цикл событий (event loop).
node app.js
Работа с ядром системы в NodeJS происходит при помощи сторонней библиотеки libuv
, написанной на C
.
libuv
предоставляет Event Loop для NodeJS, а также возможности для работы с сетью (network I/O), файлами (File I/O), операционной системой (Operating System) и другими вещами.
libuv
имеет фиксированное количество потоков (по умолчанию 4), вместе называемых thread pool
. NodeJS отправляет libuv
очередь задач, откуда потоки из thread pool
берут (pull) задачи и исполняют их. Когда какой-то поток завершает задачу, он информирует об этом NodeJS и колбэк выполненной задачи поступает в очередь на исполнение.
Когда это возможно, libuv
избегает использования thread pool
в пользу использования асинхронных интерфейсов, предоставляемых операционными системами и базами данных.thead pool
используется лишь в том случае, если такие интерфейсы отсутствуют (или отсутствовали на момент написания библиотеки). На данный момент thread pool
используется только при работе с файлами (File I/O, модуль fs
). При работе с сетью (Network I/O, модуль net
для TCP и IPC, модуль dgram
для UDP) thread pool
не используется.
Event Loop однопоточен.
Работа с сетью: TCP и UDP обработчики. Операционная система (Thread pool не используется). Работа с файлами: stream и pipe в fs (потоки ввода-вывода). Thread pool (работает только с file I/O).
При запуске приложения из консоли можно передавать ему параметры, которые сохраняются в process.argv
и доступны в приложении.
const [nodePath, filePath, ...args] = process.argv;
Первым параметром всегда передаётся полный путь к NodeJS, вторым - полный путь к исполняему js-файлу.
node index.js test
const mode = process.argv[3];
console.log(mode); // test