cluster,你真的弄明白了嗎?
在上一篇文章中,我們已經了解到了cluster模塊的基本使用,cluster使用起來非常簡單,
const cluster = require('cluster'); const http = require('http'); const numCPUs = require('os').cpus().length; if (cluster.isMaster) { for (let i = 0; i < numCPUs; i++) { cluster.fork(); } cluster.on('exit', (worker, code, signal) => { console.log(`工作進程 ${worker.process.pid} 已退出`); }); } else { http.createServer((req, res) => { res.writeHead(200); res.end('hello world\n'); }).listen(8000); }
上面的代碼就簡單的將cluster模塊加入到了node.js項目中。但是仔細分析一下這段代碼你可能會產生這些疑問:主進程僅僅fork出了子進程,並沒有創建httpserver,說好的主進程接收請求分發給子進程呢?每一個子進程都創建了一個httpserver,並偵聽同一個端口?是這樣的嗎?局面好像很尷尬。如果僅僅知道上面的這段代碼,似乎無法解決我們的疑惑。那就到源代碼中去瞧瞧,推薦一篇好文。如果你也感興趣,打開源碼過一遍,整個世界就明朗了。
cluster.js
'use strict'; const childOrMaster = 'NODE_UNIQUE_ID' in process.env ? 'child' : 'master'; module.exports = require(`internal/cluster/${childOrMaster}`);
上面的三行代碼就是cluster.js的全部內容,可以看出,子進程和主進程的區分是通過‘NODE_UNIQUE_ID’來判斷的。我們分析cluster.fork方法可以發現,在createworkprocess中都會對NODE_UNIQUE_ID進行賦值,而master進程中是沒有NODE_UNIQUE_ID的。所以再demo程序中可以分別在主進程和子進程中執行不同的內容。因此主進程執行完后,就僅僅fork出了子進程。
主進程httpserver
主進程執行完畢后,子進程開始執行響應的代碼,子進程首先創建httpserver,然后監聽端口號,而正是這個listen方法,暗藏着問題的關鍵。http模塊http.server繼承了net模塊的net.server,那我們就來看看net.js中的Server.prototype.listen干了哪些事。
在listen中主要執行了listenInCluster方法,其輸入信息包含ip,端口號,地址類型,backlog和fd等,listenInCluster函數主要內容如上圖中所示,當當前進程是主進程時,直接創建服務監聽;如果是子進程,則執行_getserver函數,該函數位於lib/internal/cluster/child.js中,它會傳入創建httpserver所需要的端口等相關信息,並向主進程發送‘serverQuery’指令,主進程接收到‘serverQuery’指令后,會new出一個RoundRobinHandle的實例,在這個過程中,主進程服務被創建,端口被監聽,子進程被加入到調度度列中。這些完成后,子進程執行回調函數,繼續后續操作。
子進程服務創建
在上面的圖中還有一個_listen2()方法,該函數對應執行的函數為setupListenHandle(),
function setupListenHandle(address, port, addressType, backlog, fd) { //... if (!address && typeof fd !== 'number') { rval = createServerHandle('::', port, 6, fd); //... if (rval === null) rval = createServerHandle(address, port, addressType, fd); //... this[async_id_symbol] = getNewAsyncId(this._handle); this._handle.onconnection = onconnection; this._handle.owner = this; //... }
通過createServerHandle函數創建句柄(句柄可理解為用戶空間的socket),同時給屬性onconnection賦值,最后偵聽端口,設定backlog。那么,socket處理請求過程“socket(),bind()”步驟就是在createServerHandle完成。
子進程是否也對端口進行了監聽?
我們在將實現回到child.js中的_getServer()函數,當子進程向主進程發送完消息后,執行回調函數。由於cluster默認的策略是round-robbin,所以會執行rr()函數:
function rr(message, indexesKey, cb) {
if (message.errno)
return cb(message.errno, null);
var key = message.key;
function listen(backlog) {
// TODO(bnoordhuis) Send a message to the master that tells it to
// update the backlog size. The actual backlog should probably be
// the largest requested size by any worker.
return 0;
}
//...
const handle = { close, listen, ref: noop, unref: noop };
handles[key] = handle;
cb(0, handle);
}
從上面的代碼中可以看出,在listen()中直接返回,沒有做任何操作。因此子進程服務沒有創建對底層服務端socket的進行監聽,所以自然不會出現子進程端口復用的情況。最后,調用cb函數,將fake后的handle傳遞給上層net.Server,設置net.Server對底層的socket的引用。此后,子進程利用fake后的handle做端口偵聽(其實壓根啥都沒有做),執行成功后返回。
client通過tcp連接向主進程發送請求,那主進程又是如何將請求傳遞給子進程處理呢?
子進程TCP服務器沒有創建底層socket,它主要依賴IPC通道與主進程通信,既然主進程負責接受客戶端請求,那么理所應當由主進程分發客戶端請求給某個子進程,由子進程處理請求。具體分配給哪個子進程處理,是由round-robbine分發策略來決定的。由於子進程在server中設置了對底層的socket的引用,所以子進程接收到任務后,觸發connection事件開始執行業務邏輯。
對於該部分還需要持續關注,因為涉及底層libuv,需要結合C++代碼一起理解。比如:IPC通信方式有多種,node.js是如何決定使用哪種方式來通信?
總結
對於cluster的分析,得出以下結論:
1. cluster在創建子進程時,會在環境變量中增加標識,以此來區分主進程和子進程
2. listen函數在實現時對主進程和子進程進行了區分,在不同的進程中會執行不同操作
3. nodeJS封裝了進程間通信的方法,支持在進程間發送句柄的功能,句柄可以是一個socket對象,一個管道等等
4. 一個端口只能被一個進程監聽,但是該端口可以建立多個連接(accpet是產生的套接字),不同進程間可以共享這些套接字
5. 子進程的listen函數並沒有監聽端口,它在listen時將端口和地址等信息發送給主進程,由主進程進行監聽。
主進程在收到accept事件時,產生連接socket,並把它發送給子進程。子進程直接通過該socket跟client端進行通信