nodejs玩兒轉程式

十三_先生發表於2019-11-15

序言

  1. nodejs是如何充分利用多核cup 伺服器的?
  2. 如何保證程式的穩健型?

正文

因為Node執行在V8引擎上,我們的JavaScript 將會執行在單個程式的單個執行緒上。它帶來的好處是: 程式狀態是單一的,在沒有多執行緒的情況 下沒有鎖、執行緒同步問題,作業系統在排程時也因為較少上下文的切換,可以很好地提高CPU的使用率

從嚴格的意義上而言,Node並非真正的單執行緒架構,Node自身還有 一定的I/O執行緒存在,這些I/O執行緒由底層libuv處理,這部分執行緒對於JavaScript開發者而言是透明 的,只在C++擴充套件開發時才會關注到

程式和執行緒的區別及優劣:
1程式是作業系統分配資源的最小單元
複製程式碼

多程式的缺點主要體現在

	1 無法共享內部狀態(程式池的方式可以解決)
	2 以及建立和銷燬程式時候
複製程式碼

多執行緒相對多程式的優點:

建立和銷燬執行緒相對程式來說開銷小很多,(並且執行緒之間可以共享資料 ,記憶體浪費的問題得
以解決)並且利用執行緒池可以減少建立和銷燬執行緒的開銷
複製程式碼

多執行緒的缺點:

每個執行緒都有自己獨立的堆疊,每個堆疊都要佔用一定的記憶體空間
複製程式碼

服務模型的變遷:

從“古”到今,Web伺服器的架構已經歷了幾次變遷。從伺服器處理客戶端請求的併發量這個緯度來看,每次變遷都是里程碑的見證

nodejs玩兒轉程式
由此來看 多執行緒和事件驅動都有自己弊端

事件驅動:CPU的計算能力決定這類服務的效能上線
多執行緒模式:受資源上限的影響
複製程式碼

那麼nodejs是如何充分利用多核cup 伺服器的?

答案是通過fork程式的方式 ,我們再一次將經典的示例程式碼存為worker.js檔案,程式碼如下:

var http = require('http'); http.createServer(function (req, res) {

	res.writeHead(200, {'Content-Type': 'text/plain'});
	res.end('Hello World\n');
	
}).listen(Math.round((1 + Math.random()) * 1000), '127.0.0.1');
複製程式碼

通過node worker.js啟動它,將會偵聽1000到2000之間的一個隨機埠 將以下程式碼存為master.js,並通過node master.js啟動它:

/**
 * 充分利用cup的資源同時啟動在多個程式上啟動服務 
*/
const cpus = require("os").cpus();
const fork = require("child_process").fork;

for (let index = 0; index < cpus.length; index++) {
  fork("./worker.js");
}
複製程式碼

這段程式碼將會根據當前機器上的CPU數量複製出對應Node程式數。在*nix系統下可以通過ps aux | grep worker.js檢視到程式的數量,如下所示

nodejs玩兒轉程式

建立子程式

child_process 模組賦予了node可以隨意建立子程式的能力 ,它提供了4個方法用於建立子程式:

spawn(): 啟動一個子程式來執行命令 exec: 啟動一個子程式來執行命令,與spawn不同的是其介面不同,他有一個回掉函式來獲知子程式的狀況。 execFile():啟動一個子程式來執行可執行檔案。 fork():與spawn()類似,不同點在於它建立Node的子程式只需指定要執行的JavaScript檔案模組即可。

spawn()與exec()、execFile()的不同是:
後兩者建立時可以指定timeout屬性設定超時時間,一旦建立的程式執行超過設定的時間將會
被殺死。
複製程式碼

exec()與execFile()不同的是,exec()適合執行已有的命令execFile()適合執行檔案。這裡我們以一個尋常命令為例,node worker.js分別用上述4種方法實現,如下所示

var cp = require('child_process');
//spawn
cp.spawn('node', ['worker.js']);
//exec
cp.exec('node worker.js', function (err, stdout, stderr) {
    // some code 
});
//execFile
cp.execFile('worker.js', function (err, stdout, stderr) { 
	// some code
}); 
//fork
cp.fork('./worker.js');
複製程式碼

nodejs玩兒轉程式

如果是JavaScript檔案通過execFile()執行,它的首行內容必須新增如下程式碼

#!/usr/bin/env node
複製程式碼

儘管4種建立子程式的方式有些差別,但事實上後面3種方法都是spawn()的延伸應用

程式間通訊

主執行緒與工作執行緒之間通過onmessage()和postMessage()進行通訊,子程式物件則由send() 方法實現主程式向子程式傳送資料message事件實現收聽子程式發來的資料,與API在一定 程度上相似。通過訊息傳遞內容,而不是共享或直接操作相關資源,這是較為輕量和無依賴 的做法

parent.js

// 
var cp = require('child_process');
var n = cp.fork(__dirname + '/sub.js');
n.on('message', function (m) { console.log('PARENT got message:', m);
});
n.send({hello: 'world'});
複製程式碼

sub.js

process.on('message', function (m) { 
	console.log('CHILD got message:', m);
});
process.send({foo: 'bar'});
複製程式碼

通過fork()或者其他API,建立子程式之後,為了實現父子程式之間的通訊,父程式與子程式之間將會建立IPC通道。通過IPC通道,父子程式之間才能通過message和send()傳遞訊息

程式間通訊原理

IPC的全稱是Inter-Process Communication,即程式間通訊 程式間通訊的目的是為了讓不同的程式能夠互相訪問資源並進行協調工作

實現程式間通訊的技術有很多,如 命名管道 匿名管道 socket 訊號量 共享記憶體 訊息佇列 Domain Socket

Node中實現IPC通道的是管道(pipe) 技術。但此管道非彼管道,在Node中管道是個抽象層面的稱呼,具體細節實現由libuv提供,在 Windows下由命名管道(named pipe)實現,*nix系統則採用Unix Domain Socket實現。表現在應用層上的程式間通訊只有簡單的message事件和send()方法,介面十分簡潔和訊息化。下圖為IPC 建立和實現的示意圖。

nodejs玩兒轉程式
父程式在實際建立子程式之前,會建立IPC通道並監聽它,然後才真正建立出子程式並通 過環境變數(NODE_CHANNEL_FD)告訴子程式這個IPC通道的檔案描述符。子程式在啟動的過程中, 根據檔案描述符去連線這個已存在的IPC通道,從而完成父子程式之間的連線

nodejs玩兒轉程式

控制程式碼傳遞

建立好程式之間的IPC後,如果僅僅只用來傳送一些簡單的資料,顯然不夠我們的實際應用 使用 如果讓服務都監聽 到相同的埠,將會有什麼樣的結果?

這時只有一個工作程式能夠監聽到該埠上,其餘的程式在監聽的過程中都丟擲了 EADDRINUSE異常,這是埠被佔用的情況,新的程式不能繼續監聽該埠了。這個問題破壞了我 們將多個程式監聽同一個埠的想法。要解決這個問題,通常的做法是讓每個程式監聽不同的端 口,其中主程式監聽主埠(如80),主程式對外接收所有的網路請求,再將這些請求分別代理 到不同的埠的程式上。示意圖如圖9-4所示。

nodejs玩兒轉程式
通過代理,可以避免埠不能重複監聽的問題,甚至可以在代理程式上做適當的負載均衡, 使得每個子程式可以較為均衡地執行任務。由於程式每接收到一個連線,將會用掉一個檔案描述 符,因此代理方案中客戶端連線到代理程式,代理程式連線到工作程式的過程需要用掉兩個檔案 描述符。作業系統的檔案描述符是有限的,代理方案浪費掉一倍數量的檔案描述符的做法影響了 系統的擴充套件能力

主程式程式碼如下所示
var child = require('child_process').fork('child.js');
// Open up the server object and send the handle 
var server = require('net').createServer(); 
server.on('connection', function (socket) {
	socket.end('handled by parent\n'); 
});
server.listen(1337, function () { 
	child.send('server', server);
});
複製程式碼
子程式程式碼如下所示:
process.on('message', function (m, server) { 
	if (m === 'server') {
		server.on('connection', function (socket) { 
			socket.end('handled by 	child\n');
		}); 
	}
});
複製程式碼

然後新開一個命令列視窗,用上curl工具,如下所示:

$ curl "http://127.0.0.1:1337/" 
handled by parent
$ curl "http://127.0.0.1:1337/"
handled by child
$ curl "http://127.0.0.1:1337/" 
handled by child
$ curl "http://127.0.0.1:1337/" 
handled by parent
複製程式碼

命令列中的響應結果也是很不可思議的,這裡子程式和父程式都有可能處理我們客戶端發起 的請求。 試試將服務傳送給多個子程式,如下所示: parent.js

var cp = require('child_process'); 
var child1 = cp.fork('child.js'); 
var child2 = cp.fork('child.js');
// Open up the server object and send the handle 
var server = require('net').createServer(); 
server.on('connection', function (socket) {
	socket.end('handled by parent\n'); 
});
server.listen(1337, function () { 
	child1.send('server', server); 		
	child2.send('server', server);
});

複製程式碼

然後在子程式中將程式ID列印出來,如下所示: // child.js

process.on('message', function (m, server) { 
	if (m === 'server') {
		server.on('connection', function (socket) {
			socket.end('handled by child, pid is ' + process.pid + '\n');
		}); 
	}
});
複製程式碼

再用curl測試我們的服務,如下所示:

$ curl "http://127.0.0.1:1337/" 
handled by child, pid is 24673 
$ curl "http://127.0.0.1:1337/" 
handled by parent
$ curl "http://127.0.0.1:1337/" 
handled by child, pid is 24672
複製程式碼

測試的結果是每次出現的結果都可能不同,結果可能被父程式處理,也可能被不同的子程式 處理。 其實我們可以在父程式啟動之後立馬把他close掉

const cp = require("child_process")
const child1 = cp.fork("child.js");
const child2 = cp.fork('child.js');
const server = require("net").createServer();

server.on("connection",(socket)=>{
  socket.end('handled by parent\n');
})

server.listen(1337,()=>{
  child2.send('server', server);
  child1.send('server', server);
  server.close();
})
複製程式碼

整個過程中,服務的過程發生了一次改變,如

nodejs玩兒轉程式
主程式傳送完控制程式碼並關閉監聽之後成為了下圖所示的結構

nodejs玩兒轉程式
我們神奇地發現,多個子程式可以同時監聽相同埠,再沒有EADDRINUSE異常發生了

控制程式碼傳送與還原

控制程式碼傳送跟我們直接將伺服器物件傳送給子程式有沒有差別?它是否真的將伺服器物件傳送給了子程式?為什麼它可以傳送到多個子程式 中?傳送給子程式為什麼父程式中還存在這個物件? 目前子程式物件send()方法可以傳送的控制程式碼型別包括如下幾種。

  • net.Socket。TCP套接字。
  • net.Server。TCP伺服器,任意建立在TCP服務上的應用層服務都可以享受到它帶來的好處。
  • net.Native。C++層面的TCP套接字或IPC管道。
  • dgram.Socket。UDP套接字。
  • dgram.Native。C++層面的UDP套接字。

send()方法在將訊息傳送到IPC管道前,將訊息組裝成兩個物件,一個引數是handle,另一個 是message。message引數如下所示

{
	cmd: 'NODE_HANDLE',
	type: 'net.Server', 
	msg: message
}
複製程式碼

傳送到IPC管道中的實際上是我們要傳送的控制程式碼檔案描述符,檔案描述符實際上是一個整數 值。這個message物件在寫入到IPC管道時也會通過JSON.stringify()進行序列化。所以最終傳送 到IPC通道中的資訊都是字串,send()方法能傳送訊息和控制程式碼並不意味著它能傳送任意物件。

連線了IPC通道的子程式可以讀取到父程式發來的訊息,將字串通過JSON.parse()解析還 原為物件後,才觸發message事件將訊息體傳遞給應用層使用。在這個過程中,訊息物件還要被 進行過濾處理,message.cmd的值如果以NODE_為字首,它將響應一個內部事件internalMessage。

 如果message.cmd值為NODE_HANDLE,它將取出message.type值和得到的檔案描述符
 一起還原出一個對應的物件。這個過程的示意圖如圖所示
複製程式碼

nodejs玩兒轉程式
以傳送的tcp伺服器控制程式碼為例 子程式收到訊息後的還原過程如下

function(message, handle, emit) {
 var self = this;
var server = new net.Server(); 
server.listen(handle, function() {
	emit(server); 4 });
}
複製程式碼

上面的程式碼中,子程式根據message.type建立對應TCP伺服器物件,然後監聽到檔案描述符上。由於底層細節不被應用層感知,所以在子程式中,開發者會有一種伺服器就是從父程式中直接傳遞過來的錯覺。值得注意的是,Node程式之間只有訊息傳遞,不會真正地傳遞物件,這種錯 覺是抽象封裝的結果

埠共同監聽

多個程式可以監聽到 相同的埠而不引起EADDRINUSE異常?

我們獨立啟動的程式中,TCP伺服器端socket套接字的檔案描述符並不相同,導致監聽到相同的埠時會丟擲異常。

Node底層對每個埠監聽都設定了SO_REUSEADDR選項,這個選項的涵義是不同程式可以就相 同的網路卡和埠進行監聽,這個伺服器端套接字可以被不同的程式複用,如下所示

setsockopt(tcp->io_watcher.fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on))
複製程式碼

由於獨立啟動的程式互相之間並不知道檔案描述符,所以監聽相同埠時就會失敗。但對於 send()傳送的控制程式碼還原出來的服務而言,它們的檔案描述符是相同的,所以監聽相同埠不會引 起異常

多個應用監聽相同埠時,檔案描述符同一時間只能被某個程式所用。換言之就是網路請求 向伺服器端傳送時,只有一個幸運的程式能夠搶到連線,也就是說只有它能為這個請求進行服務。 這些程式服務是搶佔式的。

叢集穩定之路

搭建好了叢集,充分利用了多核CPU資源,似乎就可以迎接客戶端大量的請求了。但請等等, 我們還有一些細節需要考慮。

  • 效能問題。
  • 多個工作程式的存活狀態管理。
  • 工作程式的平滑重啟。
  • 配置或者靜態資料的動態重新載入。
  • 其他細節。

程式事件

再次迴歸到子程式物件上,除了引人關注的send()方法和message事件外,子程式還有些什 麼呢?首先除了message事件外,Node還有如下這些事件:

error:當子程式無法被複制建立、無法被殺死、無法傳送訊息時會觸發該事件 exit:子程式退出時觸發該事件,子程式如果是正常退出,這個事件的第一個引數為退出 碼,否則為null。如果程式是通過kill()方法被殺死的,會得到第二個引數,它表示殺死程式時的訊號。 close:在子程式的標準輸入輸出流中止時觸發該事件,引數與exit相同 disconnect:在父程式或子程式中呼叫disconnect()方法時觸發該事件,在呼叫該方法時將關閉監聽IPC通道。

自動重啟

有了父子程式之間的相關事件之後,就可以在這些關係之間建立出需要的機制了。至少我們 能夠通過監聽子程式的exit事件來獲知其退出的資訊,接著前文的多程式架構,我們在主程式上 要加入一些子程式管理的機制,比如重新啟動一個工作程式來繼續服務。

nodejs玩兒轉程式
實現程式碼如下所示: master.js

// 主程式
const fork = require("child_process").fork;
const cpus = require("os").cpus();

// 建立tcp server
const server = require("net").createServer();
server.listen(1337);

const workers = {}

// 建立程式的函式
const createWorker = () =>{
  const worker = fork(__dirname+"/work.js");
  // 監聽程式退出事件 自動重啟
  worker.on("exit",()=>{
    delete workers[worker.pid];
    console.log(worker.pid+"is delete");
    createWorker();
  })
  // 傳送當前程式的控制程式碼檔案描述符
  worker.send("server",server)
  workers[worker.pid]= worker
  console.log('created worker :', worker.pid);
}

for (let index = 0; index < cpus.length; index++) {
  createWorker();
}

// 程式自己退出時,讓所有工作程式退出 
process.on('exit', function () {
  for (var pid in workers) { 
    workers[pid].kill();
  } 
});

console.log('process.pid', process.pid)
複製程式碼

work.js

var http = require('http');
var server = http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('handled by child, pid is' + process.pid + '\n'); 
});

var worker;
process.on('message', function (m, tcp) {
  if (m === 'server') {
    worker = tcp;
    worker.on('connection', function (socket) {
      server.emit('connection', socket); 
    });
  } 
});

process.on('uncaughtException', function () { // 停止接收新的連線
  worker.close(function () {
    // 所有已有連線斷開後,退出程式
    process.exit(1); 
  });
});
複製程式碼

測試一下上面的程式碼,如下所示:

$ node master.js
Create worker: 30504 
Create worker: 30505 
Create worker: 30506 
Create worker: 30507
複製程式碼

上述程式碼的處理流程是,一旦有未捕獲的異常出現,工作程式就會立即停止接收新的連線; 當所有連線斷開後,退出程式。主程式在偵聽到工作程式的exit後,將會立即啟動新的程式服務, 以此保證整個叢集中總是有程式在為使用者服務的。 通過kill命令殺死某個程式試試,如下所示

$ kill 30506
複製程式碼

結果是30506程式退出後,自動啟動了一個新的工作程式30518,總體程式數量並沒有發生改 變,如下所示:

Worker 30506 exited. 
Create worker. pid: 30518
複製程式碼

自殺訊號

當然上述程式碼存在的問題是要等到已有的所有連線斷開後程式才退出,在極端的情況下,所 有工作程式都停止接收新的連線,全處在等待退出的狀態。但在等到程式完全退出才重啟的過程 中,所有新來的請求可能存在沒有工作程式為新使用者服務的情景,這會丟掉大部分請求。

為此需要改進這個過程,不能等到工作程式退出後才重啟新的工作程式。當然也不能暴力退 出程式,因為這樣會導致已連線的使用者直接斷開。於是我們在退出的流程中增加一個自殺 (suicide)訊號。工作程式在得知要退出時,向主程式傳送一個自殺訊號,然後才停止接收新的 連線,當所有連線斷開後才退出。主程式在接收到自殺訊號後,立即建立新的工作程式服務。 程式碼改動如下所示:

// master.js 主要是重啟程式的任務放到了 接收到suicide 事件之後
// 主程式
const fork = require("child_process").fork;
const cpus = require("os").cpus();

// 建立tcp server
const server = require("net").createServer();
server.listen(1337);

const workers = {}

// 建立程式的函式
const createWorker = () =>{
  const worker = fork(__dirname+"/work.js");
  // 啟動新的程式
  worker.on('message', function (message) {
    if (message.act === 'suicide') { 
      createWorker();
    } 
  });
  
  worker.on("exit",()=>{
    delete workers[worker.pid];
    console.log(worker.pid+"is delete");
  })
  // 傳送當前程式的控制程式碼檔案描述符
  worker.send("server",server)
  workers[worker.pid]= worker
  console.log('created worker :', worker.pid);
}

for (let index = 0; index < cpus.length; index++) {
  createWorker();
}

// 程式自己退出時,讓所有工作程式退出 
process.on('exit', function () {
  for (var pid in workers) { 
    workers[pid].kill();
  } 
});

console.log('process.pid', process.pid)
複製程式碼

work.js主要是再接收到未捕獲的異常之後向主程式傳送事件告知子程式將要退出 此時建立新的程式為使用者服務 ,之後子程式才退出 再回頭看重啟資訊,如下所示:

created worker : 14397
14394is delete
複製程式碼

與前一種方案相比,建立新工作程式在前,退出異常程式在後。在這個可憐的異常程式退出 之前,總是有新的工作程式來替上它的崗位。至此我們完成了程式的平滑重啟,一旦有異常出現, 主程式會建立新的工作程式來為使用者服務,舊的程式一旦處理完已有連線就自動斷開。整個過程 使得我們的應用的穩定性和健壯性大大提高。示意圖如圖所示

nodejs玩兒轉程式

這裡存在問題的是有可能我們的連線是長連線,不是HTTP服務的這種短連線,等待長連線 斷開可能需要較久的時間。為此為已有連線的斷開設定一個超時時間是必要的,在限定時間裡強 制退出的設定如下所示:

process.on('uncaughtException', function (err) {
process.send({act: 'suicide'}); 2 // 停止接收新的連線
   worker.close(function () {
   	// 所有已有連線斷開後,退出程式
   	process.exit(1);
   }); // 5秒後退出程式
   setTimeout(function () {
     process.exit(1); 
   }, 5000);
});
複製程式碼

程式中如果出現未能捕獲的異常,就意味著有那麼一段程式碼在健壯性上是不合格的。為此退 出程式前,通過日誌記錄下問題所在是必須要做的事情,它可以幫我們很好地定位和追蹤程式碼異 常出現的位置,如下所示:

process.on('uncaughtException', function (err) { // 記錄日誌
	logger.error(err);
	// 傳送自殺訊號
	process.send({act: 'suicide'}); // 停止接收新的連線 						
	worker.close(function () {
	// 所有已有連線斷開後,退出程式
		process.exit(1); 
	});
	// 5秒後退出程式 
	setTimeout(function () {
		process.exit(1);
	}, 5000);
});

複製程式碼

通過自殺訊號告知主程式可以使得新連線總是有程式服務,但是依然還是有極端的情況。工 作程式不能無限制地被重啟,如果啟動的過程中就發生了錯誤,或者啟動後接到連線就收到錯誤, 會導致工作程式被頻繁重啟,這種頻繁重啟不屬於我們捕捉未知異常的情況,因為這種短時間內 頻繁重啟已經不符合預期的設定,極有可能是程式編寫的錯誤。 為了消除這種無意義的重啟,在滿足一定規則的限制下,不應當反覆重啟。比如在單位時間 內規定只能重啟多少次,超過限制就觸發giveup事件,告知放棄重啟工作程式這個重要事件。 為了完成限量重啟的統計,我們引入一個佇列來做標記,在每次重啟工作程式之間進行打點 並判斷重啟是否太過頻繁,如下所示:

// 重啟次數 
var limit = 10;
// 時間單位
var during = 60000;
var restart = [];
var isTooFrequently = function () {
	// 記錄重啟時間
	var time = Date.now();
	var length = restart.push(time);
	if (length > limit) {
		// 取出最後10個記錄
		restart = restart.slice(limit * -1);
	}
	// 最後一次重啟到前10次重啟之間的時間間隔
	return restart.length >= limit && restart[restart.length - 1] - restart[0] < during; 
};
var workers = {};
var createWorker = function () {
	// 檢查是否太過頻繁
	if (isTooFrequently()) {
	// 觸發giveup事件後,不再重啟 
		process.emit('giveup', length, during);
		return;
	}
	var worker = fork(__dirname + '/worker.js'); 
	worker.on('exit', function () {
		console.log('Worker ' + worker.pid + ' exited.');
		delete workers[worker.pid]; 
	});
	// 重新啟動新的程式
	worker.on('message', function (message) {
		if (message.act === 'suicide') {
		 createWorker();
		} 
	});
// 控制程式碼轉發
worker.send('server', server); workers[worker.pid] = worker;
console.log('Create worker. pid: ' + worker.pid);
};
複製程式碼

giveup事件是比uncaughtException更嚴重的異常事件。uncaughtException只代表叢集中某個 工作程式退出,在整體性保證下,不會出現使用者得不到服務的情況,但是這個giveup事件則表示 叢集中沒有任何程式服務了,十分危險。為了健壯性考慮,我們應在giveup事件中新增重要日誌, 並讓監控系統監視到這個嚴重錯誤,進而報警等。

負載均衡

在多程式之間監聽相同的埠,使得使用者請求能夠分散到多個程式上進行處理,這帶來的好 處是可以將CPU資源都呼叫起來。這猶如飯店將客人的點單分發給多個廚師進行餐點製作。既然 涉及多個廚師共同處理所有選單,那麼保證每個廚師的工作量是一門學問,既不能讓一些廚師忙不過來,也不能讓一些廚師閒著,這種保證多個處理單元工作量公平的策略叫負載均衡Node預設提供的機制是採用作業系統的搶佔式策略。所謂的搶佔式就是在一堆工作程式中,閒著的程式對到來的請求進行爭搶,誰搶到誰服務。

一般而言,這種搶佔式策略對大家是公平的,各個程式可以根據自己的繁忙度來進行搶佔。對於Node而言,它的繁忙是由CPU、I/O兩個部分構成的影響搶佔的是CPU 的繁忙度對不同的業務,可能存在I/O繁忙,而CPU較為空閒的情況,這可能造成某個程式能 夠搶到較多請求,形成負載不均衡的情況

為此Node在v0.11中提供了一種新的策略使得負載均衡更合理,這種新的策略叫 Round-Robin,又叫輪叫排程。輪叫排程的工作方式是由主程式接受連線,將其依次分發給工作 程式。分發的策略是在N個工作程式中,每次選擇第i = (i + 1) mod n個程式來傳送連線。在cluster 模組中啟用它的方式如下:

狀態共享

Node程式中不宜存放太多資料,因為它會加重垃圾回收的負擔,進 而影響效能。同時,Node也不允許在多個程式之間共享資料。但在實際的業務中,往往需要共享 一些資料,譬如配置資料,這在多個程式中應當是一致的。為此,在不允許共享資料的情況下, 我們需要一種方案和機制來實現資料在多個程式之間的共享。

  1. 第三方資料儲存 解決資料共享最直接、簡單的方式就是通過第三方來進行資料儲存,比如將資料存放到資料 庫、磁碟檔案、快取服務(如Redis)中,所有工作程式啟動時將其讀取進記憶體中。但這種方式 存在的問題是如果資料發生改變,還需要一種機制通知到各個子程式,使得它們的內部狀態也得 到更新。 實現狀態同步的機制有兩種,一種是各個子程式去向第三方進行定時輪詢,示意圖如圖所示。

nodejs玩兒轉程式
實現狀態同步的機制有兩種:

   一種是各個子程式去向第三方進行定時輪詢
複製程式碼

定時輪詢帶來的問題是輪詢時間不能過密,如果子程式過多,會形成併發處理,如果資料沒 有發生改變,這些輪詢會沒有意義,白白增加查詢狀態的開銷。如果輪詢時間過長,資料發生改 變時,不能及時更新到子程式中,會有一定的延遲。 2. 主動通知 一種改進的方式是當資料發生更新時,主動通知子程式。當然,即使是主動通知,也需要一 種機制來及時獲取資料的改變。這個過程仍然不能脫離輪詢,但我們可以減少輪詢的程式數量, 我們將這種用來傳送通知和查詢狀態是否更改的程式叫做通知程式。為了不混合業務邏輯,可以 將這個程式設計為只進行輪詢和通知,不處理任何業務邏輯,示意圖如圖所示

nodejs玩兒轉程式
這種推送機制如果按程式間訊號傳遞,在跨多臺伺服器時會無效,是故可以考慮採用TCP或 UDP的方案。程式在啟動時從通知服務處除了讀取第一次資料外,還將程式資訊註冊到通知服務 處。一旦通過輪詢發現有資料更新後,根據註冊資訊,將更新後的資料傳送給工作程式。由於不涉及太多程式去向同一地方進行狀態查詢,狀態響應處的壓力不至於太過巨大,單一的通知服務 輪詢帶來的壓力並不大,所以可以將輪詢時間調整得較短,一旦發現更新,就能實時地推送到各個子程式中。

Cluster 模組 v0.8時直接引入了cluster模組,用以解決多核CPU的利用率問題,同時也提供了較完 善的API,用以處理程式的健壯性問題 對於開頭提到的建立Node程式叢集,cluster實現起來也是很輕鬆的事情,如下所示

// 事實上cluster模組就是child_process和net模組的組合應用
const cluster = require("cluster");
const cpus = require('os').cpus();

cluster.setupMaster({
  exec: "worker.js"
})

for(var i = 0; i < cpus.length; i++) {
  cluster.fork();
}

複製程式碼

Cluster 事件

對於健壯性處理,cluster模組也暴露了相當多的事件。

fork:複製一個工作程式後觸發該事件。
online:複製好一個工作程式後,工作程式主動傳送一條online訊息給主程式,主程式收到訊息後,觸發該事件。
listening:工作程式中呼叫listen()(共享了伺服器端Socket)後,傳送一條listening訊息給主程式,主程式收到訊息後,觸發該事件。
disconnect:主程式和工作程式之間IPC通道斷開後會觸發該事件。
exit:有工作程式退出時觸發該事件。
setup:cluster.setupMaster()執行後觸發該事件。
複製程式碼

這些事件大多跟child_process模組的事件相關,在程式間訊息傳遞的基礎上完成的封裝。 這些事件對於增強應用的健壯性已經足夠了 儘管通過child_process模組可以大幅提升Node的穩定性,但是一旦主程式出現問題,所 有子程式將會失去管理。在Node的程式管理之外,還需要用監聽程式數量或監聽日誌的方式確 保整個系統的穩定性,即使主程式出錯退出,也能及時得到監控警報,使得開發者可以及時處 理故障

歡迎訪問我的部落格

相關文章