OneKuma

OneKuma's Blog

One Lonely Kuma.
github
bilibili
twitter

Node.js コマンドラインプログラムの『死』

故事#

これは平凡な Node.js スクリプトで、未来のある時点で何かをするのを手伝います。

setTimeout(() => {
  // 未来に何かをする
}, 1000000)

物語が複雑になるにつれて、この Node.js スクリプトは規模が大きくなり、Node.js コマンドラインプログラムに変わりました。

起動時にいくつかの初期化を行い、その後しばらくして何かを行い、もしこの Node.js プロセスが突然終了した場合(ctrl + c)、いくつかのクリーンアップ作業を行う必要があります。または中間状態を記録するなどです。

// to-be.js

// 何かを初期化する

setTimeout(() => {
  // 未来に何かをする
}, 1000000)

process.on('SIGINT', () => {
  // このプロセスが終了した場合にクリーンアップを行う
})

しかし、あなたは先ほど実行したこのスクリプトが間違っていることに気づき、実行したくないと思いました。そこで、あなたはターミナルで ctrl + c を狂ったように押しましたが、何も起こりませんでした。

ターミナルで ctrl + c を押すと、現在フォアグラウンドで実行中のプロセスに SIGINT 信号が送信され、その実行を終了させることが知られています。ここで Node.js が正常に終了しない理由は、Node.js が SIGINTSIGTERM の 2 つの信号を受け取った後のデフォルトの処理動作現在のプロセスを終了することだからです;もしこれら 2 つの信号にカスタムコールバック関数を追加した場合このデフォルトの動作が無効になります(Node.js は終了しません)。

詳細は Node.js ドキュメント Signal events を参照してください。

暴力的な Workaround#

そこで、あなたは暴力的なワークアラウンドを持つことになりました:

// not-to-be.js

setTimeout(() => {
  // 未来に何かをする
}, 1000000)

process.on('SIGINT', () => {
  // このプロセスが終了した場合にクリーンアップを行う
  process.exit()
})

これは確かに多くの問題を解決できます。しかし、もしあなたが異なるコンテキストで異なるクリーンアップ作業を行う多くのコールバック関数を持っている場合はどうでしょうか?どれか一つの process.exit がプロセスを突然終了させ、他のクリーンアップコールバック関数が実行されなくなります。別の観点から言えば、process.exit を乱用すると、Node.js コマンドラインプログラムのデバッグが難しくなります。時には以前に書いた process.exit を完全に忘れてしまい、プログラムが突然終了してしまい、どうしていいかわからなくなることがあります。ライブラリを開発している場合は、これを使用することはできず、例外を投げるなどのより明示的な方法で終了することを選ぶべきです。

『優雅』な解決策?#

したがって、より『優雅』な解決策は次のとおりです:

const timer = setTimeout(() => {
  // 未来に何かをする
}, 1000000)

process.on('SIGINT', () => {
  // このプロセスが終了した場合にクリーンアップを行う
  clearTimeout(timer)
})

SIGINT 信号を受け取った後、以前に起動したタイマーを手動でクリアします。この時点で Node.js は実行中または待機中の非同期タスクがないことを確認し、正常に終了します。これはより理にかなっています。

しかし、新たな困難も生じます。CLI アプリケーションがますます複雑になるにつれて、より多くの非同期タスク(より多くのタイマー、TCP サーバーの構築、子プロセスの実行など)を起動します。どのタスクが停止していないかを忘れてしまう可能性があり、上記の『優雅』な解決策が時には信頼性がないように見えることがあります。それは、すべてのタスクに対応するクリーンアップコールバック関数を追加したかどうかに依存します。

なぜノードが実行中なのか#

ここにデバッグを助けるツールがあります:why-is-node-running、またはその代替品 why-is-node-still-running

あなたの Node.js コマンドラインプログラムの開始時に、このライブラリを導入すると、背後で async_hooks API を使用してすべての非同期イベントを監視します。以下はこのライブラリのデモです:

const log = require('why-is-node-running') // 最初に require すべきです
const net = require('net')

function createServer () {
  const server = net.createServer()
  setInterval(function () {}, 1000)
  server.listen(0)
}

createServer()
createServer()

setTimeout(function () {
  log() // ノードを実行中にしているアクティブなハンドルをログ出力します
}, 100)
プロセスを実行中にしているハンドルは 5 つあります

# Timeout
/home/maf/dev/node_modules/why-is-node-running/example.js:6  - setInterval(function () {}, 1000)
/home/maf/dev/node_modules/why-is-node-running/example.js:10 - createServer()

# TCPSERVERWRAP
/home/maf/dev/node_modules/why-is-node-running/example.js:7  - server.listen(0)
/home/maf/dev/node_modules/why-is-node-running/example.js:10 - createServer()

# Timeout
/home/maf/dev/node_modules/why-is-node-running/example.js:6  - setInterval(function () {}, 1000)
/home/maf/dev/node_modules/why-is-node-running/example.js:11 - createServer()

# TCPSERVERWRAP
/home/maf/dev/node_modules/why-is-node-running/example.js:7  - server.listen(0)
/home/maf/dev/node_modules/why-is-node-running/example.js:11 - createServer()

# Timeout
/home/maf/dev/node_modules/why-is-node-running/example.js:13 - setTimeout(function () {

@breadc/death#

暴力的な Workaround を引き続き使用しますが、集中型のイベントバスを作成し、それを SIGINT などの終了信号のコールバック関数として使用し、より複雑なクリーンアップ機能を提供します。そこで、私は自分で @breadc/death というライブラリを作成しました。

// https://github.com/yjl9903/Breadc/blob/main/packages/death/src/death.ts からの抜粋

const emitter = new EventEmitter();

const handlers = {
  SIGINT: makeHandler('SIGINT')
};

function makeHandler(signal: NodeJS.Signals) {
  return async (signal: NodeJS.Signals) => {
    const listeners = emitter.listeners(signal);

    // リスナーを逆順で反復処理
    for (const listener of listeners.reverse()) {
      await listener(signal);
    }

    // リスナーを削除して Node.js のデフォルトの動作を復元し
    // 無限ループを回避します
    process.removeListener('SIGINT', handlers.SIGINT);
    process.kill(process.pid, context.kill);    
  };
}

export function onDeath(callback: OnDeathCallback): () => void {
  process.on('SIGINT', handlers.SIGINT);
  emitter.addListener('SIGINT', callback);
  return () => {
    emitter.removeListener('SIGINT', callback)
  };
}

ここでは、Node.js に組み込まれているプロセスのイベントバスを自分で作成した emitter に置き換えています。自分の onDeath を呼び出してコールバック関数を登録すると、元の process.on('SIGINT', ...) には自分のコールバック関数が登録されます(同じ関数を重複して登録すると複数保持されるため、ここでは関連する処理を省略しています)。その後、emitter が自分でコールバック関数を管理します。

SIGINT 信号がトリガーされた後、すべてのコールバック関数の配列のコピーを取り、逆順で実行します。これらのリソースをクリーンアップするコールバック関数は、順序に依存しないか、割り当てられた順序に従ってクリーンアップする必要があるため、逆順を選択します。

最後に、ここでの SIGINT のコールバック関数を削除して、Node.js のデフォルトの終了動作を復元し、受け取った終了信号を再送信します。

Node.js プロセスの死#

「あなたの Node.js プロセスはなぜまだ実行中なのか?」という対称的なトピックは、「あなたの Node.js プロセスはなぜ突然終了するのか?」です。

以下の理由により、Node.js プロセスが異常終了することがあります:

操作
手動でプロセスを終了process.exit(1)
未捕捉の例外throw new Error()
未処理の Promise rejectPromise.reject()
無視された error イベントEventEmitter#emit('error')
未処理の信号$ kill <PROCESS_ID>

表は Node.js プロセスの死 から引用されています。
このブログには、Node.js のエラーハンドリングに関するいくつかのヒントも含まれており、拡張して読むことができます。

未捕捉の例外は、問題が発生する可能性のある場所で try catch するか、プログラムのエントリーポイントの最上部で try catch するか、または uncaughtException イベントをリッスンすることができます:

process.on('uncaughtException', error => {
  console.error(error)
})

未処理の Promise reject は、unhandledRejection イベントをリッスンすることができます:

process.on('unhandledRejection', error => {
  console.error(error)
})

したがって、uncaughtExceptionunhandledRejection の 2 つのイベント、SIGINTSIGTERMSIGQUIT の 3 つの終了信号にコールバック関数を登録することで、Node.js コマンドラインプログラムが「臨終」の際に何をすべきかをより堅牢に処理するのに役立ちます。

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。