故事#
這是一個平平無奇的 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 接收到 SIGINT
和 SIGTERM
這兩個信號後的默認處理行為是退出當前的進程;如果你為這 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 reject | Promise.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)
})
因此,對 uncaughtException
和 unhandledRejection
這兩個事件,SIGINT
, SIGTERM
, SIGQUIT
三個終止信號註冊回調函數,可以幫助你更加 robust 的處理一個 Node.js 命令行程序『臨終』時應該做些什麼。