[Перевод] О завершении работы Node.js-процессов

Node.js-процессы завершают работу по разным причинам. При этом в некоторых случаях «смерть» процесса можно предотвратить. Например — в ситуации, когда причиной остановки процесса может стать необработанная ошибка. А вот иногда с остановкой процесса ничего поделать нельзя. Например — если её причина кроется в нехватке памяти. В 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 вы используете для организации корректного завершения процессов?

oug5kh6sjydt9llengsiebnp40w.png

© Habrahabr.ru