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', () => {
  // 如果這個進程被終止,做一些清理工作
})

然而,你發現你剛才運行的這段腳本寫錯了 / 不想運行了等等,你希望結束它。於是,你瘋狂地在 terminal 裡按 ctrl + c,但是無事發生。

我們知道在 terminal 裡按 ctrl + c 會給當前在前台運行的進程,發送一個 SIGINT 信號去終止它的運行。這裡 Node.js 沒有正常退出的原因是:Node.js 接收到 SIGINTSIGTERM 這兩個信號後的默認處理行為退出當前的進程;如果你為這 2 個信號添加了自定義的回調函數,將會禁用這個默認行為(Node.js 不會退出)。

詳情見 Node.js 文檔 Signal events

暴力的 Workaround#

然後,你就有了一個暴力的 workaround:

// not-to-be.js

setTimeout(() => {
  // 在未來做一些事情
}, 1000000)

process.on('SIGINT', () => {
  // 如果這個進程被終止,做一些清理工作
  process.exit()
})

Okay,這確實可以解決很多問題。但是如果你有很多個回調函數,分別在不同的上下文下,做不同的清理工作呢?其中任何一個 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#

這裡有一個輔助你 debug 的工具:why-is-node-running,或者它的替代品 why-is-node-still-running

在你的 Node.js 命令行程序的開始,引入的這個庫在背後使用了 async_hooks API 監聽了所有異步事件。下面是這個庫的 demo:

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() // 日誌輸出保持 node 運行的活動句柄
}, 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)
  };
}

可以看到,我們這裡用自己創建的 emitter 替換了 Node.js 內置的 process 上的事件總線。在調用自己的 onDeath 註冊回調函數時,在本來的 process.on('SIGINT', ...) 註冊的是我們自己的回調函數(重複註冊同一個函數會保留多個,此處省略了相關處理),然後使用 emitter 自己維護回調函數。

在觸發 SIGINT 信號後,我們會拿出一份所有回調函數的數組的拷貝,用逆序運行它們。可以理解為,這些回收資源的回調函數,往往要麼順序無關,要麼可能需要按照它們分配的順序進行回收,因此選擇使用逆序。

最後,將我們這裡 SIGINT 的回調函數移除,以恢復 Node.js 的默認退出行為,並重新發送一遍接受到的終止信號。

The Death of a Node.js Process#

與『你的 Node.js 進程為什麼還在運行?』對稱的另一個話題是:『你的 Node.js 進程為什麼會突然暴毙?』

以下這幾種原因會導致 Node.js 的進程異常終止:

操作例子
手動退出進程process.exit(1)
未捕獲的異常throw new Error()
未處理的 Promise rejectPromise.reject()
被忽略的 error 事件EventEmitter#emit('error')
未處理的信號$ kill <PROCESS_ID>

表格引用自 The Death of a Node.js Process
此博客內容還包含一些 Node.js 如何錯誤處理的 Tips 可以擴展閱讀。

未捕獲的異常可以手動在可能有問題的位置 try catch,或者程序入口最頂層進行 try catch,或者你可以監聽 uncaughtException 這個事件:

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

未處理的 Promise reject 可以監聽 unhandledRejection 這個事件:

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

因此,對 uncaughtExceptionunhandledRejection 這兩個事件,SIGINT, SIGTERM, SIGQUIT 三個終止信號註冊回調函數,可以幫助你更加 robust 的處理一個 Node.js 命令行程序『臨終』時應該做些什麼。

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。