NodeJS是單進(jìn)程單線程<a name="1">[1]</a>結(jié)構(gòu),適合編寫IO密集型的網(wǎng)絡(luò)應(yīng)用。為了充分利用多核CPU的計(jì)算能力,最直接的想法是同時(shí)運(yùn)行多個(gè)實(shí)例進(jìn)程,但手動(dòng)管理這些進(jìn)程卻是個(gè)麻煩事,不但要知道當(dāng)前CPU的核心數(shù)以確定進(jìn)程數(shù)量,還要為不同實(shí)例進(jìn)程配置不同網(wǎng)絡(luò)監(jiān)聽端口(Listening Port)避免端口沖突<a name="2">[2]</a>,另外還要監(jiān)控進(jìn)程運(yùn)行狀態(tài),執(zhí)行Crash后重啟等操作,最后還得配合Load Balancer統(tǒng)一對(duì)外的服務(wù)端口:

想想就好煩!幸好,NodeJS引入了Cluster模塊試圖簡化這些體力勞動(dòng)。使用Cluster模塊可以運(yùn)行并管理多個(gè)實(shí)例進(jìn)程,而且無須為每個(gè)進(jìn)程單獨(dú)配置監(jiān)聽端口(當(dāng)然如果你想的話也可以)。下面是Cluster模塊的基本用法,一個(gè)子進(jìn)程啟動(dòng)器:
//cluster_launcher.js
let cluster = require('cluster');
if (cluster.isMaster) {
// Here is in master process
let cpus = require('os').cpus().length;
console.log(`Master PID: ${process.pid}, CPUs: ${cpus}`);
// Fork workers.
for (var i = 0; i < cpus; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died`);
});
} else {
// Here is in Worker process
console.log(`Worker PID: ${process.pid}`);
require('./tcpapp.js');
//require('./udpapp.js'); //uncomment if you need a udp server
}
代碼很簡單,運(yùn)行后會(huì)產(chǎn)生一個(gè)Master進(jìn)程及n個(gè)Worker子進(jìn)程,n等于CPU核心數(shù)。
啟動(dòng)器本身代碼(cluster_launcher.js)在Master和Worker子進(jìn)程都會(huì)被執(zhí)行,依據(jù)cluster.isMaster的值來區(qū)分運(yùn)行在Master和Worker上的代碼分支。Master進(jìn)程的cluster對(duì)象上定義有fork方法,調(diào)用后操作系統(tǒng)會(huì)生成一個(gè)新的Worker子進(jìn)程。Worker子進(jìn)程除了從Master進(jìn)程繼承了環(huán)境變量和命令行等設(shè)置,另外還多了一個(gè)環(huán)境變量NODE_UNIQUE_ID來保存Worker進(jìn)程的Id(由Master負(fù)責(zé)分配)。Cluster模塊內(nèi)部通過判斷NODE_UNIQUE_ID的存在與否確定當(dāng)前運(yùn)行的進(jìn)程是Master還是Worker:
cluster.isWorker = ('NODE_UNIQUE_ID' in process.env);
cluster.isMaster = (cluster.isWorker === false);
剛才提到使用Cluster模塊管理多進(jìn)程N(yùn)ode應(yīng)用,可以不用單獨(dú)為每個(gè)進(jìn)程指定監(jiān)聽端口,也就是從使用者角度看每個(gè)進(jìn)程使用同一個(gè)端口監(jiān)聽網(wǎng)絡(luò)而不會(huì)發(fā)生端口沖突。這是怎么做到的呢?原來Node內(nèi)部讓TCP和UDP模塊的對(duì)Cluster啟動(dòng)的情況做了特殊處理,接下來對(duì)TCP和UDP兩種情況分別開8。
首先是TCP,按國際慣例,Hello World!。
//tcpapp.js
let http = require('http');
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(8000);
以上代碼實(shí)現(xiàn)了一個(gè)最簡單的HTTP服務(wù)器,在8000端口監(jiān)聽請(qǐng)求并返回“hello world”字符串。TCP是面向連接的協(xié)議,操作系統(tǒng)層面每個(gè)監(jiān)聽端口都對(duì)應(yīng)一個(gè) Socket用來監(jiān)聽網(wǎng)絡(luò)上的TCP連接請(qǐng)求(Incoming Connection),每當(dāng)握手成功操作系統(tǒng)就會(huì)創(chuàng)建一個(gè)新的Socket代表這個(gè)已建立的連接(Established Connection)用做后續(xù)的IO操作。單獨(dú)運(yùn)行上面的服務(wù)器的話,這兩種Socket都屬于同一個(gè)進(jìn)程,也就是監(jiān)聽TCP連接和處理HTTP請(qǐng)求都在一個(gè)進(jìn)程完成。以下步驟幫助確認(rèn)這種情況:
$ node tcpapp.js &
[1] 51647 //pid
打開瀏覽器訪問:http://localhost:8000 ,然后lsof查看進(jìn)程socket的情況:
$ lsof -a -i tcp:8000 -P -l
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
Google 10268 501 97u IPv6 0xff13215c8e8c1ac7 0t0 TCP localhost:50807->localhost:8000 (ESTABLISHED)
node 38622 501 11u IPv6 0xff13215c8e8c1567 0t0 TCP *:8000 (LISTEN)
node 38622 501 12u IPv6 0xff13215c8e8c1007 0t0 TCP localhost:8000->localhost:50807 (ESTABLISHED)
可以看到同一個(gè)node進(jìn)程上打開了兩個(gè)Socket(DEVICE列的值不同),一個(gè)負(fù)責(zé)監(jiān)聽端口,一個(gè)負(fù)責(zé)已建立連接上的IO。一般來說TCP連接握手由操作系統(tǒng)在內(nèi)核空間完成,不會(huì)形成性能瓶頸,單進(jìn)程node應(yīng)用的瓶頸在于應(yīng)用邏輯,即使業(yè)務(wù)邏輯以IO為主,CPU消耗仍然比內(nèi)核操作大得多。因此單進(jìn)程node應(yīng)用的瓶頸會(huì)在業(yè)務(wù)邏輯處理量增加到單CPU核心飽和時(shí)出現(xiàn)。
下面看看用cluster_launcher.js啟動(dòng)的情況,運(yùn)行下面的命令:
$ node cluster_launcher.js &
[1] 28153
Master PID: 28153, CPUs: 4
Worker PID: 28155
Worker PID: 28156
Worker PID: 28154
Worker PID: 28157
可以看到一個(gè)Master進(jìn)程啟動(dòng)了四個(gè)Worker子進(jìn)程:
$ pstree 28153
-+- 28153 /usr/local/bin/node /tmp/demo/cluster_launcher.js
|--- 28154 /usr/local/bin/node /tmp/demo/cluster_launcher.js
|--- 28155 /usr/local/bin/node /tmp/demo/cluster_launcher.js
|--- 28156 /usr/local/bin/node /tmp/demo/cluster_launcher.js
\--- 28157 /usr/local/bin/node /tmp/demo/cluster_launcher.js
打開瀏覽器訪問http://localhost:8000,然后lsof下進(jìn)程的socket的情況:
$ lsof -a -i tcp:8000 -P -R -l
COMMAND PID PPID USER FD TYPE DEVICE SIZE/OFF NODE NAME
Google 10268 1 501 3u IPv6 0xff13215c8e8c1007 0t0 TCP localhost:50504->localhost:8000 (ESTABLISHED)
Google 10268 1 501 5u IPv6 0xff13215c8e8bffe7 0t0 TCP localhost:50547->localhost:8000 (ESTABLISHED)
Google 10268 1 501 10u IPv6 0xff13215c8e8c0547 0t0 TCP localhost:50548->localhost:8000 (ESTABLISHED)
node 28153 12710 501 17u IPv6 0xff13215c8e8c1567 0t0 TCP *:8000 (LISTEN)
node 28154 28153 501 14u IPv6 0xff13215c8e8c0aa7 0t0 TCP localhost:8000->localhost:50548 (ESTABLISHED)
node 28155 28153 501 14u IPv6 0xff13215c8e8bfa87 0t0 TCP localhost:8000->localhost:50547 (ESTABLISHED)
node 28156 28153 501 14u IPv6 0xff13215c8e8c1ac7 0t0 TCP localhost:8000->localhost:50504 (ESTABLISHED)
可以看到分配給Master和Worker進(jìn)程的DEVICE(對(duì)應(yīng)Protocol Control Block的內(nèi)核地址)的值都不一樣,說明各有各的Socket。Master進(jìn)程只有一個(gè)處在Listening狀態(tài)的Socket負(fù)責(zé)監(jiān)聽8000端口,Worker進(jìn)程的Socket都是Established的,說明Worker進(jìn)程只負(fù)責(zé)處理連接上的IO。同時(shí)也可以看到,三個(gè)Established狀態(tài)的TCP連接<a name="3">[3]</a>被分配給了三個(gè)Worker進(jìn)程,也就是說,Cluster模塊可以利用多進(jìn)程并行處理同一端口的TCP連接:

剩下的就要看每個(gè)Worker進(jìn)程的負(fù)載是否均衡了。上圖所示是Cluster模式下Established TCP連接的默認(rèn)調(diào)度方式(除Windows以外),調(diào)度由Master進(jìn)程負(fù)責(zé),以Round Robin的方式將Established狀態(tài)的連接IPC給Worker進(jìn)程做進(jìn)一步處理,這樣看來各Worker的負(fù)載是平均的。
默認(rèn)的調(diào)度策略(Round Robin)大多數(shù)時(shí)候可以工作的很好,連接按建立的順序依次被分配到各Worker進(jìn)程,每個(gè)CPU內(nèi)核都可以得到充分利用。但是,這也意味著這種調(diào)度方式不能保證“同源(來自同一個(gè)IP)的連接”被同一個(gè)Worker進(jìn)程處理,帶來上層應(yīng)用會(huì)話狀態(tài)的管理問題。一般情況下可以使用redis等全局session store保存應(yīng)用會(huì)話狀態(tài),對(duì)所有進(jìn)程可見,然而不是所有的應(yīng)用層狀態(tài)都受業(yè)務(wù)代碼掌控,能放入全局store,典型的例子是Socket.io 在建立WebSocket連接過程中的握手狀態(tài)是保存在本地進(jìn)程內(nèi)存中的,而且目前沒有提供接口控制保存策略,那么當(dāng)通過Cluster模塊啟動(dòng)Socket.io服務(wù)器時(shí),同源的連接可能會(huì)被分配給不同Worker進(jìn)程,出現(xiàn)握手失敗的狀況。解決的方法并不復(fù)雜,Master進(jìn)程只需把同源IP的連接分配給同一個(gè)worker進(jìn)程就可以了(這種調(diào)度方式有時(shí)被稱作IP Hash),可惜Cluster模塊目前并沒提供這個(gè)選項(xiàng),只能借助第三方插件了<a name="4">[4]</a>。
除了默認(rèn)的調(diào)度策略,還可以讓OS的Process Scheduler來負(fù)責(zé)worker進(jìn)程的調(diào)度(詳見SCHED_NONE策略),這也是Windows上的默認(rèn)策略,但在Linux下效果并不理想,這里不再贅述。
接下來是UDP的情況:
//udpapp.js
let dgram = require('dgram');
let server = dgram.createSocket('udp4');
server.on('error', (err) => {
console.log(`server error:\n${err.stack}`);
server.close();
});
server.on('message', (msg, rinfo) => {
console.log(`server got: ${msg} from ${rinfo.address}:${rinfo.port}`);
});
server.on('listening', () => {
var address = server.address();
console.log(`server listening ${address.address}:${address.port}`);
});
server.bind(9000);
上面的代碼啟動(dòng)UDP服務(wù)器,在9000端口監(jiān)聽UDP packet。用cluster_launcher.js啟動(dòng)并lsof查看socket結(jié)果如下:
$ node cluster_launcher.js &
Master PID: 23263, CPUs: 4
Worker PID: 23266
Worker PID: 23265
Worker PID: 23267
Worker PID: 23264
server listening 0.0.0.0:9000
server listening 0.0.0.0:9000
server listening 0.0.0.0:9000
server listening 0.0.0.0:9000
$ lsof -a -i udp:9000 -P -R -l
COMMAND PID PPID USER FD TYPE DEVICE SIZE/OFF NODE NAME
node 23263 12710 501 17u IPv4 0xff13215c8a237c97 0t0 UDP *:9000
node 23264 23263 501 14u IPv4 0xff13215c8a237c97 0t0 UDP *:9000
node 23265 23263 501 14u IPv4 0xff13215c8a237c97 0t0 UDP *:9000
node 23266 23263 501 14u IPv4 0xff13215c8a237c97 0t0 UDP *:9000
node 23267 23263 501 14u IPv4 0xff13215c8a237c97 0t0 UDP *:9000
可以看到Master和Worker進(jìn)程的實(shí)際上共享同一個(gè)UDP Socket(DEVICE指向地址相同),但區(qū)別是Worker子進(jìn)程調(diào)用了Bind方法而Master進(jìn)程沒有,Master進(jìn)程在這里的作用僅僅是管理Worker進(jìn)程“聲明”使用到的UDP Socket:每當(dāng)Worker調(diào)用Bind方法監(jiān)聽某UDP端口時(shí),內(nèi)部會(huì)通過IPC詢問Master是否有可重用的UDP Socket,Master收到詢問后會(huì)在本地Socket緩存中查找,沒有則新創(chuàng)建一個(gè)并緩存起來,之后把相應(yīng)的UDP Socket IPC給Worker,Worker收到后在其上完成真正的Bind操作。這樣處理結(jié)果就是Cluster模塊把UDP packet的分發(fā)任務(wù)交給OS的Process Scheduler負(fù)責(zé):當(dāng)9000端口收到一個(gè)UDP packet時(shí),Process Scheduler就會(huì)隨機(jī)分配給一個(gè)Worker做進(jìn)一步處理:

以上是對(duì)Cluster模塊在處理TCP和UDP時(shí)內(nèi)部機(jī)理的一些分析發(fā)掘,希望能對(duì)各位使用好Cluster模塊有所幫助,如有紕漏敬請(qǐng)指出。
<a name="1ref">[1]</a>這里指用戶的編程模型是單進(jìn)程單線程的,NodeJS進(jìn)程本身是多線程的,例如,NodeJS的底層庫libuv用線程池將文件系統(tǒng)的同步操作轉(zhuǎn)化成異步操作,只不過這一切對(duì)用戶透明。
<a name="2ref">[2]</a>Linux Kernel 3.9之后支持了SO_REUSEPORT選項(xiàng),可以讓多個(gè)進(jìn)程共享同一個(gè)端口,但libuv目前沒有采用。
<a name="3ref">[3]</a>訪問服務(wù)器時(shí),瀏覽器通常會(huì)同時(shí)打開多個(gè)TCP連接發(fā)送HTTP請(qǐng)求,加快頁面的加載速度。
<a name="4ref">[4]</a>indutny/sticky-session可以解決Socket.io的問題。
本文原創(chuàng),歡迎轉(zhuǎn)載,但請(qǐng)注明出處