Node.js進(jìn)程退出的深入理解
背景介紹
在我們的服務(wù)發(fā)布后,難免會(huì)被運(yùn)行環(huán)境(如容器、pm2 等)調(diào)度、升級(jí)服務(wù)導(dǎo)致重啟、各種異常導(dǎo)致進(jìn)程崩潰;一般情況下,運(yùn)行環(huán)境都有對(duì)服務(wù)進(jìn)程的健康監(jiān)測,在進(jìn)程異常時(shí),會(huì)重新拉起進(jìn)程,在升級(jí)時(shí),也有滾動(dòng)升級(jí)的策略。但運(yùn)行環(huán)境的調(diào)度策略是把我們服務(wù)的進(jìn)程當(dāng)成黑盒來處理的,不會(huì)管服務(wù)進(jìn)程內(nèi)部的運(yùn)行情況,因此需要我們的服務(wù)進(jìn)程主動(dòng)感知運(yùn)行環(huán)境的調(diào)度動(dòng)作,然后做一些退出的清理動(dòng)作。
因此我們今天就是梳理各種可能導(dǎo)致 Node.js 進(jìn)程退出的情況,以及我們可以通過監(jiān)聽這些進(jìn)程退出事件做哪些事情。
原理
一個(gè)進(jìn)程要退出,無非就是兩種情況,一是進(jìn)程自己主動(dòng)退出,另外就是收到系統(tǒng)信號(hào),要求進(jìn)程退出。
系統(tǒng)信號(hào)通知退出
在 Node.js 官方文檔 中列出了常見的系統(tǒng)信號(hào),我們主要關(guān)注幾個(gè):
- SIGHUP:不通過 ctrl+c 停止進(jìn)程,而是直接關(guān)閉命令行終端,會(huì)觸發(fā)該信號(hào)
- SIGINT:按下 ctrl+c 停止進(jìn)程時(shí)觸發(fā);pm2 重啟或者停止子進(jìn)程時(shí),也會(huì)向子進(jìn)程發(fā)送該信號(hào)
- SIGTERM:一般用于通知進(jìn)程優(yōu)雅退出,如 k8s 刪除 pod 時(shí),就會(huì)向 pod 發(fā)送 SIGTERM 信號(hào),pod 可以在超時(shí)時(shí)間內(nèi)(默認(rèn) 30s)做一些退出清理動(dòng)作
- SIGBREAK:在 window 系統(tǒng)上,按下 ctrl+break 會(huì)觸發(fā)該信號(hào)
- SIGKILL:強(qiáng)制退出進(jìn)程,進(jìn)程無法做任何清理動(dòng)作,執(zhí)行命令 kill -9 pid,進(jìn)程會(huì)收到該信號(hào)。k8s 刪除 pod 時(shí),如果超過 30s,pod 還沒退出,k8s 會(huì)向 pod 發(fā)送 SIGKILL 信號(hào),立即退出 pod 進(jìn)程;pm2 在重啟或者停止進(jìn)程時(shí),如果超過 1.6s,進(jìn)程還沒退出,也會(huì)發(fā)送 SIGKILL 信號(hào)
在收到非強(qiáng)制退出信號(hào)時(shí),Node.js 進(jìn)程可以監(jiān)聽退出信號(hào),做一些自定義的退出邏輯。比如我們寫了一個(gè) cli 工具,需要比較長的時(shí)間執(zhí)行任務(wù),如果用戶在任務(wù)執(zhí)行完成前想要通過 ctrl+c 退出進(jìn)程時(shí),可以提示用戶再等等:
const readline = require('readline');
process.on('SIGINT', () => {
// 我們通過 readline 來簡單地實(shí)現(xiàn)命令行里面的交互
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('任務(wù)還沒執(zhí)行完,確定要退出嗎?', answer => {
if (answer === 'yes') {
console.log('任務(wù)執(zhí)行中斷,退出進(jìn)程');
process.exit(0);
} else {
console.log('任務(wù)繼續(xù)執(zhí)行...');
}
rl.close();
});
});
// 模擬一個(gè)需要執(zhí)行 1 分鐘的任務(wù)
const longTimeTask = () => {
console.log('task start...');
setTimeout(() => {
console.log('task end');
}, 1000 * 60);
};
longTimeTask();實(shí)現(xiàn)效果如下,每次按下 ctrl + c 都會(huì)提示用戶:

進(jìn)程主動(dòng)退出
Node.js 進(jìn)程主動(dòng)退出,主要包含下面幾種情況:
- 代碼執(zhí)行過程中觸發(fā)了未捕獲的錯(cuò)誤,可以通過 process.on('uncaughtException') 監(jiān)聽這種情況
- 代碼執(zhí)行過程中觸發(fā)了未處理的 promise rejection(Node.js v16 開始會(huì)導(dǎo)致進(jìn)程退出),可以通過 process.on('unhandledRejection') 監(jiān)聽這種情況
- EventEmitter 觸發(fā)了未監(jiān)聽的 error 事件
- 代碼中主動(dòng)調(diào)用 process.exit 函數(shù)退出進(jìn)程,可以通過 process.on('exit') 監(jiān)聽
- Node.js 的事件隊(duì)列為空,可簡單認(rèn)為沒有需要執(zhí)行的代碼了,可以通過 process.on('exit') 監(jiān)聽
我們知道 pm2 有守護(hù)進(jìn)程的效果,在你的進(jìn)程發(fā)生錯(cuò)誤退出時(shí),pm2 會(huì)重啟你的進(jìn)程,我們也在 Node.js 的 cluster 模式下,實(shí)現(xiàn)一個(gè)守護(hù)子進(jìn)程的效果(實(shí)際上 pm2 也是類似的邏輯):
const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;
const process = require('process');
// 主進(jìn)程代碼
if (cluster.isMaster) {
console.log(`啟動(dòng)主進(jìn)程: ${process.pid}`);
// 根據(jù) cpu 核數(shù),創(chuàng)建工作進(jìn)程
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
// 監(jiān)聽工作進(jìn)程退出事件
cluster.on('exit', (worker, code, signal) => {
console.log(`工作進(jìn)程 ${worker.process.pid} 退出,錯(cuò)誤碼: ${code || signal}, 重啟中...`);
// 重啟子進(jìn)程
cluster.fork();
});
}
// 工作進(jìn)程代碼
if (cluster.isWorker) {
// 監(jiān)聽未捕獲錯(cuò)誤事件
process.on('uncaughtException', error => {
console.log(`工作進(jìn)程 ${process.pid} 發(fā)生錯(cuò)誤`, error);
process.emit('disconnect');
process.exit(1);
});
// 創(chuàng)建 web server
// 各個(gè)工作進(jìn)程都會(huì)監(jiān)聽端口 8000(Node.js 內(nèi)部會(huì)做處理,不會(huì)導(dǎo)致端口沖突)
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(8000);
console.log(`啟動(dòng)工作進(jìn)程: ${process.pid}`);
}應(yīng)用實(shí)踐
上面分析了 Node.js 進(jìn)程退出的各種情況,現(xiàn)在我們來做一個(gè)監(jiān)聽進(jìn)程退出的工具,在 Node.js 進(jìn)程退出時(shí),允許使用方執(zhí)行自己的退出邏輯:
// exit-hook.js
// 保存需要執(zhí)行的退出任務(wù)
const tasks = [];
// 添加退出任務(wù)
const addExitTask = fn => tasks.push(fn);
const handleExit = (code, error) => {
// ...handleExit 的實(shí)現(xiàn)見下面
};
// 監(jiān)聽各種退出事件
process.on('exit', code => handleExit(code));
// 按照 POSIX 的規(guī)范,我們用 128 + 信號(hào)編號(hào) 得到最終的退出碼
// 信號(hào)編號(hào)參考下面的圖片,大家可以在 linux 系統(tǒng)下執(zhí)行 kill -l 查看所有的信號(hào)編號(hào)
process.on('SIGHUP', () => handleExit(128 + 1));
process.on('SIGINT', () => handleExit(128 + 2));
process.on('SIGTERM', () => handleExit(128 + 15));
// windows 下按下 ctrl+break 的退出信號(hào)
process.on('SIGBREAK', () => handleExit(128 + 21));
// 退出碼 1 代表未捕獲的錯(cuò)誤導(dǎo)致進(jìn)程退出
process.on('uncaughtException', error => handleExit(1, error));
process.on('unhandledRejection', error => handleExit(1, error));信號(hào)編號(hào):

接下來我們要實(shí)現(xiàn)真正的進(jìn)程退出函數(shù) handleExit,因?yàn)橛脩魝魅氲娜蝿?wù)函數(shù)可能是同步的,也可能是異步的;我們可以借助 process.nextTick 來保證用戶的同步代碼都已經(jīng)執(zhí)行完成,可以簡單理解 process.nextTick 會(huì)在每個(gè)事件循環(huán)階段的同步代碼執(zhí)行完成后執(zhí)行(理解 process.nextTick);針對(duì)異步任務(wù),我們需要用戶調(diào)用 callback 來告訴我們異步任務(wù)已經(jīng)執(zhí)行完成了:
// 標(biāo)記是否正在退出,避免多次執(zhí)行
let isExiting = false;
const handleExit = (code, error) => {
if (isExiting) return;
isExiting = true;
// 標(biāo)記已經(jīng)執(zhí)行了退出動(dòng)作,避免多次調(diào)用
let hasDoExit = fasle;
const doExit = () => {
if (hasDoExit) return;
hasDoExit = true
process.nextTick(() => process.exit(code))
}
// 記錄有多少個(gè)異步任務(wù)
let asyncTaskCount = 0;
// 異步任務(wù)結(jié)束后,用戶需要調(diào)用的回調(diào)
let ayncTaskCallback = () => {
process.nextTick(() => {
asyncTaskCount--
if (asyncTaskCount === 0) doExit()
})
}
// 執(zhí)行所有的退出任務(wù)
tasks.forEach(taskFn => {
// 如果 taskFn 函數(shù)的參數(shù)個(gè)數(shù)大于 1,認(rèn)為傳遞了 callback 參數(shù),是一個(gè)異步任務(wù)
if (taskFn.length > 1) {
asyncTaskCount++
taskFn(error, ayncTaskCallback)
} else {
taskFn(error)
}
});
// 如果存在異步任務(wù)
if (asyncTaskCount > 0) {
// 超過 10s 后,強(qiáng)制退出
setTimeout(() => {
doExit();
}, 10 * 1000)
} else {
doExit()
}
};至此,我們的進(jìn)程退出監(jiān)聽工具就完成了,完整的實(shí)現(xiàn)可以查看這個(gè)開源庫 async-exit-hook
進(jìn)程優(yōu)雅退出
通常我們的 web server 在重啟、被運(yùn)行容器調(diào)度(pm2 或者 docker 等)、出現(xiàn)異常導(dǎo)致進(jìn)程退出時(shí),我們希望執(zhí)行退出動(dòng)作,如完成已經(jīng)連接到服務(wù)的請(qǐng)求響應(yīng)、清理數(shù)據(jù)庫連接、打印錯(cuò)誤日志、觸發(fā)告警等,做完退出動(dòng)作后,再退出進(jìn)程,我們可以使用剛才的進(jìn)程退出監(jiān)聽工具實(shí)現(xiàn):
const http = require('http');
// 創(chuàng)建 web server
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(8000);
// 使用我們在上面開發(fā)的工具添加進(jìn)程退出任務(wù)
addExitTask((error, callback) => {
// 打印錯(cuò)誤日志、觸發(fā)告警、釋放數(shù)據(jù)庫連接等
console.log('進(jìn)程異常退出', error)
// 停止接受新的請(qǐng)求
server.close((error) => {
if (error) {
console.log('停止接受新請(qǐng)求錯(cuò)誤', error)
} else {
console.log('已停止接受新的請(qǐng)求')
}
})
// 比較簡單的做法是,等待一定的時(shí)間(這里我們等待 5s),讓存量請(qǐng)求執(zhí)行完畢
// 如果要完全保證所有請(qǐng)求都處理完畢,需要記錄每一個(gè)連接,在所有連接都釋放后,才執(zhí)行退出動(dòng)作
// 可以參考開源庫 https://github.com/sebhildebrandt/http-graceful-shutdown
setTimout(callback, 5 * 1000)
})總結(jié)
通過上面的文字,相信你已經(jīng)對(duì)導(dǎo)致 Node.js 進(jìn)程退出的各種情況心里有數(shù)了。在服務(wù)上線后,雖然 k8s、pm2 等工具能夠在進(jìn)程異常退出時(shí),不停地拉起進(jìn)程,保證服務(wù)的可用性,但我們也應(yīng)該在代碼中主動(dòng)感知進(jìn)程的異?;蛘弑徽{(diào)度的情況,從而能夠更早發(fā)現(xiàn)問題。
到此這篇關(guān)于Node.js進(jìn)程退出的文章就介紹到這了,更多相關(guān)Node.js進(jìn)程退出內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Egret引擎開發(fā)指南之運(yùn)行項(xiàng)目
Egret Framework是一款使用TypeScript語言構(gòu)建的開源免費(fèi)的移動(dòng)游戲框架。Egret Framework的核心定位是開放,高效,優(yōu)雅。通過它,你可以快速地創(chuàng)建HTML5類型的移動(dòng)游戲,也可以將游戲項(xiàng)目編譯輸出成為目標(biāo)移動(dòng)平臺(tái)的原生游戲應(yīng)用。2014-09-09
window10系統(tǒng)下nvm詳細(xì)安裝步驟以及使用
nvm可以管理不同版本的node和npm,可以簡單操作node版本的切換、安裝、查看等,下面這篇文章主要給大家介紹了關(guān)于window10系統(tǒng)下nvm詳細(xì)安裝步驟以及使用的相關(guān)資料,需要的朋友可以參考下2022-07-07
Node.js實(shí)現(xiàn)批量去除BOM文件頭
這篇文章主要介紹了Node.js實(shí)現(xiàn)批量去除BOM文件頭,本文直接給出實(shí)現(xiàn)代碼,需要的朋友可以參考下2014-12-12
Node.js中Process.nextTick()和Process.setImmediate()的區(qū)別
這篇文章介紹了Node.js中Process.nextTick()和Process.setImmediate()的區(qū)別,文中通過示例代碼介紹的非常詳細(xì)。對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2022-07-07

