NodeJS是單進程單線程<a name="1">[1]</a>結構识啦,適合編寫IO密集型的網絡應用箕憾。為了充分利用多核CPU的計算能力擂煞,最直接的想法是同時運行多個實例進程诬辈,但手動管理這些進程卻是個麻煩事酵使,不但要知道當前CPU的核心數(shù)以確定進程數(shù)量,還要為不同實例進程配置不同網絡監(jiān)聽端口(Listening Port)避免端口沖突<a name="2">[2]</a>焙糟,另外還要監(jiān)控進程運行狀態(tài)口渔,執(zhí)行Crash后重啟等操作,最后還得配合Load Balancer統(tǒng)一對外的服務端口:
想想就好煩穿撮!幸好缺脉,NodeJS引入了Cluster模塊試圖簡化這些體力勞動。使用Cluster模塊可以運行并管理多個實例進程混巧,而且無須為每個進程單獨配置監(jiān)聽端口(當然如果你想的話也可以)枪向。下面是Cluster模塊的基本用法勤揩,一個子進程啟動器:
//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
}
代碼很簡單咧党,運行后會產生一個Master進程及n個Worker子進程,n等于CPU核心數(shù)陨亡。
啟動器本身代碼(cluster_launcher.js)在Master和Worker子進程都會被執(zhí)行傍衡,依據(jù)cluster.isMaster的值來區(qū)分運行在Master和Worker上的代碼分支。Master進程的cluster對象上定義有fork方法负蠕,調用后操作系統(tǒng)會生成一個新的Worker子進程蛙埂。Worker子進程除了從Master進程繼承了環(huán)境變量和命令行等設置,另外還多了一個環(huán)境變量NODE_UNIQUE_ID來保存Worker進程的Id(由Master負責分配)遮糖。Cluster模塊內部通過判斷NODE_UNIQUE_ID的存在與否確定當前運行的進程是Master還是Worker:
cluster.isWorker = ('NODE_UNIQUE_ID' in process.env);
cluster.isMaster = (cluster.isWorker === false);
剛才提到使用Cluster模塊管理多進程Node應用绣的,可以不用單獨為每個進程指定監(jiān)聽端口,也就是從使用者角度看每個進程使用同一個端口監(jiān)聽網絡而不會發(fā)生端口沖突欲账。這是怎么做到的呢屡江?原來Node內部讓TCP和UDP模塊的對Cluster啟動的情況做了特殊處理,接下來對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);
以上代碼實現(xiàn)了一個最簡單的HTTP服務器文黎,在8000端口監(jiān)聽請求并返回“hello world”字符串惹苗。TCP是面向連接的協(xié)議,操作系統(tǒng)層面每個監(jiān)聽端口都對應一個 Socket用來監(jiān)聽網絡上的TCP連接請求(Incoming Connection)耸峭,每當握手成功操作系統(tǒng)就會創(chuàng)建一個新的Socket代表這個已建立的連接(Established Connection)用做后續(xù)的IO操作桩蓉。單獨運行上面的服務器的話,這兩種Socket都屬于同一個進程劳闹,也就是監(jiān)聽TCP連接和處理HTTP請求都在一個進程完成触机。以下步驟幫助確認這種情況:
$ node tcpapp.js &
[1] 51647 //pid
打開瀏覽器訪問:http://localhost:8000
,然后lsof查看進程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)
可以看到同一個node進程上打開了兩個Socket(DEVICE列的值不同)玷或,一個負責監(jiān)聽端口儡首,一個負責已建立連接上的IO。一般來說TCP連接握手由操作系統(tǒng)在內核空間完成偏友,不會形成性能瓶頸蔬胯,單進程node應用的瓶頸在于應用邏輯,即使業(yè)務邏輯以IO為主位他,CPU消耗仍然比內核操作大得多氛濒。因此單進程node應用的瓶頸會在業(yè)務邏輯處理量增加到單CPU核心飽和時出現(xiàn)。
下面看看用cluster_launcher.js啟動的情況鹅髓,運行下面的命令:
$ node cluster_launcher.js &
[1] 28153
Master PID: 28153, CPUs: 4
Worker PID: 28155
Worker PID: 28156
Worker PID: 28154
Worker PID: 28157
可以看到一個Master進程啟動了四個Worker子進程:
$ 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下進程的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進程的DEVICE(對應Protocol Control Block的內核地址)的值都不一樣,說明各有各的Socket窿冯。Master進程只有一個處在Listening狀態(tài)的Socket負責監(jiān)聽8000端口骗奖,Worker進程的Socket都是Established的,說明Worker進程只負責處理連接上的IO醒串。同時也可以看到执桌,三個Established狀態(tài)的TCP連接<a name="3">[3]</a>被分配給了三個Worker進程,也就是說芜赌,Cluster模塊可以利用多進程并行處理同一端口的TCP連接:
剩下的就要看每個Worker進程的負載是否均衡了仰挣。上圖所示是Cluster模式下Established TCP連接的默認調度方式(除Windows以外),調度由Master進程負責缠沈,以Round Robin的方式將Established狀態(tài)的連接IPC給Worker進程做進一步處理膘壶,這樣看來各Worker的負載是平均的。
默認的調度策略(Round Robin)大多數(shù)時候可以工作的很好洲愤,連接按建立的順序依次被分配到各Worker進程颓芭,每個CPU內核都可以得到充分利用。但是禽篱,這也意味著這種調度方式不能保證“同源(來自同一個IP)的連接”被同一個Worker進程處理畜伐,帶來上層應用會話狀態(tài)的管理問題。一般情況下可以使用redis等全局session store保存應用會話狀態(tài)躺率,對所有進程可見玛界,然而不是所有的應用層狀態(tài)都受業(yè)務代碼掌控万矾,能放入全局store,典型的例子是Socket.io 在建立WebSocket連接過程中的握手狀態(tài)是保存在本地進程內存中的慎框,而且目前沒有提供接口控制保存策略良狈,那么當通過Cluster模塊啟動Socket.io服務器時,同源的連接可能會被分配給不同Worker進程笨枯,出現(xiàn)握手失敗的狀況薪丁。解決的方法并不復雜,Master進程只需把同源IP的連接分配給同一個worker進程就可以了(這種調度方式有時被稱作IP Hash)馅精,可惜Cluster模塊目前并沒提供這個選項严嗜,只能借助第三方插件了<a name="4">[4]</a>。
除了默認的調度策略洲敢,還可以讓OS的Process Scheduler來負責worker進程的調度(詳見SCHED_NONE策略)漫玄,這也是Windows上的默認策略,但在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);
上面的代碼啟動UDP服務器,在9000端口監(jiān)聽UDP packet壮不。用cluster_launcher.js啟動并lsof查看socket結果如下:
$ 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進程的實際上共享同一個UDP Socket(DEVICE指向地址相同)汗盘,但區(qū)別是Worker子進程調用了Bind方法而Master進程沒有,Master進程在這里的作用僅僅是管理Worker進程“聲明”使用到的UDP Socket:每當Worker調用Bind方法監(jiān)聽某UDP端口時询一,內部會通過IPC詢問Master是否有可重用的UDP Socket隐孽,Master收到詢問后會在本地Socket緩存中查找,沒有則新創(chuàng)建一個并緩存起來家凯,之后把相應的UDP Socket IPC給Worker缓醋,Worker收到后在其上完成真正的Bind操作。這樣處理結果就是Cluster模塊把UDP packet的分發(fā)任務交給OS的Process Scheduler負責:當9000端口收到一個UDP packet時绊诲,Process Scheduler就會隨機分配給一個Worker做進一步處理:
以上是對Cluster模塊在處理TCP和UDP時內部機理的一些分析發(fā)掘,希望能對各位使用好Cluster模塊有所幫助褪贵,如有紕漏敬請指出掂之。
<a name="1ref">[1]</a>這里指用戶的編程模型是單進程單線程的,NodeJS進程本身是多線程的脆丁,例如世舰,NodeJS的底層庫libuv用線程池將文件系統(tǒng)的同步操作轉化成異步操作,只不過這一切對用戶透明槽卫。
<a name="2ref">[2]</a>Linux Kernel 3.9之后支持了SO_REUSEPORT選項跟压,可以讓多個進程共享同一個端口,但libuv目前沒有采用歼培。
<a name="3ref">[3]</a>訪問服務器時震蒋,瀏覽器通常會同時打開多個TCP連接發(fā)送HTTP請求茸塞,加快頁面的加載速度。
<a name="4ref">[4]</a>indutny/sticky-session可以解決Socket.io的問題查剖。
本文原創(chuàng)钾虐,歡迎轉載,但請注明出處