node是單線程運行,我們的node項目如何利用多核CPU的資源,同時提高node服務的穩定性呢?
1. node的單線程
進程是一個具有一定獨立功能的程序在一個數據集上的一次動態執行的過程,是操作系統進行資源分配和調度的一個獨立單位,是應用程序運行的載體。
線程是程序執行中一個單一的順序控制流,它存在於進程之中,是比進程更小的能獨立運行的基本單位。
早期在單核 CPU 的系統中,為了實現多任務的運行,引入了進程的概念,不同的程序運行在數據與指令相互隔離的進程中,通過時間片輪轉調度執行,由於 CPU 時間片切換與執行很快,所以看上去像是在同一時間運行了多個程序。
由於進程切換時需要保存相關硬件現場、進程控制塊等信息,所以系統開銷較大。為了進一步提高系統吞吐率,在同一進程執行時更充分的利用 CPU 資源,引入了線程的概念。線程是操作系統調度執行的最小單位,它們依附於進程中,共享同一進程中的資源,基本不擁有或者只擁有少量系統資源,切換開銷極小。
Node是基於V8引擎之上構建的,決定了他與瀏覽器的機制很類似。
一個node進程只能利用一個核,而且node只能運行在單線程中,嚴格意義上,node並非真正的單線程架構,即一個進程內可以有多個線程,因為node自己還有一定的i/o線程存在,這些I/O線程由底層的libuv處理,但這些線程對node開發者而言是完成透明的,只有在C++擴展時才會用到,這里我們就屏蔽底層的細節,專門討論我們所要關注的。
單線程的好處是:程序狀態單一,在沒有多線程的情況下,沒有鎖、線程同步問題,操作系統在調度時,也因為較少的上下文的切換,可以很好地提高CPU的使用率。然而單核單線程也有相應的缺點:
- 這個線程掛掉后整個程序就會掛掉;
- 無法充分利用多核資源
2. node多進程的創建
node中有提供child_process
模塊,這個模塊中,提供了多個方法來創建子進程。
const { spawn, exec, execFile, fork } = require('child_process');
這4個方法都可以創建子進程,不過使用方法還是稍微有點區別。我們以創建一個子進程計算斐波那契數列數列為例,子進程的文件(worker.js):
// worker.js
const fib = (num) => {
if (num === 1 || num === 2) {
return num;
}
let a = 1, b = 2, sum = 0;
for (let i = 3; i <= num; i++) {
sum = a + b;
a = b;
b = sum;
}
return sum;
}
const num = Math.floor(Math.random() * 10) + 3;
const result = fib(num);
console.log(num, result, process.pid); // process.pid表示當前的進程id
在master.js中如何調用這些方法創建子進程呢?
命令 | 使用方法 | 解析 |
---|---|---|
spawn | spawn('node', ['worker.js']) | 啟動一個字進程來執行命令 |
exec | exec('node worker.js', (err, stdout, stderr) => {}) | 啟動一個子進程來執行命令,有回調 |
execFile | exexFile('worker.js') | 啟動一個子進程來執行可執行的文件 (頭部要添加#!/usr/bin/env node) |
fork | fork('worker.js') | 與spawn類似,不過這里只需要自定js文件模塊即可 |
以fork命令為例:
const { fork } = require('child_process');
const cpus = require('os').cpus();
for(let i=0, len=cpus.length; i<len; i++) {
fork('./worker.js');
}
3. 多進程之間的通信
node中進程的通信主要在主從(子)進程之間進行通信,子進程之間無法直接通信,若要相互通信,則要通過主進程進行信息的轉發。
主進程和子進程之間是通過IPC(Inter Process Communication,進程間通信)進行通信的,IPC也是由底層的libuv根據不同的操作系統來實現的。
我們還是以計算斐波那契數列數列為例,在這里,我們用cpu個數減1個的進程來進行計算,剩余的那一個用來輸出結果。這就需要負責計算的子進程,要把結果傳給主進程,再讓主進程傳給輸出進行,來進行輸出。這里我們需要3個文件:
- master.js:用來創建子進程和子進程間的通信;
- fib.js:計算斐波那契數列;
- log.js:輸出斐波那契數列計算的結果;
主進程:
// master.js
const { fork } = require('child_process');
const cpus = require('os').cpus();
const logWorker = fork('./log.js');
for(let i=0, len=cpus.length-1; i<len; i++) {
const worker = fork('./fib.js');
worker.send(Math.floor(Math.random()*10 + 4)); // 要計算的num
worker.on('message', (data) => { // 計算后返回的結果
logWorker.send(data); // 將結果發送給輸出進程
})
}
計算進程:
// fib.js
const fib = (num) => {
if (num===1 || num===2) {
return num;
}
let a=1, b=2, sum=0;
for(let i=3; i<num; i++) {
sum = a + b;
a = b;
b = sum;
}
return sum;
}
process.on('message', num => {
const result = fib(num);
process.send(JSON.stringify({
num,
result,
pid: process.pid
}))
})
輸出進程:
process.on('message', data => {
console.log(process.pid, data);
})
當我們運行master時,就能看到各個子進程計算的結果:
第1個數字表示當前輸出子進程的編號,后面表示在各個子進程計算的數據。
同理,我們在進行http服務日志記錄時,也可以采用類似的思路,多個子進程承擔http服務,剩下的子進程來進行日志記錄等操作。
當我想用子進程創建服務器時,采用上面類似斐波那契數列的思路,將fib.js改為httpServer.js:
// httpServer.js
const http = require('http');
http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/html'
});
res.end(Math.random()+'');
}).listen(8080);
console.log('http server has started at 8080, pid: '+process.pid);
結果卻出現錯誤了,提示8080端口已經被占用了:
Error: listen EADDRINUSE: address already in use :::8080
這是因為:在TCP端socket套接字監聽端口有一個文件描述符,每個進程的文件描述符都不相同,監聽相同端口時就會失敗。
解決方案有兩種:首先最簡單的就是每個子進程都使用不同的端口,主進程將循環的標識給子進程,子進程通過這個標識來使用相關的端口(例如從8080+傳入的標識作為當前進程的端口號)。
第二種方案是,在主進程進行端口的監聽,然后將監聽的套接字傳給子進程。
主進程:
// master.js
const fork = require('child_process').fork;
const net = require('net');
const server = net.createServer();
const child1 = fork('./httpServer1.js'); // random
const child2 = fork('./httpServer2.js'); // now
server.listen(8080, () => {
child1.send('server', server);
child2.send('server', server);
server.close();
})
httpServer1.js:
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain'
});
res.end(Math.random()+', at pid: ' + process.pid);
});
process.on('message', (type, tcp) => {
if (type==='server') {
tcp.on('connection', socket => {
server.emit('connection', socket)
})
}
})
httpServer2.js:
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/plain'
});
res.end(Date.now()+', at pid: ' + process.pid);
});
process.on('message', (type, tcp) => {
if (type==='server') {
tcp.on('connection', socket => {
server.emit('connection', socket)
})
}
})
我們的2個server,一個是輸出隨機數,一個是輸出當前的時間戳,可以發現這兩個server都可以正常的運行。同時,因為這些進程服務是搶占式的,哪個進程搶到連接,就哪個進程處理請求。
我們也應當知道的是:
每個進程之間的內存數據是不互通的,若我們在某一進程中使用變量緩存了數據,另一個進程是讀取不到的。
4. 多進程的守護
剛才我們在第3部分創建的多進程,解決了多核CPU利用率的問題,接下來要解決進程穩定的問題。
每個子進程退出時,都會觸發exit事件
,因此我們通過監聽exit事件來獲知有進程退出了,這時,我們就可以創建一個新的進程來替代。
const fork = require('child_process').fork;
const cpus = require('os').cpus();
const net = require('net');
const server = net.createServer();
const createServer = () => {
const worker = fork('./httpServer.js');
worker.on('exit', () => {
// 當有進程退出時,則創建一個新的進程
console.log('worker exit: ' + worker.pid);
createServer();
});
worker.send('server', server);
console.log('create worker: ' + worker.pid);
}
server.listen(8080, () => {
for(let i=0, len=cpus.length; i<len; i++) {
createServer();
}
})
cluster模塊
在多進程守護這塊,node也推出了cluster模塊
,用來解決多核CPU的利用率問題。同時cluster中也提供了exit事件來監聽子進程的退出。
一個經典的案例:
const cluster = require('cluster');
const http = require('http');
const cpus = require('os').cpus();
if (cluster.isMaster) {
console.log(`主進程 ${process.pid} 正在運行`);
// 衍生工作進程。
for (let i = 0, len=cpus.length; i < len; i++) {
cluster.fork();
}
cluster.on('exit', (worker) => {
console.log(`工作進程 ${worker.process.pid} 已退出`);
cluster.fork();
});
} else {
http.createServer((req, res) => {
res.writeHead(200);
res.end(Math.random()+ ', at pid: ' + process.pid);
}).listen(8080);
console.log(`工作進程 ${process.pid} 已啟動`);
}
5. 總結
node雖然是單線程運行的,但我們可以通過創建多個子進程,來充分利用多核CPU資源,通過可以監聽進程的一些事件,來感知每個進程的運行狀態,來提高我們項目整體的穩定性。
歡迎關注我的公眾號,查閱更多的前端文章: