Node.js 進程、線程調(diào)試和診斷的設計和實現(xiàn)
大廠技術 高級前端 Node進階
點擊上方 程序員成長指北,關注公眾號
回復1,加入高級Node交流群
前言:本文介紹 Node.js 中,關于進程、線程調(diào)試和診斷的相關內(nèi)容。進程和線程的方案類似,但是也有一些不一樣的地方,本文將會分開介紹,另外本文介紹的是對業(yè)務代碼無侵入的方案,通過命令行開啟 Inspector 端口或者在代碼里通過 Inspector 模塊打開端口在很多場景下并不適用,我們需要的是一種動態(tài)控制的能力。
1. 背景
隨著前端的快速發(fā)展,Node.js 在業(yè)務中的使用場景也越來越多,如何保證 Node.js 服務的穩(wěn)定也逐漸成為一個非常重要事情,傳統(tǒng)的服務器架構大多數(shù)基于多進程、多線程的,任務的執(zhí)行是隔離的,一個任務出現(xiàn)問題通常不會影響其他任務,比如在一個請求中執(zhí)行一個死循環(huán),服務器還能處理其他的請求。
但是 Node.js 不一樣,從整體來看,Node.js 是單線程的,單個任務出現(xiàn)問題有可能會影響其他任務,比如在一個請求中執(zhí)行了死循環(huán),那么整個服務就沒法繼續(xù)工作了。所以在 Node.js 中,我們更加需要方便的調(diào)試和診斷工具,以便遇到問題時可以快速找到問題,解決問題,另外,工具不僅可以幫我們排查問題,還可以找出我們服務中的性能瓶頸,方便我們進行性能優(yōu)化。
2. 目標
我們基于 Node.js 本身提供的調(diào)試和診斷能力,提供一個調(diào)試和診斷平臺,使用方只需要引入 SDK,然后通過調(diào)試和診斷平臺就可以對服務的進程和線程進行調(diào)試和診斷。
3. 實現(xiàn)
目前支持了多進程和多線程的調(diào)試和診斷,下面按照進程和線程兩個方面介紹一下原理和具體實現(xiàn)。
3.1. 單進程
3.1.1 調(diào)試和診斷基礎
在 Node.js 中,可以通過以下方式收集進程的數(shù)據(jù)。
const inspector = require('inspector');
const session = new inspector.Session();
session.connect();
// 發(fā)送命令
session.post('Profiler.enable', () => {});
使用方式很簡單,通過新建一個和 V8 Inspector 通信的 Session 就可以對進程進行數(shù)據(jù)的收集,比如抓取進程的堆快照和 Profile 數(shù)據(jù)。有了這個基礎后,我們就可以封裝這個能力。
const http = require('http');
const inspector = require('inspector');
const fs = require('fs');
// 打開一個和 V8 Inspector 的會話
const session = new inspector.Session();
session.connect();
function getCpuprofile(req, res) {
// 向V8 Inspector 提交命令,開啟 CPU Profile 并收集數(shù)據(jù)
session.post('Profiler.enable', () = >{
session.post('Profiler.start', () = >{
// 收集一段時間后提交停止收集命令
setTimeout(() = >{
session.post('Profiler.stop', (err, { profile }) = >{
// 把數(shù)據(jù)寫入文件
if (!err && profile) {
fs.writeFileSync('./profile.cpuprofile', JSON.stringify(profile));
}
// 回復客戶端
res.end('ok');
});
},
3000);
})
});
}
http.createServer((req, res) = >{
if (req.url == '/debug/getCpuprofile') {
getCpuprofile(req, res);
} else {
res.end('ok');
}
}).listen(80);
但是這種方式不能調(diào)試進程,調(diào)試進程需要使用另外的 API,可以通過以下方式啟動調(diào)試進程的服務。
const inspector = require('inspector');
inspector.open();
console.log(inspector.url());
這時候 Node.js 進程中就會啟動一個 WebSocket Server,我們可以通過 Chrome Dev Tools 連上這個 Server 進行調(diào)試,我們看看如何封裝。
const inspector = require('inspector');
const http = require('http');
let isOpend = false;
function getHTML() {
return `<html>
<meta charset="utf-8" />
<body>
復制到新 Tab 打開該 URL 開始調(diào)試 devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=${inspector.url().replace("ws://", '')}
</body>
</html>`;
}
http.createServer((req, res) = >{
if (req.url == '/debug/open') {
// 還沒開啟則開啟
if (!isOpend) {
isOpend = true;
// 打開調(diào)試器
inspector.open();
}
// 返回給前端的內(nèi)容
const html = getHTML();
res.end(html);
} else if (req.url == '/debug/close') {
// 如果開啟了則關閉
if (isOpend) {
inspector.close();
isOpend = false;
}
res.end('ok');
} else {
res.end('ok');
}
}).listen(80);
我們以 API 的方式對外提供動態(tài)控制進程調(diào)試和診斷的能力,具體的實現(xiàn)可以根據(jù)場景去修改,比如給前端返回一個不帶 Inspector 端口的 URL,前端再通過 URL 訪問服務,服務代理請求 Websocket 請求到 Inspector 對應的 WebSocket 服務。比如把收集的數(shù)據(jù)上傳到云上,給前端返回一個 URL。
3.1.2 具體實現(xiàn)
我們通過 API 的方式提供功能,設計上采用插件化的思想,主框架負責接收請求和路由處理,具體的邏輯交給具體的插件去做,結構如下所示。
數(shù)據(jù)收集的實現(xiàn)和上面的例子中類似,收到請求路由到對應的插件,插件通過 Session 和 V8 Inspector 通信完成數(shù)據(jù)的收集。調(diào)試的實現(xiàn)就稍微復雜些,主要的原因是我們不能把端口返回給前端,讓前端直接連接該端口。這個不是因為安全問題,因為調(diào)試的 URL 是一個帶有一個復雜隨機值的字符串,就算端口暴露了,攻擊者也很難猜對隨機值,相比來說,通過提供 API 的方式更加不安全,因為只要知道服務的地址,就可以通過 API 去調(diào)試進程了,所以嚴格來說,這里還需要加一些校驗機制。言歸正傳,不暴露端口的原因是通常前端無法直接連接到這個端口,原因可能有很多,比如我們的服務運行在容器中,容器只對外暴露有限的端口,我們不能期待在進程中隨便起一個端口,在前端就可以直接訪問,但是有一個可以肯定的是,服務至少會對外提供一個端口,那就意味著我們可以通過某個對外的端口把非業(yè)務相關的請求傳遞到進程內(nèi),基于上面的情況,當我們打開 Inspector 端口時,我們只會告訴前端打開成功或者失敗,當前端通過調(diào)試 API 訪問服務器時,我們會判斷端口是否已經(jīng)打開,是的話代理請求到 WebSocket Server。結構設計如下:
大致實現(xiàn)如下:
const client = connect(WebSocket Server地址);
client.on('connect', () = >{
// 轉發(fā)協(xié)議升級的 HTTP 請求給 WebSocket Server
client.write(`GET ${req.path} HTTP/1.1\r\n` + buildHeaders(req.headers) + '\r\n');
// 透傳
socket.pipe(client);
client.pipe(socket);
});
收到客戶的的請求后,首先連接到 WebSocket Server,然后透傳客戶端的請求,接著通過管道讓 WebSocket Server 和客戶端通信就行。
3.2 多進程
為了利用多核,Node.js 服務通常會啟動多個進程,所以支持多進程的調(diào)試和診斷也是非常必要的。但是單進程的調(diào)試診斷方案無法通過橫行拓展來支持多進程的場景。
3.2.1 單進程方案的限制
前面提到的方式看起來工作得不錯,但是如果服務是單實例上多進程部署,就會存在一些限制。我們來看看這時候的結構:
假如我們只有一個對外端口:
-
基于 Node.js Cluster 模塊的多進程管理機制,多個進程監(jiān)聽同一個端口是沒問題的,但是請求的分發(fā)上會存在問題,比如請求 1 被分發(fā)到進程 1,打開了進程 1 的 Inspector 端口,接著請求 2 想關閉這個端口,但是請求被分發(fā)到了進程 2,但是進程 2 并沒有打開 Inspector 端口。 -
基于 child_process 的 fork 創(chuàng)建多進程,則在重復監(jiān)聽端口時會報錯,導致只有一個進程可以使用提供的功能。
3.2.1 Agent 進程
一種解決方案是每個進程監(jiān)聽不同的端口,這樣又回到了前面討論到問題,但是這種方案也不是完全不可行,只需要基于這個方案做一下改進,那就是引入 Agent 進程,這時候結構如下:
Agent 進程負責收集和管理工作進程的信息(如 pid、監(jiān)聽地址),并接管所有調(diào)試和診斷相關的請求,收到請求后根據(jù)參數(shù)進行請求分發(fā)。具體流程如下:
-
Agent 啟動一個服務器。 -
子進程啟動后,把自己的 pid 和監(jiān)聽的隨機服務地址注冊到 Agent。 -
客戶端通過 Agent 獲取進程的 pid 列表,并選擇需要操作的進程。 -
Agent 收到客戶的請求,根據(jù)入?yún)⒅械?pid 把請求發(fā)送給對應的子進程。 -
子進程處理完畢后返回給 Agent,Agent返回給客戶端。
3.2.2 如何創(chuàng)建 Agent 進程
確定了 Agent 進程的方案后,如何創(chuàng)建 Agent 進程成為一個需要解決的問題。在 Node.js 里啟動多個服務器的方式是通過 Cluster 或者直接通過 child_process 模塊 fork 出多個子進程,Node.js 框架/工具通常都會封裝這些邏輯,但是框架不一定會提供創(chuàng)建 Agent 進程的方式。為了通用,我們不能假設運行在某種框架/工具中,所以我們只能尋找一種獨立于框架/工具的方案。我們在每個 Worker 進程里都創(chuàng)建一個 Agent 進程,然后多個 Agent 進程競爭監(jiān)聽一個端口,監(jiān)聽成功的進程繼續(xù)運行,監(jiān)聽失敗的退出,最終剩下一個 Agent 進程。
3.3 多線程
線程和進程的調(diào)試、診斷類似,下面主要講一下不一樣的地方。
3.3.1 調(diào)試和診斷基礎
可以通過以下方式收集線程的數(shù)據(jù)。
const { Worker, workerData } = require('worker_threads');
const { Session } = require('inspector');
const session = new Session();
session.connect();
let id = 1;
// 給子線程發(fā)送消息
function post(sessionId, method, params, callback) {
session.post('NodeWorker.sendMessageToWorker', {
sessionId,
message: JSON.stringify({
id: id++,
method,
params
})
},
callback);
}
// 子線程連接上 V8 Inspector 后觸發(fā)
session.on('NodeWorker.attachedToWorker', (data) = >{
post(data.params.sessionId, 'Profiler.enable');
post(data.params.sessionId, 'Profiler.start');
// 收集一段時間后提交停止收集命令
setTimeout(() = >{
post(data.params.sessionId, 'Profiler.stop');
},
10000)
});
// 收到子線程消息時觸發(fā)
session.on('NodeWorker.receivedMessageFromWorker', ({ params: { message } }) = >{});
const worker = new Worker('./httpServer.js', { workerData: { port: 80 } });
worker.on('online', () = >{
session.post("NodeWorker.enable", { waitForDebuggerOnStart: false }, (err) = >{
console.log(err, "NodeWorker.enable");
});
});
setInterval(() = >{},100000);
類似通過 Agent 進程管理多個 Worker 進程一樣,因為一個進程中可能存在多個線程,所以需要對多個線程進行管理。首先通過 NodeWorker.enable 命令開啟子線程的 Inspector 能力,然后通過 NodeWorker.attachedToWorker 事件拿到線程對應的 sessionId,后續(xù)通過 sessionId 和線程進行通信。接著看一下調(diào)試的實現(xiàn):
const { Worker, workerData } = require('worker_threads');
const { Session } = require('inspector');
const session = new Session();
session.connect();
let workerSessionId;
let id = 1;
function post(method, params) {
session.post('NodeWorker.sendMessageToWorker', {
sessionId: workerSessionId,
message: JSON.stringify({
id: id++,
method,
params
})
});
}
session.on('NodeWorker.receivedMessageFromWorker', ({ params: { message } }) = >{
const data = JSON.parse(message);
console.log(data);
});
session.on('NodeWorker.attachedToWorker', (data) = >{
workerSessionId = data.params.sessionId;
post("Runtime.evaluate", {
includeCommandLineAPI: true,
expression: `const inspector = process.binding('inspector');
inspector.open();
inspector.url();
`
});
});
const worker = new Worker('./httpServer.js', { workerData: { port: 80 } });
worker.on('online', () = >{
session.post("NodeWorker.enable", { waitForDebuggerOnStart: false }, (err) = >{
err && console.log("NodeWorker.enable", err);
});
});
setInterval(() = >{}, 100000);
線程的調(diào)試主要利用 Runtime.evaluate 在子線程里動態(tài)執(zhí)行代碼來打開子線程的 Inspector 端口。了解了基礎使用后,我們看一下具體實現(xiàn)。
3.3.2 具體實現(xiàn)
首先我們提供一個 API 獲取線程列表,這樣我們后續(xù)就可以選擇操作某個線程,后續(xù)的每個請求都需要帶上 線程對應的 id,這里以獲取 Profile 為例講一下處理過程。
const {
sessionId,
interval = INTERVAL,
duration = DURATION
} = req.query;
// 向V8 Inspector 提交命令,開啟 CPU Profile 并收集數(shù)據(jù)
this.post(sessionId, { method: 'Profiler.enable' }, (err) = >{
this.post(sessionId, {
method: 'Profiler.setSamplingInterval',
params: { interval }
});
this.post(sessionId, { method: 'Profiler.start' }, (err) = >{
// 收集一段時間后提交停止收集命令
setTimeout(() = >{
this.post(sessionId, { method: 'Profiler.stop' }, (err, { profile }) => {});
}, duration);
});
})
我們看到每一個操作都需要 sessionId。通過 sessionId,我們把請求轉發(fā)到對應的線程。但是和進程不一樣,進程發(fā)送一個請求時傳入一個回調(diào),請求成功后就會執(zhí)行對應的回調(diào),我們不需要保存請求上下文,Node.js 會幫我們處理,但是線程不一樣,存在一個嵌套的過程,因為 Inspector 命令的執(zhí)行模式是一個請求命令對應一個回調(diào),但是和線程通信時,是首選通過 NodeWorker.sendMessageToWorker 命令和主線程通信,主線程會解析出 NodeWorker.sendMessageToWorker 的參數(shù),參數(shù)里包含了給子線程發(fā)送的命令,接著主線程通過 sessionId 把請求轉發(fā)到子線程,然后這時候 NodeWorker.sendMessageToWorker 就會返回并執(zhí)行對應的回調(diào),這時候意味著 NodeWorker.sendMessageToWorker 執(zhí)行結束了,但是我們請求子線程的命令還沒有完成,也就是說我們需要自己維護請求子線程對應的回調(diào)。我們看看 post 的具體實現(xiàn):
post(sessionId, message, callback ? ) {
// 請求對應的 id
const requestId = ++this.id;
this.session.post('NodeWorker.sendMessageToWorker', {
sessionId,
message: JSON.stringify({ ...message, id: requestId })
},
(err) = >{
/*
回調(diào)說明 NodeWorker.sendMessageToWorker 請求完成
err非空說明請求失敗,直接執(zhí)行回調(diào)
err為空說明請求成功,記錄 post 調(diào)用方的請求回調(diào),通過 id 關聯(lián)
*/
if (typeof callback === 'function') {
// 發(fā)送失敗則直接執(zhí)行回調(diào),成功則記錄回調(diào)
if (err) {
callback(err);
} else {
this.sessionMap[sessionId]['requests'][requestId] = callback;
}
}
});
}
我們看到在 NodeWorker.sendMessageToWorker 回調(diào)里保存了請求子線程的回調(diào)。接下來我們看一下線程執(zhí)行完命令后的回調(diào)。
this.session.on('NodeWorker.receivedMessageFromWorker', ({
params: {
sessionId,
message
}
}) = >{
const ctx = this.sessionMap[sessionId];
try {
const data = JSON.parse(message);
/**
* data 的內(nèi)容格式如下:
* {
* method: string,
* params: Object
* }
* 或者
* {
* id: number,
* result: { result: Object }
* }
*/
const {
id,
method,
result
} = data;
// 有 id 說明是請求對應的響應,沒有 id 說明是 Inspector 異步觸發(fā)的事件
if (id) {
if (typeof ctx.requests[id] === 'function') {
const fn = ctx.requests[id];
delete ctx.requests[id];
fn(null, result);
}
} else {
ctx.emit(method, data);
}
} catch(e) {
console.warn(e);
}
});
通過 NodeWorker.receivedMessageFromWorker 事件可以接收到線程返回的請求結果,從響應的數(shù)據(jù)中我們可以知道這個響應來自的線程和請求 id,根據(jù)這些信息我們就可以從維護的上下文中找到對應的回調(diào)(某些請求在收到響應前會觸發(fā)一些事件,這種情況下響應里是沒有請求 id 的)。
接著看一下如何調(diào)試子線程,調(diào)試端口默認是 9229,因為存在多線程,如果我們要同時調(diào)試多個線程的話,則會失敗,所以我們要允許前端來控制打開的端口,接著給子線程發(fā)送一個命令。
this.post(query.sessionId, {
method: "Runtime.evaluate",
params: {
includeCommandLineAPI: true,
expression: `let inspector;
try {
inspector = require('inspector');
inspector.open(${port}, ${host});
} catch(e) {
inspector = process.binding('inspector');
inspector.open(${port}, ${host});
}
inspector.url();`
}
},
(err, result) = >{
});
我們通過在子線程里動態(tài)執(zhí)行代碼來打開 Inspector 端口,這里需要處理一下不同 Node.js 版本的兼容問題,高版本(比如 16)中增加了一個判斷邏輯,如果存在 session 就無法動態(tài)打開 Inspector 端口了,比如以下代碼在 16 中會報錯(換一下 connect 和 open 的位置就可以執(zhí)行)。
const inspector = require('inspector');
const session = new inspector.Session();
session.connect();
inspector.open()
這里需要繞過 JS 層的判斷,通過 C++ 模塊提供的接口直接打開 Inspector 端口,這樣就可以保證任何時候我們都可以動態(tài)打開 Inspector 端口。最后通過 inspector.url() 讓子線程返回調(diào)試的 URL 并保存到上下文中,和進程一樣,前端也是通過 API 的方式連接子線程的 WebSocket Server。最后形成的結構如下。
4. 使用方式
目前支持了多個子進程和多個線程的調(diào)試、獲取 CPU Profile、獲取 Heap Profile、獲取 Heap Snapshot、獲取內(nèi)存信息(RSS、堆外內(nèi)存、ArrayBuffer等信息)能力。使用方首先在業(yè)務代碼里加載 SDK,部署服務后,進入調(diào)試診斷平臺頁面,按照以下步驟操作:
-
選擇調(diào)試進程還是線程 -
輸入服務地址(Agent 進程監(jiān)聽的地址)和選擇對應的操作類型,如果是收集數(shù)據(jù)則還需要輸入收集的持續(xù)時間。 -
獲取進程列表,并從中選擇你想操作的進程,每個選項 hover 時會提示進程對應的信息,比如文件路徑。 -
如果操作線程的話,在選擇進程后,還需要獲取該進程下的線程列表,并選擇你想操作的線程。 -
點擊執(zhí)行就可以獲得你想收集的數(shù)據(jù)或者在線調(diào)試的 URL。
進程:
線程:
5. 總結
進程、線程的調(diào)試和診斷在 Node.js 中的實現(xiàn)非常復雜,了解了 Node.js 的實現(xiàn)和使用方式后,具體應用到業(yè)務里也不容易,主要是要考慮到不同的業(yè)務場景,需要設計出通用的方案,另外調(diào)試是一個比較有用但是也比較危險的操作,在安全方面也需要多多考慮。調(diào)試、診斷和安全一樣,平時用不上,但是有問題的時候,能幫助我們更好地解決問題。
更多內(nèi)容參考:
-
深入理解 Node.js 的 Inspector:https://mp.weixin.qq.com/s/GLIlhURSrCYQ-8Bqg7i1kA -
Node.js子線程調(diào)試和診斷指南:https://zhuanlan.zhihu.com/p/402855448
我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學習感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關的交流、學習、共建。下方加 考拉 好友回復「Node」即可。
“分享、點贊、在看” 支持一波??
