Node.js 誕生之初就遭到很多這樣的吐槽,固然這些都早已不是問題了。php
一、可靠性低。
二、單進程,單線程,只支持單核 CPU,不能充分的利用多核 CPU 服務器。一旦這個進程崩掉,那麼整個 web 服務就崩掉了。node
回想之前用 php 開發 web 服務器的時候,每一個 request 都在單獨的線程中處理,即便某一個請求發生很嚴重的錯誤也不會影響到其它請求。Node.js 會在一個線程中處理大量請求,若是處理某個請求時產生一個沒有被捕獲到的異常將致使整個進程的退出,已經接收到的其它鏈接所有都沒法處理,對一個 web 服務器來講,這絕對是致命的災難。nginx
應用部署到多核服務器時,爲了充分利用多核 CPU 資源通常啓動多個 Node.js 進程提供服務,這時就會使用到 Node.js 內置的 cluster 模塊了。相信大多數的 Node.js 開發者可能都沒有直接使用到 cluster,cluster 模塊對 child_process 模塊提供了一層封裝,能夠說是爲了發揮服務器多核優點而量身定作的。簡單的一個 fork,不須要開發者修改任何的應用代碼便可以實現多進程部署。當下最熱門的帶有負載均衡功能的 Node.js 應用進程管理器 pm2 即是最好的一個例子,開發的時候徹底不須要關注多進程場景,剩餘的一切都交給 pm2 處理,與開發者的應用代碼完美分離。git
pm2 start app.js
pm2 確實很是強大,但本文並不講解 pm2 的工做原理,而是從更底層的進程通訊講起,爲你們揭祕使用 Node.js 開發 web 應用時,使用 cluster 模塊實現多進程部署的原理。github
說到多進程固然少不了 fork ,在 un*x 系統中,fork 函數爲用戶提供最底層的多進程實現。web
fork() creates a new process by duplicating the calling process. The new process is referred to as the child process. The calling process is referred to as the parent process.算法
The child process and the parent process run in separate memory spaces. At the time of fork() both memory spaces have the same content. Memory writes, file mappings (mmap(2)), and unmappings (munmap(2)) performed by one of the processes do not affect the other.服務器
本文中要講解的 fork 是 cluster 模塊中很是重要的一個方法,固然了,底層也是依賴上面提到的 fork 函數實現。 多個子進程即是經過在master進程中不斷的調用 cluster.fork 方法構造出來。下面的結構圖你們應該很是熟悉了。多線程
上面的圖很是粗糙, 並無告訴咱們 master 與 worker 究竟是如何分工協做的。Node.js 在這塊作過比較大的改動,下面就細細的剖析開來。app
最初的 Node.js 多進程模型就是這樣實現的,master 進程建立 socket,綁定到某個地址以及端口後,自身不調用 listen 來監聽鏈接以及 accept 鏈接,而是將該 socket 的 fd 傳遞到 fork 出來的 worker 進程,worker 接收到 fd 後再調用 listen,accept 新的鏈接。但實際一個新到來的鏈接最終只能被某一個 worker 進程 accpet 再作處理,至因而哪一個 worker 可以 accept 到,開發者徹底沒法預知以及干預。這勢必就致使了當一個新鏈接到來時,多個 worker 進程會產生競爭,最終由勝出的 worker 獲取鏈接。
爲了進一步加深對這種模型的理解,我編寫了一個很是簡單的 demo。
master 進程
const net = require('net'); const fork = require('child_process').fork; var handle = net._createServerHandle('0.0.0.0', 3000); for(var i=0;i<4;i++) { fork('./worker').send({}, handle); }
worker 進程
const net = require('net'); process.on('message', function(m, handle) { start(handle); }); var buf = 'hello nodejs'; var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf; function start(server) { server.listen(); server.onconnection = function(err,handle) { console.log('got a connection on worker, pid = %d', process.pid); var socket = new net.Socket({ handle: handle }); socket.readable = socket.writable = true; socket.end(res); } }
保存後直接運行 node master.js
啓動服務器,在另外一個終端屢次運行 ab -n10000 -c100 http://127.0.0.1:3000/
各個 worker 進程統計到的請求數分別爲
worker 63999 got 14561 connections worker 64000 got 8329 connections worker 64001 got 2356 connections worker 64002 got 4885 connections
相信到這裏你們也應該知道這種多進程模型比較明顯的問題了
這其實就是著名的"驚羣"現象。
簡單說來,多線程/多進程等待同一個 socket 事件,當這個事件發生時,這些線程/進程被同時喚醒,就是驚羣。能夠想見,效率很低下,許多進程被內核從新調度喚醒,同時去響應這一個事件,固然只有一個進程能處理事件成功,其餘的進程在處理該事件失敗後從新休眠(也有其餘選擇)。這種性能浪費現象就是驚羣。
驚羣一般發生在 server 上,當父進程綁定一個端口監聽 socket,而後 fork 出多個子進程,子進程們開始循環處理(好比 accept)這個 socket。每當用戶發起一個 TCP 鏈接時,多個子進程同時被喚醒,而後其中一個子進程 accept 新鏈接成功,餘者皆失敗,從新休眠。
現代的 web 服務器通常都會在應用服務器外面再添加一層負載均衡,好比目前使用最普遍的 nginx。
利用 nginx 強大的反向代理功能,能夠啓動多個獨立的 node 進程,分別綁定不一樣的端口,最後由nginx 接收請求而後進行分配。
http { upstream cluster { server 127.0.0.1:3000; server 127.0.0.1:3001; server 127.0.0.1:3002; server 127.0.0.1:3003; } server { listen 80; server_name www.domain.com; location / { proxy_pass http://cluster; } } }
這種方式就將負載均衡的任務徹底交給了 nginx 處理,而且 nginx 自己也至關擅長。再加一個守護進程負責各個 node 進程的穩定性,這種方案也勉強行得通。但也有比較大的侷限性,好比想增長或者減小一個進程時還得再去改下 nginx 的配置。該方案與 nginx 耦合度過高,實際項目中並不常用。
說了這麼多,一直在講解 Node.js 多進程部署時遇到的各類問題。小夥伴們確定會有很是多的疑問。實際的 Node.js 項目中咱們究竟是如何利用多進程的呢,而且如何保障各個 worker 進程的穩定性。如何利用 cluster 模塊 fork 子進程,父子進程間又是如何實現通訊的呢?
下篇將爲你們一一揭曉,敬請期待!
Owner
hustxiaoc commented on 10 Nov 2015
上篇文章講解了 Node.js 中多進程部署時遇到的各類問題,那麼實際的線上項目中究竟是如何利用多進程,如何保障各個 worker 進程穩定性的呢,又是如何利用 cluster 模塊 fork 子進程,父子進程間又是如何實現通訊的呢?本篇就來一一揭曉。
回憶一下上篇中提到的最初 Node.js 多進程模型,多個進程綁定同一端口,相互競爭 accpet 新到來的鏈接。因爲沒法控制一個新的鏈接由哪一個進程來處理,致使各 worker 進程之間的負載很是不均衡。
因而後面就出現了基於 round-robin 算法的另外一種模型。主要思路是 master 進程建立 socket,綁定地址以及端口後再進行監聽。該 socket 的 fd 不傳遞到各個 worker 進程。當 master 進程獲取到新的鏈接時,再決定將 accept 到的客戶端鏈接分發給指定的 worker 處理。這裏使用了指定, 因此如何傳遞以及傳遞給哪一個 worker 徹底是可控的。round-robin 只是其中的某種算法而已,固然能夠換成其餘的。
一樣基於這種模型也給出一個簡單的 demo。
master 進程
const net = require('net'); const fork = require('child_process').fork; var workers = []; for(var i=0;i<4;i++) { workers.push(fork('./worker')); } var handle = net._createServerHandle('0.0.0.0', 3000); handle.listen(); handle.onconnection = function(err,handle) { var worker = workers.pop(); worker.send({},handle); workers.unshift(worker); }
woker 進程
const net = require('net'); process.on('message', function(m, handle) { start(handle); }); var buf = 'hello Node.js'; var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf; function start(handle) { console.log('got a connection on worker, pid = %d', process.pid); var socket = new net.Socket({ handle: handle }); socket.readable = socket.writable = true; socket.end(res); }
因爲只有 master 進程接收客戶端鏈接,而且可以按照特定的算法進行分發, 很好的解決了上篇中提到的因爲競爭致使各 worker 進程負載不均衡的硬傷。
上篇文章開頭提到 Node.js 被吐槽穩定性差,進程發生未捕獲到的異常就會退出。實際項目中因爲各類緣由,不可避免最後上線時仍是存在各類 bug 以及異常,最終進程退出。
當進程異常退出時,有可能該進程上還有不少未處理完的請求,簡單粗暴的使進程直接退出必然致使全部的請求都會丟失,給用戶帶來很是糟的體驗,這就很是須要一個進程優雅退出的方案。
給 process 對象添加 uncaughtException 事件綁定可以避免發生異常時進程直接退出。在回調函數裏調用當前運行 server 對象的 close 方法,中止接收新的鏈接。同時告知 master 進程該 worker 進程即將退出,能夠 fork 新的 worker 了。
接着在幾秒中以後差很少全部請求都已經處理完畢後,該進程主動退出,其中 timeout 能夠根據實際業務場景進行設置。
setTimeout(function(){ process.exit(1); }, timeut)
這裏面有一個小的細節處理,在關閉服務器以前,後續新接收的 request 所有關閉 keep-alive 特性,通知客戶端不須要與該服務器保持 socket 鏈接了。
server.on('request', function (req, res) { req.shouldKeepAlive = false; res.shouldKeepAlive = false; if (!res._header) { res.setHeader('Connection', 'close'); } });
第三方 graceful
模塊專門來處理這種場景的,感興趣的同窗能夠閱讀下源碼。
master 進程除了負責接收新的鏈接,分發給各 worker 進程處理以外,還得像天使同樣默默地守護着這些 worker 進程,保障整個應用的穩定性。一旦某個 worker 進程異常退出就 fork 一個新的子進程頂替上去。
這一切 cluster 模塊都已經好處理了,當某個 worker 進程發生異常退出或者與 master 進程失去聯繫(disconnected)時,master 進程都會收到相應的事件通知。
cluster.on('exit', function(){ clsuter.fork(); }); cluster.on('disconnect', function(){ clsuter.fork(); });
推薦使用第三方模塊 recluster 和 cfork,已經處理的很成熟了。
這樣一來整個應用的穩定性重任就落在 master 進程上了,因此必定不要給 master 太多其它的任務,百分百保證它的健壯性,一旦 master 進程掛掉你的應用也就玩完了。
master 進程可以接收鏈接進行分發,同時守護 worker 進程,這一切都離不開進程間的通訊。
講了這麼多,終於到最核心的地方了,要用多進程模型就必定會涉及到 ipc(進程間通訊)了。Node.js 中 ipc 都是在父子進程之間進行,按有無發送 fd 分爲 2 種方式。
當進程間須要發生文件描述符 fd 時,libuv 底層採用消息隊列來實現 ipc。master 進程接收到客戶端鏈接分發給 worker 進程處理時就用到了進程間 fd 的傳遞。
這種狀況父子進程之間只是發送簡單的字符串,而且它們之間的通訊是雙向的。master 與 worker 間的消息傳遞即是這種方式。雖然 pipe 可以知足父子進程間的消息傳遞,但因爲 pipe 是半雙工的,也就是說必須得建立 2 個 pipe 才能夠實現雙向的通訊,這無疑使得程序邏輯更復雜。
libuv 底層採用 socketpair 來實現全雙工的進程通訊,父進程 fork 子進程以前會調用 socketpair 建立 2 個 fd,下面是一個最簡單的也最原始的利用 socketpair 來實現父子進程間雙向通訊的 demo。
#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/types.h> #include <errno.h> #include <sys/socket.h> #include <stdlib.h> #define BUF_SIZE 100 int main(){ int s[2]; int w,r; char * buf = (char*)calloc(1 , BUF_SIZE); pid_t pid; if( socketpair(AF_UNIX,SOCK_STREAM,0,s) == -1 ){ printf("create unnamed socket pair failed:%s\n",strerror(errno) ); exit(-1); } if( ( pid = fork() ) > 0 ){ printf("Parent process's pid is %d\n",getpid()); close(s[1]); char *messageToChild = "a message to child process!"; if( ( w = write(s[0] , messageToChild , strlen(messageToChild) ) ) == -1 ){ printf("Write socket error:%s\n",strerror(errno)); exit(-1); } sleep(1); if( (r = read(s[0], buf , BUF_SIZE )) == -1){ printf("Pid %d read from socket error:%s\n",getpid() , strerror(errno) ); exit(-1); } printf("Pid %d read string : %s \n",getpid(),buf); }else if(pid == 0){ printf("Fork child process successed\n"); printf("Child process's pid is :%d\n",getpid()); close(s[0]); char *messageToParent = "a message to parent process!"; if( ( w = write(s[1] , messageToParent , strlen(messageToParent) ) ) == -1 ){ printf("Write socket error:%s\n",strerror(errno)); exit(-1); } sleep(1); if( (r = read(s[1], buf , BUF_SIZE )) == -1){ printf("Pid %d read from socket error:%s\n",getpid() , strerror(errno) ); exit(-1); } printf("Pid %d read string : %s \n",getpid(),buf); }else{ printf("Fork failed:%s\n",strerror(errno)); exit(-1); } exit(0); }
保存爲 socketpair.c 後運行 gcc socketpair.c -o socket && ./socket
輸出
Parent process's pid is 52853 Fork child process successed Child process's pid is :52854 Pid 52854 read string : a message to child process! Pid 52853 read string : a message to parent process!
上面從 libuv 底層方面講解了父子進程間雙向通訊的原理,在上層 Node.js 中又是如何實現的呢,讓咱們來一探究竟。
Node.js 中父進程調用 fork 產生子進程時,會事先構造一個 pipe 用於進程通訊,
new process.binding('pipe_wrap').Pipe(true);
構造出的 pipe 最初仍是關閉的狀態,或者說底層還並無建立一個真實的 pipe,直至調用到 libuv 底層的uv_spawn
, 利用 socketpair 建立的全雙工通訊管道綁定到最初 Node.js 層建立的 pipe 上。
管道此時已經真實的存在了,父進程保留對一端的操做,經過環境變量將管道的另外一端文件描述符 fd 傳遞到子進程。
options.envPairs.push('NODE_CHANNEL_FD=' + ipcFd);
子進程啓動後經過環境變量拿到 fd
var fd = parseInt(process.env.NODE_CHANNEL_FD, 10);
並將fd綁定到一個新構造的 pipe 上
var p = new Pipe(true); p.open(fd);
因而父子進程間用於雙向通訊的全部基礎設施都已經準備好了。說了這麼多可能仍是不太明白吧? 不要緊,咱們仍是來寫一個簡單的 demo 感覺下。
Node.js 構造出的 pipe 被存儲在進程的_channel
屬性上
master.js
const WriteWrap = process.binding('stream_wrap').WriteWrap; var cp = require('child_process'); var worker = cp.fork(__dirname + '/worker.js'); var channel = worker._channel; channel.onread = function(len, buf, handle){ if(buf){ console.log(buf.toString()) channel.close() }else{ channel.close() console.log('channel closed'); } } var message = { hello: 'worker', pid: process.pid } var req = new WriteWrap(); var string = JSON.stringify(message) + '\n'; channel.writeUtf8String(req, string, null);
worker.js
const WriteWrap = process.binding('stream_wrap').WriteWrap; const channel = process._channel; channel.ref() channel.onread = function(len, buf, handle){ if(buf){ console.log(buf.toString()) }else{ process._channel.close() console.log('channel closed'); } } var message = { hello: 'master', pid: process.pid } var req = new WriteWrap(); var string = JSON.stringify(message) + '\n'; channel.writeUtf8String(req, string, null);
運行node master.js
輸出
{"hello":"worker","pid":58731} {"hello":"master","pid":58732} channel closed
在多進程服務器中,爲了保障整個 web 應用的穩定性,master 進程須要監控 worker 進程的 exit 以及 disconnect 事件,收到相應事件通知後重啓 worker 進程。
exit 事件不用說,disconnect 事件可能不少人就不太明白了。還記得上面講到的進程優雅退出嗎,當捕獲到未處理異常時,進程不當即退出,可是會馬上通知 master 進程從新 fork 新的進程,而不是等該進程主動退出後再 fork。具體的作法就是調用 worker進程的 disconnect 方法,從而關閉父子進程用於通訊的 channel ,此時父子進程之間失去了聯繫,此時master 進程會觸發 disconnect 事件,fork 一個新的 worker進程。
下面是一個觸發disconnect
事件的簡單 demo
master.js
const WriteWrap = process.binding('stream_wrap').WriteWrap; const net = require('net'); const fork = require('child_process').fork; var workers = []; for(var i=0;i<4;i++) { var worker = fork(__dirname + '/worker.js'); worker.on('disconnect', function() { console.log('[%s] worker %s is disconnected', process.pid, worker.pid); }); workers.push(worker); } var handle = net._createServerHandle('0.0.0.0', 3000); handle.listen(); handle.onconnection = function(err,handle) { var worker = workers.pop(); var channel = worker._channel; var req = new WriteWrap(); channel.writeUtf8String(req, 'dispatch handle', handle); workers.unshift(worker); }
worker.js
const net = require('net'); const WriteWrap = process.binding('stream_wrap').WriteWrap; const channel = process._channel; var buf = 'hello Node.js'; var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf; channel.ref() //防止進程退出 channel.onread = function(len, buf, handle){ console.log('[%s] worker %s got a connection', process.pid, process.pid); var socket = new net.Socket({ handle: handle }); socket.readable = socket.writable = true; socket.end(res); console.log('[%s] worker %s is going to disconnect', process.pid, process.pid); channel.close(); }
運行node master.js
啓動服務器後,在另外一個終端執行屢次curl http://127.0.0.1:3000
,下面是輸出的內容
[63240] worker 63240 got a connection [63240] worker 63240 is going to disconnect [63236] worker 63240 is disconnected
回到前面講的 round-robin 多進程服務器模型,用於通訊的 channel 除了能夠發送簡單的字符串數據外,還能夠發送文件描述符,
channel.writeUtf8String(req, string, null);
最後一個參數即是要傳遞的 fd。round-robin 多進程服務器模型的核心也正式依賴於這個特性。 在上面的 demo 基礎上,咱們再稍微加工一下,還原在 Node.js 中最原始的處理。
master.js
const WriteWrap = process.binding('stream_wrap').WriteWrap; const net = require('net'); const fork = require('child_process').fork; var workers = []; for(var i=0;i<4;i++) { workers.push(fork(__dirname + '/worker.js')); } var handle = net._createServerHandle('0.0.0.0', 3000); handle.listen(); handle.onconnection = function(err,handle) { var worker = workers.pop(); var channel = worker._channel; var req = new WriteWrap(); channel.writeUtf8String(req, 'dispatch handle', handle); workers.unshift(worker); }
worker.js
const net = require('net'); const WriteWrap = process.binding('stream_wrap').WriteWrap; const channel = process._channel; var buf = 'hello Node.js'; var res = ['HTTP/1.1 200 OK','content-length:'+buf.length].join('\r\n')+'\r\n\r\n'+buf; channel.ref() channel.onread = function(len, buf, handle){ var socket = new net.Socket({ handle: handle }); socket.readable = socket.writable = true; socket.end(res); }
運行 node master.js
, 一個簡單的多進程 Node.js web 服務器便跑起來了。
到此整個 Node.js 的多進程服務器模型,以及底層進程間通訊原理就講完了,也爲你們揭開了 cluster 的神祕面紗, 相信你們對 cluster 有了更深入的認識。祝你們 Node.js 的開發旅途上玩得更愉快!