該文章首發於我的博客,歡迎來踩 ~ 另外,本文的 代碼 demo 鏈接,可以盡情 fork 提 PR😂。
文章開頭,先給大家拋出一個問題。
用過 Node 的人都知道,Node 採用的是類似 Nginx 單進程、異步IO 的運行模型,這也是 Node 性能強勁的根源。我們可能也經常聽人說 js 的執行是單進程、單線程的,那麼,如果換個說法,若說 Node 是單進程、單線程 的,是對的嗎?
下面我們來驗證一下。
我們來執行一個最簡單的 Node 程序。它只做一件事,就是不停接受標準輸入流並丟棄,這樣保證進程一直存在
process.stdin.resume();
啓動後,我們使用 ps -ef | grep node
命令找到該進程的 pid,並使用 top 命令查看該進程的線程數會打印出如下信息
這裏就不在贅述 top 命令的用法了,感興趣的同學可以自行 google 😁。這裏框出來的部分就是進程中的線程數,可以看到,並不是 1,而是 7。由此我們就有了上一個問題的結論。
Node 是單進程,但不是單線程的
那我們常說的 js 是單線程的又是怎麼回事呢?帶着問題,我們來看一下 Node 的架構圖:
- Node Standard library 就是我們常用的 Node 核心模塊,如 fs、path、http 等等
- Node Bindings 是溝通JS 和 C++的橋樑,封裝V8和Libuv的細節,向上層提供基礎API服務
-
最底層也是支撐 Node 的最核心的部分
- V8 是Google開發的JavaScript引擎,提供JavaScript運行環境,可以說它就是 Node.js 的發動機
- Libuv 是專門爲Node.js開發的一個封裝庫,提供跨平臺的異步I/O能力
- C-ares:提供了異步處理 DNS 相關的能力
- http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、數據壓縮等其他的能力
要解釋爲什麼上圖會有 7 個線程,關鍵在於 libuv 這個庫。
libuv 是一個跨平臺的異步 IO 庫,實現了網絡請求、文件 IO、子進程、線程池等功能。
可以發現,libuv 中是有線程池的,可以推斷出那 7 個線程很可能就是 libuv 所創建的。具體原因由於篇幅有限,再加上這也不是本文的重點,就不贅述了。
感興趣的同學可以這樣啓動 Node,set UV_THREADPOOL_SIZE=100 && node your-node.js
,並執行需要依賴 thread pool 的方法,如fs.readFile
,會發現線程數變多了。
綜上所述,我們可以得到結論,Node 默認是單進程多線程的,而 js 執行是單線程的。
索引
本文我將按照如下順序介紹如何利用 cluster
模塊創建一個單機集羣,以及 cluster
實現的基本原理。能夠讓大家對 Node 的進程、進程間通信機制有一個全面的瞭解
- Node 中的進程
- cluster 模塊使用
- cluster 模塊基本原理
由於筆者還是個渣渣,還有很多地方不理解,也可能存在描述不準確的地方,還請見諒。本文的 代碼 demo 鏈接,裏面還有一些問題待研究,都已用 TODO:
標註出來,如有大神瞭解,還請提 PR,在此提前感謝!!!
Node 中的進程
要實現一個單機集羣,首先就是要有創建子進程的能力。Node 默認是單進程運行的,但也可以創建子進程從而利用多核 CPU 的能力。
Node 中創建子進程依賴的模塊是 child_process
,方法主要有如下四個:
- spawn(command,args):核心方法,剩餘三個方法底層都依賴它
- exec(command,options):衍生一個 shell 執行一個系統命令,與spawn不同的是它會有一個回調函數參數可以獲知子進程的錯誤、標準輸出等
- execFile(file, args[, callback]):衍生一個子進程執行一個可執行文件
-
fork(modulePath,args):
fork
是spawn
的變體,專門用於衍生一個 node 進程,最大的特點是父子進程自帶通信機制(IPC管道)
如上四個方法中,spwan
方法是核心,理解了它的用法,剩餘三個就很好學習了。
它存在幾個重要的 options,如下:
- shell:默認 spawn 是不會在一個新的 shell 中執行的,若要開啓,可將該配置設置爲 true,或字符串指定 shell 的名稱。從而支持執行命令完全是 shell 中的語法。詳見官方文檔
- stdio:選項用於配置子進程與父進程之間建立的管道,詳見官方文檔
-
detached:
-
默認情況下,父進程退出,子進程也會一併退出。當設置了該選項爲
true
時,子進程會獨立於父進程,即父進程退出子進程不會退出 -
默認情況下,父進程等待所有子進程退出後自動退出。若希望父進程可以獨立於子進程退出,則可以調用
childProcess.unref()
方法,斷開與子進程的關聯
-
默認情況下,父進程退出,子進程也會一併退出。當設置了該選項爲
以上 stdio、unref 兩個選項是實現單機集羣的關鍵選項,在下文也會用到。
進程間如何通信?
要想實現多進程架構,進程間通信能力是必不可少的。Node 中進程間通信的方式有很多種,常用的如下:
-
IPC:Node 內置的進程間通信方式,通過建立子進程時的 stdio 選項打開
-
限制:
- 需要拿到進程的 handle,比如 process 對象,因此完全獨立的兩個進程無法使用這種方式
-
-
stdio:此 stdio 非彼 stdio,只是一個代稱,表示通過進程的 stdin、stdout、stderr 來通信
-
限制:
- 同上限制 1
- 只能傳遞 String 或 Buffer
-
-
socket:進程間通信常用的一種手段。Node 中
net
模塊提供了通過 socket 通信的功能- 優勢:可以方便地跨進程通信,無需拿到進程的 handle
- 限制:需要創建 socket 文件
本文將重點介紹 IPC 這種方式,這也是 Node 中最常用的方式,其他通信方式在 代碼 demo 中都可以找到。
-
打開方式:spawn 時 stdio 選項傳入數組,並帶上 'ipc',如
['ipc']
,還可以是[0, 1, 2, 'ipc']
,表示將子進程的 stdin、stdout、stderr 都繼承主進程的,並開啓 IPC 管道,詳見官方文檔。// 代碼示例 const cp = child_process.spawn('node', [你的文件路徑], { stdio: [0, 1, 2, 'ipc'] }); // 或 const cp = child_process.fork(你的文件路徑);
fork
方法創建的子進程是默認就帶 IPC 管道的。 -
使用方法:
-
主進程:在主進程中可以拿到子進程的句柄,如上例就是
cp
,通過send
方法即可向其發送消息了。子進程通過on('message')
事件監聽即可。 -
子進程:子進程中通過
process
對象即可拿到主進程的句柄,使用方式與主進程一樣。/* 主進程 */ const cp = spawn('node', [resolve(__dirname, './child.js')], { // 繼承父進程的 stdin、stdout、stderr,同時建立 IPC 通道 stdio: [0, 1, 2, 'ipc'] }); // 將輸入發送給子進程 process.stdin.on('data', (d) => { // 判斷 IPC 管道是否連接 if (cp.connected) { cp.send(d.toString()); } }); cp.on('message', (data) => { log('父進程收到數據'); log(data.toString()); }); cp.on('disconnect', () => { log('好的,再見兒子'); }); /* 子進程 */ process.on('message', (data) => { process.send('子進程收到數據'); // 若子進程沒有繼承父進程的 stdin、stdout、stderr,則該行沒有任何輸出 process.stdout.write(data); });
本代碼示例在
process/ipc/ipc
。
-
主進程:在主進程中可以拿到子進程的句柄,如上例就是
使用 cluster 模塊創建集羣
終於到重點了。默認 Node 程序是跑在單個進程中,js 又是執行在單個線程中的,因此無法利用多核 CPU 的並行能力。但 Node 也提供了 cluster
模塊用於方便地創建多個進程的單機集羣。
Node 單機集羣的核心思想是 “主從模式(Master-Worker)”,即 主進程負責分發工作給工作進程,工作進程負責完成交付的任務。
以 Web Server 爲例,就是主進程負責監聽端口,並將每次到來的請求分發給工作進程去進行業務邏輯的處理。
先貼官方文檔。
cluster 的常用 API 有如下幾個:
- isMaster/isWorker:用於判斷當前進程是主進程還是工作進程
-
setupMaster([settings]):cluster 內部通過
fork
創建子進程,該方法用於設置fork
方法的默認配置,唯一無法設置的是fork
參數中的env
屬性 - fork(filepath?):創建工作進程
-
worker:當處在工作進程中,通過該字段獲取當前 Worker 實例的相關信息,包括
process
、id
等,更多字段參見文檔 -
cluster.schedulingPolicy:設置調度策略。這是一個全局設置,當第一個工作進程被衍生或者調用 cluster.setupMaster() 時,都將第一時間生效。cluster 中有如下兩種調度策略
-
cluster.SCHED_RR
:即round-robin
,循環策略,即每個工作進程按順序接收請求 -
cluster.SCHED_NONE
:搶佔策略。即由系統自行決定該由哪個工作進程來處理請求
-
下面來實現一個簡單的單機集羣。
/* 主進程 */
cluster.schedulingPolicy = cluster.SCHED_NONE;
cluster.setupMaster({
exec: resolve(__dirname, './worker.js'),
});
for (let i = 0; i < os.cpus().length; i++) {
cluster.fork();
}
/* 工作進程 */
http.createServer((req, res) => {
console.log(worker.process.pid + ' 響應請求');
res.end('hello');
}).listen(5000, () => {
console.log('process %s started', worker.process.pid);
});
本示例代碼在 cluster/basic
這樣就實現了一個簡單的單機集羣,可以通過 ab -n 10 -c 5 http://127.0.0.1:5000/
命令去測試一下效果。
不出意外的話,server 的輸出應該如下圖:
可以看到分發給每個工作進程的請求基本是平均的,大家可以嘗試更換一下調度策略,再看看 👀~
但是目前我們的集羣還沒有任何錯誤處理能力,若其中一個工作進程出錯掛掉了怎麼辦?這樣工作進程就越來越少了。
要解決這個問題,只需在上例主進程代碼中加上簡單幾行即可。
cluster.on('exit', (worker, code, signal) => {
console.log(`工作進程 ${worker.process.pid} 已退出`);
const newWorker = cluster.fork();
console.log(`已重啓工作進程,pid:${newWorker.process.pid}`);
});
本示例代碼在 cluster/refork
如上,通過 cluster.on('exit')
事件監聽子進程退出,自動重啓一個新的工作進程。這樣就可以從容應對工作進程出錯的情況。
現在我們的集羣已經比較穩定了,但啓動還不太優雅。因爲它只能在 shell 中啓動,相當於 shell 的一個子進程,當你退出 shell 後 shell 會將它所創建的子進程回收,我們的服務就被幹掉了。
我們需要一個讓服務後臺運行的方法。
還記得上面提到的 ChildProcess.unref
方法麼?這個方法是實現該功能的關鍵。
默認情況下,父進程等待所有子進程退出後自動退出。若希望父進程可以獨立於子進程(即子進程都退出後父進程依舊運行或者父進程無需等待子進程都退出即可退出),則可以調用該方法,斷開與子進程的關聯,即可調用這個方法。
該方法有幾個注意事項:
- 若父子進程間存在通信管道,則該選項無效,如 stdio: 'pipe'。必須將 stdio 設置爲 'ignore' 或將子進程標準輸入、輸出重定向到其他地方(與父進程無關)才行
- 若啓用了它,則主進程默認會在執行完成後直接退出,但子進程不會退出,並被提升爲 init 進程的子進程(Mac 下是 launchd),即 ppid 爲 1
- 用 fork 實現不了 unref
下面來動手實現吧~
我們只需要新建一個啓動腳本,它所做的就是接受命令啓動服務或終止服務。
實現原理就是通過上面描述的 unref
方法斷開與腳本進程的聯繫,讓它提升爲一個後臺進程,並把服務的進程 id 保存爲一個 pid 文件,用於在傳入 stop 子命令時 kill 調服務進程。
使用detached
屬性也可以達到相同效果,讓主進程退出後子進程依然存在,但相比unref
,使用detached
還需要手動將主進程 kill 掉,否則默認主進程會等待所有子進程退出。
const pidFile = __dirname + '/pid';
// 若進程子命令是 stop,則 kill
if (process.argv[2] === 'stop') {
const pid = fs.readFileSync(pidFile, 'utf8');
if (!process.kill(pid, 0)) {
console.log(`進程 ${pid} 不存在!`);
return;
}
process.kill(Number(pid));
fs.unlinkSync(pidFile);
}
else {
const cp = spawn('node', [resolve(__dirname, './main.js')], {
stdio: 'ignore'
});
// 記錄主進程 pid
fs.writeFileSync(pidFile, cp.pid);
// 刪除當前進程的引用計數,取消該進程與它子進程的關聯
cp.unref();
}
本示例代碼在 cluster/background/index.js
。
這樣,我們就可以通過 node cluster/background/index.js
來啓動服務,並通過 cluster/background/index.js stop
終止服務啦~若想更方便地調用該命令,還可以將該腳本改成一個 shell 腳本,在文件頂部添加一個解析器註釋即可,如 #!/usr/bin/env node
。
至此,我們已經完成了一個簡單、相對穩定的單機集羣,並能通過命令方便地啓動、關閉。
不過總的來說,我們的集羣還遠遠不能用於生產環境,node 的 cluster 模塊實現的單機集羣還是太粗糙,個人建議用 pm2 這樣功能全面、穩定,並且無需修改任何業務代碼的工具更好~
cluster 模塊基本原理
由於筆者能力有限,目前還沒有完全看懂 cluster 模塊全部代碼,這裏只把明白的介紹一下,之後應該會再仔細研究一下,寫一篇 cluster 原理的文章😓。
-
如何實現 isMaster/isWorker?
- 通過環境變量判斷當前進程是主進程還是子進程,fork 子進程時 node 內部會給子進程添加一個特殊的環境變量
-
工作進程如何創建?
- 工作進程由
child_process.fork
方法創建,因此它們可以直接使用 IPC 和父進程通信
- 工作進程由
-
請求如何處理?
- 只由主進程監聽端口,將請求通過 IPC 管道分發給子進程,由子進程去處理
- 子進程只啓動服務,不會真正監聽端口。因爲內部 listen 方法被 fake 成一個直接返回 0 的空方法,因此不會去真正監聽端口
-
接問題 3,主進程的服務是在何時創建的呢?
- 主進程的 server 啓動實現是在子進程調用 listen 方法中開始的。子進程中若有調用 listen,則觸發主進程去創建 server 或獲取已創建的 server 句柄,創建時會把子進程啓動 server 的參數傳給主進程(比如端口、host 等)
-
接問題 3,主進程如何分發請求給工作進程?
- 如上所屬,進程間可通過 IPC 管道通信,即使用
process.send
方法向子進程發送消息。該方法還有個重要功能就是能夠發送句柄,如net.Server
、net.Socket
等等,因此能夠將主進程的net.Server
實例直接發送給工作進程處理。
- 如上所屬,進程間可通過 IPC 管道通信,即使用
====== 分割線 =======
能看到這裏證明你是個熱愛技術的優秀程序猿,請不要猶豫,立即加入我們!