[Перевод] О завершении работы Node.js-процессов
process
, являющийся экземпляром класса EventEmitter
. Этот объект, при нормальном завершении процесса, генерирует событие exit
. Код приложения может прослушивать это событие и, при его возникновении, выполнять, в синхронном режиме, некие операции по освобождению ресурсов.Существует несколько способов намеренного завершения работы процесса. Среди них — следующие:
Многие из этих операций часто выполняются случайно, например — это касается неперехваченных ошибок и необработанных исключений. Но одна из них, с которой мы начнём разбор причин завершения 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. (/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. (/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. (/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» описана стандартная реакция 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
Эти эксперименты призваны продемонстрировать то, как одна программа может отправлять сигналы другой программе. В ответ на эту команду Node.js-программа, работающая в другом терминале и получившая сигнал
SIGHUP
, выдаст соответствующее сообщение.Возможно, вы уже догадались о том, что Node.js-программы могут отправлять сообщения другим программам. Выполните следующую команду, которая демонстрирует отправку сообщения от короткоживущего процесса работающему процессу:
$ node -e "process.kill(, 'SIGHUP')"
В ответ на эту команду наш процесс покажет то же SIGHUP-сообщение, что показывал ранее. А если же работу этого процесса нужно завершить, ему надо отправить необрабатываемый сигнал
SIGKILL
: $ kill -9
После этого работа программы должна завершиться.
Эти сигналы часто используются в Node.js-приложениях для корректной обработки событий, приводящих к завершению работы программ. Например, когда завершается работа пода Kubernetes, он отправляет приложениям сигнал SIGTERM
, после чего запускает 30-секундный таймер. Если процессы продолжают работу после истечения срока этого таймера — Kubernetes отправляет им сигнал SIGKILL
.
Какие механизмы Node.js вы используете для организации корректного завершения процессов?