Node.js-процессы завершают работу по разным причинам. При этом в некоторых случаях «смерть» процесса можно предотвратить. Например — в ситуации, когда причиной остановки процесса может стать необработанная ошибка. А вот иногда с остановкой процесса ничего поделать нельзя. Например — если её причина кроется в нехватке памяти. В Node.js существует глобальный объект process
, являющийся экземпляром класса EventEmitter
. Этот объект, при нормальном завершении процесса, генерирует событие exit
. Код приложения может прослушивать это событие и, при его возникновении, выполнять, в синхронном режиме, некие операции по освобождению ресурсов.
Существует несколько способов намеренного завершения работы процесса. Среди них — следующие:
Операция | Пример |
Ручной выход из процесса |
|
Неперехваченная ошибка |
|
Необработанное отклонение промиса |
|
Проигнорированное событие error |
|
Необработанный сигнал |
|
Многие из этих операций часто выполняются случайно, например — это касается неперехваченных ошибок и необработанных исключений. Но одна из них, с которой мы начнём разбор причин завершения Node.js-процессов, была создана с целью дать разработчику возможность вручную завершать процессы.
Ручной выход из процесса
Подход к завершению работы Node.js-процессов, при котором используется команда вида process.exit(code)
, это — самый простой и понятный механизм такого рода, который имеется у разработчика. Эта команда весьма полезна при разработке программ, в коде которых есть место, момент достижения которого означает, что работа программы завершена. Значение code
, которое передаётся методу process.exit()
, является необязательным, оно может принимать значения от 0 до 255, по умолчанию оно устанавливается в 0. 0 означает успешное завершение процесса, любое ненулевое значение говорит о том, что при работе процесса что-то пошло не так. Подобные значения используются различными внешними по отношению к Node.js-процессам программами. Например, если запуск набора тестов завершился с ненулевым кодом, это означает, что системе не удалось успешно выполнить этот набор тестов.
Когда вызывается команда process.exit()
, это не приводит к выводу в консоль какого-либо стандартного сообщения. Если вы написали код, в котором этот метод вызывается при возникновении какой-то ошибки, вам надо предусмотреть вывод сведений об этой ошибке, нужно сообщить пользователю программы о проблеме. Например — попробуйте запустить такой код:
$ node -e "process.exit(42)"
$ echo $?
Этот Node.js-однострочник ничего в консоль не выведет. Правда, воспользовавшись возможностями командной оболочки, можно узнать статус завершения процесса. Пользователь программы, столкнувшись с тем, что она завершила работу подобным образом, не поймёт того, что произошло.
А вот — более удачный пример использования process.exit()
. Этот код представляет собой фрагмент программы, в котором, если некий конфигурационный объект настроен неправильно, осуществляется выход из процесса с предварительным выводом сообщения об ошибке:
function checkConfig(config) {
if (!config.host) {
console.error("Configuration is missing 'host' parameter!");
process.exit(1);
}
}
Теперь пользователю будут понятны причины остановки приложения. Пользователь запускает приложение, оно выдаёт ошибку в консоль, после чего пользователь принимает меры для исправления ситуации.
Стоит отметить, что метод process.exit()
— это весьма мощный механизм. Хотя у него есть своё место в коде приложений, его категорически не рекомендуется использовать в библиотеках, рассчитанных на многократное использование. Если ошибка произошла в библиотеке, библиотека должна её выбросить. Это позволит приложению, использующему библиотеку, самостоятельно принять решение о том, как обрабатывать эту ошибку.
Исключения, отклонения промисов, выдача событий error
Метод process.exit()
— это полезный инструмент, применимый для борьбы с ошибками, возникающими при запуске программ и при наличии проблем с их первоначальными настройками. Но он не очень хорошо подходит в тех случаях, когда речь идёт об ошибках времени выполнения. Тут нужны другие инструменты. Например, когда приложение занимается работой с HTTP-запросами, ошибка, возникшая в ходе обработки запроса, вероятно, не должна приводить к остановке процесса. Программа всего лишь должна вернуть ответ с сообщением об ошибке. Тут пригодятся и сведения о том, где именно произошла ошибка. Именно в подобных ситуациях очень кстати оказываются объекты Error
.
Экземпляры класса Error
содержат метаданные, которые полезны в деле определения причины ошибки. Например — данные трассировки стека и строки с сообщениями об ошибке. Распространённой является практика построения на основе класса Error
классов ошибок, рассчитанных на конкретное приложение. При этом одно лишь создание экземпляра класса Error
не приводит к каким-то заметным последствиям. Экземпляр ошибки нужно не только создать, но и выбросить.
Ошибки выбрасывают, используя ключевое слово throw
. Ошибки, при наличии каких-то проблем в коде, могут выбрасываться и автоматически. Когда это происходит — производится «раскручивание» стека вызовов. То есть — происходит выход из всех функций, цепочка вызовов которых привела к достижению места выброса ошибки. Делается это до тех пор, пока не будет достигнута функция, в которой соответствующий вызов помещён в выражение try/catch
. После этого ошибка перехватывается и вызывается код, который имеется в ветви выражения catch
. Если же при вызове кода, в котором была выброшена ошибка, выражение try/catch
не использовалось, такая ошибка считается неперехваченной.
Хотя предполагается, что ключевое слово throw
нужно использовать лишь для выбрасывания ошибок (например — так: throw new Error('foo')
), оно, с технической точки зрения, позволяет «выбрасывать» всё что угодно. Если что-то выброшено с помощью throw
— это «что-то» считается исключением. Настоятельно рекомендуется выбрасывать с помощью throw
именно экземпляры класса Error
, так как код, который перехватывает объекты, выброшенные throw
, весьма вероятно, рассчитывает на то, что это будут объекты, представляющие ошибки и имеющие соответствующие свойства.
Среди свойств объектов-ошибок можно отметить свойство .code
, представляющее код ошибки. Оно было популяризировано благодаря его использованию во внутренних Node.js-библиотеках. Это свойство содержит задокументированное строковое значение, которое не должно меняться между релизами системы. В качестве примера кода ошибки можно привести ERR_INVALID_URI
. Несмотря на то, что описание ошибки, предназначенное для программистов и хранящееся в свойстве .message
, может меняться, то, что хранится в свойстве .code
, меняться не должно.
К сожалению, один из широко распространённых подходов, используемых для различения ошибок, заключается в исследовании свойства .message
соответствующих объектов. А ведь значение этого свойства в разных версиях системы вполне может меняться. Это — рискованный и чреватый ошибками подход к обработке ошибок. Правда, надо сказать, что в экосистеме Node.js не существует идеального механизма различения ошибок, который подходит для работы со всеми существующими библиотеками.
Когда выбрасывается ошибка, которая оказывается неперехваченной, в консоль выводятся данные трассировки стека, осуществляется выход из процесса со статусом завершения процесса 1
. Вот пример сообщения о необработанном исключении:
/tmp/foo.js:1
throw new TypeError('invalid foo');
^
Error: invalid foo
at Object.<anonymous> (/tmp/foo.js:2:11)
... удалено для краткости ...
at internal/main/run_main_module.js:17:47
Эти данные позволяют нам сделать вывод о том, что ошибка произошла в строке 2, в 11 столбце кода файла foo.js
.
Как уже было сказано, глобальный объект process
является экземпляром EventEmitter
. Его можно использовать для «ловли» неперехваченных ошибок. А именно, его можно настроить на прослушивание события uncaughtException
. Вот пример использования такого подхода, когда ошибка перехватывается, после чего отправляется асинхронное сообщение, а уже потом осуществляется выход из процесса:
const logger = require('./lib/logger.js');
process.on('uncaughtException', (error) => {
logger.send("An uncaught exception has occured", error, () => {
console.error(error);
process.exit(1);
});
});
Отклонения промисов очень похожи на выброс ошибок. Промис может быть отклонён либо в том случае, если в нём вызван метод reject()
, либо в том случае, если в асинхронной функции будет выброшена ошибка. В этом смысле следующие два примера, в целом, эквивалентны:
Promise.reject(new Error('oh no'));
(async () => {
throw new Error('oh no');
})();
А вот пример того, что при выполнении подобного кода выводится в консоли:
(node:52298) UnhandledPromiseRejectionWarning: Error: oh no
at Object.<anonymous> (/tmp/reject.js:1:16)
... удалено для краткости ...
at internal/main/run_main_module.js:17:47
(node:52298) UnhandledPromiseRejectionWarning: Unhandled promise
rejection. This error originated either by throwing inside of an
async function without a catch block, or by rejecting a promise
which was not handled with .catch().
Отклонённые промисы, в отличие от неперехваченных исключений, не приводят, в Node.js v14, к остановке процесса. В будущих версиях Node.js отклонённые промисы будут завершать работу процессов. Подобные события, как и в случае с событиями ошибок, можно перехватывать с помощью объекта process
:
process.on('unhandledRejection', (reason, promise) => {});
В Node.js распространено использование объектов-источников событий, основанных на классе EventEmitter
. Множество таких объектов, кроме того, применяется в библиотеках и приложениях. Эти объекты настолько популярны, что, говоря об ошибках и отклонённых промисах, стоит подробнее обсудить и их.
Когда объект EventEmitter
выдаёт событие error
, и при этом нет прослушивателя, ожидающего появления этого события, объект выбросит аргумент, который был выдан в виде события. Это приведёт к выдаче ошибки и станет причиной завершения работы процесса. Вот пример того, что в подобной ситуации попадает в консоль:
events.js:306
throw err; // Необработанное событие 'error'
^
Error [ERR_UNHANDLED_ERROR]: Unhandled error. (undefined)
at EventEmitter.emit (events.js:304:17)
at Object.<anonymous> (/tmp/foo.js:1:40)
... удалено для краткости ...
at internal/main/run_main_module.js:17:47 {
code: 'ERR_UNHANDLED_ERROR',
context: undefined
}
Не забывайте о прослушивании событий в экземплярах EventEmitter
, с которыми вы работаете. Это позволит приложению корректно обрабатывать подобные события, не останавливаясь при их возникновении.
Сигналы
Сигналы — это механизм, работа которого обеспечивается операционной системой. Они представляют собой короткие числовые сообщения, отправляемые одной программой другой программе. В качестве названий этих числовых сообщений часто пользуются строковыми именами соответствующих им констант. Например, имени SIGKILL
соответствует числовой сигнал с кодом 9
. Сигналы применяют, преследуя различные цели, но надо отметить, что часто они, так или иначе, используются для остановки программ.
В разных операционных системах могут быть определены различные сигналы. Ниже приведён список сигналов, которые, по большей части, универсальны.
Имя | Код | Подлежит ли сигнал обработке | Стандартная реакция Node.js | Цель сигнала |
|
1 | Да | Завершение работы | Закрытие терминала |
|
2 | Да | Завершение работы | Сигнал прерывания (Ctrl+C) с терминала |
|
3 | Да | Завершение работы | Сигнал Quit с терминала (Ctrl+D) |
|
9 | Нет | Завершение работы | Безусловное завершение процесса |
|
10 | Да | Запуск отладчика | Пользовательский сигнал №1 |
|
12 | Да | Завершение работы | Пользовательский сигнал №2 |
|
12 | Да | Завершение работы | Запрос на завершение работы процесса |
|
19 | Нет | Завершение работы | Остановка выполнения процесса |
Если в программе может быть реализован механизм обработки соответствующего сигнала — в столбце таблицы «Подлежит ли сигнал обработке» стоит «Да». Два сигнала из таблицы с «Нет» в этой колонке обработке не подлежат. В столбце «Стандартная реакция Node.js» описана стандартная реакция Node.js-программы на получение соответствующего сигнала. В столбце «Цель сигнала» приведено описание стандартного общепринятого подхода к использованию сигналов.
Для обработки этих сигналов в Node.js-приложении можно воспользоваться уже знакомым нам механизмом объекта process
по прослушиванию событий:
#!/usr/bin/env node
console.log(`Process ID: ${process.pid}`);
process.on('SIGHUP', () => console.log('Received: SIGHUP'));
process.on('SIGINT', () => console.log('Received: SIGINT'));
setTimeout(() => {}, 5 * 60 * 1000); // поддержание процесса в работающем состоянии
Запустите эту программу в терминале, а потом нажмите Ctrl + C
. Процесс не остановится. Вместо этого он сообщит о том, что получил сигнал SIGINT
. Переключитесь на другое окно терминала и выполните следующую команду, использовав в ней идентификатор процесса (Process ID
), выведенный вышеприведённым кодом:
$ kill -s SIGHUP <PROCESS_ID>
Эти эксперименты призваны продемонстрировать то, как одна программа может отправлять сигналы другой программе. В ответ на эту команду Node.js-программа, работающая в другом терминале и получившая сигнал SIGHUP
, выдаст соответствующее сообщение.
Возможно, вы уже догадались о том, что Node.js-программы могут отправлять сообщения другим программам. Выполните следующую команду, которая демонстрирует отправку сообщения от короткоживущего процесса работающему процессу:
$ node -e "process.kill(<PROCESS_ID>, 'SIGHUP')"
В ответ на эту команду наш процесс покажет то же SIGHUP-сообщение, что показывал ранее. А если же работу этого процесса нужно завершить, ему надо отправить необрабатываемый сигнал SIGKILL
:
$ kill -9 <PROCESS_ID>
После этого работа программы должна завершиться.
Эти сигналы часто используются в Node.js-приложениях для корректной обработки событий, приводящих к завершению работы программ. Например, когда завершается работа пода Kubernetes, он отправляет приложениям сигнал SIGTERM
, после чего запускает 30-секундный таймер. Если процессы продолжают работу после истечения срока этого таймера — Kubernetes отправляет им сигнал SIGKILL
.
Какие механизмы Node.js вы используете для организации корректного завершения процессов?
Автор: ru_vds