### 由表及裡 HTTP伺服器用於響應來自客戶端的請求,當客戶端請求數逐漸增大時服務端的處理機制有多種,如tomcat的多執行緒、nginx的事件迴圈等。而對於node而言,由於其也採用事件迴圈和非同步I/O機制,因此在高I/O併發的場景下效能非常好,但是由於單個node程式僅僅利用單核cpu,因此為了更好利用系統資源就需要fork多個node程式執行HTTP伺服器邏輯,所以node內建模組提供了child_process和cluster模組。 利用childprocess模組,我們可以執行shell命令,可以fork子程式執行程式碼,也可以直接執行二進位制檔案;利用cluster模組,使用node封裝好的API、IPC通道和排程機可以非常簡單的建立包括
主程式建立多個子程式,同時接受子程式傳來的訊息,迴圈輸出處理請求的數量; 子程式建立http伺服器,偵聽8000埠並返回響應。 泛泛的大道理誰都瞭解,可是這套程式碼如何執行在主程式和子程式中呢?父程式如何向子程式傳遞客戶端的請求?多個子程式共同偵聽8000埠,會不會造成埠reuse error?每個伺服器程式最大可有效支援多少併發量?主程式下的代理伺服器如何排程請求? 這些問題,如果不深入進去便永遠只停留在寫應用程式碼的層面,而且不瞭解cluster叢集建立的多程式與使用child_process建立的程式叢集的區別,也寫不出符合業務的最優程式碼,因此,深入cluster還是有必要的。 ## cluster與net cluster模組與net模組息息相關,而net模組又和底層socket有聯絡,至於socket則涉及到了系統核心,這樣便由表及裡的瞭解了node對底層的一些優化配置,這是我們的思路。介紹前,筆者仔細研讀了node的js層模組實現,在基於自身理解的基礎上詮釋上節程式碼的實現流程,力圖做到清晰、易懂,如果有某些紕漏也歡迎讀者指出,只有在互相交流中才能收穫更多。 ### 一套程式碼,多次執行 很多人對code1程式碼如何在主程式和子程式執行感到疑惑,怎樣通過_cluster.isMaster判斷語句內的程式碼是在主程式執行,而其他程式碼在子程式執行呢? 其實只要你深入到了node原始碼層面,這個問題很容易作答。cluster模組的程式碼只有一句:
只需要判斷當前程式有沒有環境變數“NODE_UNIQUE_ID”就可知道當前程式是否是主程式;而變數“NODE_UNIQUE_ID”則是在主程式fork子程式時傳遞進去的引數,因此採用cluster.fork建立的子程式是一定包含“NODE_UNIQUE_ID”的。 這裡需要指出的是,必須通過cluster.fork建立的子程式才有NODE_UNIQUE_ID變數,如果通過child_process.fork的子程式,在不傳遞環境變數的情況下是沒有NODE_UNIQUE_ID的。因此,當你在child_process.fork的子程式中執行
時,http模組會呼叫net模組(確切的說,http.Server繼承net.Server),建立net.Server物件,同時偵聽埠。建立net.Server例項,呼叫建構函式返回。建立的net.Server例項呼叫listen(8000),等待accpet連線。那麼,子程式如何傳遞伺服器相關資訊給主程式呢?答案就在listen函式中。我保證,net.Server.prototype.listen函式絕沒有表面上看起來的那麼簡單,它涉及到了許多IPC通訊和相容性處理,可以說HTTP伺服器建立的所有邏輯都在listen函式中。 > 延伸下,在學習linux下的socket程式設計時,服務端的邏輯依次是執行
由於本文只探究cluster模式下HTTP伺服器的相關內容,因此我們只關注有關TCP伺服器部分,其他的Pipe(domain socket)服務不考慮。 listen函式可以偵聽埠、路徑和指定的fd,因此在listen函式的實現中判斷各種引數的情況,我們最為關心的就是偵聽埠的情況,在成功進入條件語句後發現所有的情況最後都執行了listenInCluster函式而返回,因此有必要繼續探究。 code3
listenInCluster函式傳入了各種引數,如server例項、ip、port、ip型別(IPv6和IPv4)、backlog(底層服務端socket處理請求的最大佇列)、fd等,它們不是必須傳入,比如建立一個TCP伺服器,就僅僅需要一個port即可。 簡化後的listenInCluster函式很簡單,cluster模組判斷當前程式為主程式時,執行_listen2函式;否則,在子程式中執行cluster._getServer函式,同時像函式傳遞serverQuery物件,即建立伺服器需要的相關資訊。 因此,我們可以大膽假設,子程式在cluster._getServer函式中向主程式傳送了建立伺服器所需要的資料,即serverQuery。實際上也確實如此: code4
子程式在該函式中向已建立的IPC通道傳送內部訊息message,該訊息包含之前提到的serverQuery資訊,同時包含act: ‘queryServer’欄位,等待服務端響應後繼續執行回撥函式modifyHandle。 主程式接收到子程式傳送的內部訊息,會根據act: ‘queryServer’執行對應queryServer方法,完成伺服器的建立,同時傳送回覆訊息給子程式,子程式執行回撥函式modifyHandle,繼續接下來的操作。 至此,針對主程式在cluster模式下如何建立伺服器的流程已完全走通,主要的邏輯是在子程式伺服器的listen過程中實現。 ### net模組與socket 上節提到了node中建立伺服器無法與socket建立對應的問題,本節就該問題做進一步解釋。在net.Server.prototype.listen函式中呼叫了listenInCluster函式,listenInCluster會在主程式或者子程式的回撥函式中呼叫_listen2函式,對應底層服務端socket建立階段的正是在這裡。
通過createServerHandle函式建立控制程式碼(控制程式碼可理解為使用者空間的socket),同時給屬性onconnection賦值,最後偵聽埠,設定backlog。 那麼,socket處理請求過程“socket(),bind()”步驟就是在createServerHandle完成。
在createServerHandle中,我們看到了如何建立socket(createServerHandle在底層利用node自己封裝的類庫建立TCP handle),也看到了bind繫結ip和地址,那麼node的net模組如何接收客戶端請求呢? 必須深入c++模組才能瞭解node是如何實現在c++層面呼叫js層設定的onconnection回撥屬性,v8引擎提供了c++和js層的型別轉換和介面透出,在c++的tcp_wrap中:
我們關注uvlisten函式,它是libuv封裝後的函式,傳入了**handle,backlog和OnConnection回撥函式,其中handle_為node呼叫libuv介面建立的socket封裝,OnConnection函式為socket接收客戶端連線時執行的操作。我們可能會猜測在js層設定的onconnction函式最終會在OnConnection中呼叫,於是進一步深入探查node的connection_wrap c++模組:
過濾掉多餘資訊便於分析。當新的客戶端連線到來時,libuv呼叫OnConnection,在該函式內執行uv_accept接收連線,最後將js層的回撥函式onconnection[通過env->onconnection_string()獲取js的回撥]和接收到的客戶端socket封裝傳入MakeCallback中。其中,argv陣列的第一項為錯誤資訊,第二項為已連線的clientSocket封裝,最後在MakeCallback中執行js層的onconnection函式,該函式的引數正是argv陣列傳入的資料,“錯誤程式碼和clientSocket封裝”。 js層的onconnection回撥
這樣,node在C++層呼叫js層的onconnection函式,構建node層的socket物件,並觸發connection事件,完成底層socket與node net模組的連線與請求打通。 至此,我們打通了socket連線建立過程與net模組(js層)的流程的互動,這種封裝讓開發者在不需要查閱底層介面和資料結構的情況下,僅使用node提供的http模組就可以快速開發一個應用伺服器,將目光聚集在業務邏輯中。 > backlog是已連線但未進行accept處理的socket佇列大小。在linux 2.2以前,backlog大小包括了半連線狀態和全連線狀態兩種佇列大小。linux 2.2以後,分離為兩個backlog來分別限制半連線SYN_RCVD狀態的未完成連線佇列大小跟全連線ESTABLISHED狀態的已完成連線佇列大小。這裡的半連線狀態,即在三次握手中,服務端接收到客戶端SYN報文後併傳送SYN+ACK報文後的狀態,此時服務端等待客戶端的ACK,全連線狀態即服務端和客戶端完成三次握手後的狀態。backlog並非越大越好,當等待accept佇列過長,服務端無法及時處理排隊的socket,會造成客戶端或者前端伺服器如nignx的連線超時錯誤,出現“error: Broken Pipe”**。因此,node預設在socket層設定backlog預設值為511,這是因為nginx和redis預設設定的backlog值也為此,儘量避免上述錯誤。 ###
一個master程式下HTTP代理伺服器 + 多個worker程式多個HTTP應用伺服器
的架構,並提供兩種排程子程式演算法。本文主要針對cluster模組講述node是如何實現簡介高效的服務叢集建立和排程的。那麼就從程式碼進入本文的主題: code1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
const cluster = require('cluster'); const http = require('http'); if (cluster.isMaster) { let numReqs = 0; setInterval(() => { console.log(<code>numReqs = ${numReqs}</code>); }, 1000); function messageHandler(msg) { if (msg.cmd && msg.cmd === 'notifyRequest') { numReqs += 1; } } const numCPUs = require('os').cpus().length; for (let i = 0; i < numCPUs; i++) { cluster.fork(); } for (const id in cluster.workers) { cluster.workers[id].on('message', messageHandler); } } else { // Worker processes have a http server. http.Server((req, res) => { res.writeHead(200); res.end('hello world\n'); process.send({ cmd: 'notifyRequest' }); }).listen(8000); } |
主程式建立多個子程式,同時接受子程式傳來的訊息,迴圈輸出處理請求的數量; 子程式建立http伺服器,偵聽8000埠並返回響應。 泛泛的大道理誰都瞭解,可是這套程式碼如何執行在主程式和子程式中呢?父程式如何向子程式傳遞客戶端的請求?多個子程式共同偵聽8000埠,會不會造成埠reuse error?每個伺服器程式最大可有效支援多少併發量?主程式下的代理伺服器如何排程請求? 這些問題,如果不深入進去便永遠只停留在寫應用程式碼的層面,而且不瞭解cluster叢集建立的多程式與使用child_process建立的程式叢集的區別,也寫不出符合業務的最優程式碼,因此,深入cluster還是有必要的。 ## cluster與net cluster模組與net模組息息相關,而net模組又和底層socket有聯絡,至於socket則涉及到了系統核心,這樣便由表及裡的瞭解了node對底層的一些優化配置,這是我們的思路。介紹前,筆者仔細研讀了node的js層模組實現,在基於自身理解的基礎上詮釋上節程式碼的實現流程,力圖做到清晰、易懂,如果有某些紕漏也歡迎讀者指出,只有在互相交流中才能收穫更多。 ### 一套程式碼,多次執行 很多人對code1程式碼如何在主程式和子程式執行感到疑惑,怎樣通過_cluster.isMaster判斷語句內的程式碼是在主程式執行,而其他程式碼在子程式執行呢? 其實只要你深入到了node原始碼層面,這個問題很容易作答。cluster模組的程式碼只有一句:
1 2 3 |
module.exports = ('NODE<em>UNIQUE_ID' in process.env) ? require('internal/cluster/child') : require('internal/cluster/master');</em> |
只需要判斷當前程式有沒有環境變數“NODE_UNIQUE_ID”就可知道當前程式是否是主程式;而變數“NODE_UNIQUE_ID”則是在主程式fork子程式時傳遞進去的引數,因此採用cluster.fork建立的子程式是一定包含“NODE_UNIQUE_ID”的。 這裡需要指出的是,必須通過cluster.fork建立的子程式才有NODE_UNIQUE_ID變數,如果通過child_process.fork的子程式,在不傳遞環境變數的情況下是沒有NODE_UNIQUE_ID的。因此,當你在child_process.fork的子程式中執行
cluster.isMaster
判斷時,返回 true。 ### 主程式與伺服器 code1中,並沒有在cluster.isMaster的條件語句中建立伺服器,也沒有提供伺服器相關的路徑、埠和fd,那麼主程式中是否存在TCP伺服器,有的話到底是什麼時候怎麼建立的? 相信大家在學習nodejs時閱讀的各種書籍都介紹過在叢集模式下,主程式的伺服器會接受到請求然後傳送給子程式,那麼問題就來到主程式的伺服器到底是如何建立呢?主程式伺服器的建立離不開與子程式的互動,畢竟與建立伺服器相關的資訊全在子程式的程式碼中。 當子程式執行
1 2 3 4 5 6 |
http.Server((req, res) => { res.writeHead(200); res.end('hello world\n'); process.send({ cmd: 'notifyRequest' }); }).listen(8000); |
時,http模組會呼叫net模組(確切的說,http.Server繼承net.Server),建立net.Server物件,同時偵聽埠。建立net.Server例項,呼叫建構函式返回。建立的net.Server例項呼叫listen(8000),等待accpet連線。那麼,子程式如何傳遞伺服器相關資訊給主程式呢?答案就在listen函式中。我保證,net.Server.prototype.listen函式絕沒有表面上看起來的那麼簡單,它涉及到了許多IPC通訊和相容性處理,可以說HTTP伺服器建立的所有邏輯都在listen函式中。 > 延伸下,在學習linux下的socket程式設計時,服務端的邏輯依次是執行
socket(),bind(),listen()和accept()
,在接收到客戶端連線時執行read(),write()
呼叫完成TCP層的通訊。那麼,對應到node的net模組好像只有listen()階段,這是不是很難對應socket的四個階段呢?其實不然,node的net模組把“bind,listen”操作全部寫入了net.Server.prototype.listen中,清晰的對應底層socket和TCP三次握手,而向上層使用者只暴露簡單的listen介面。 code2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
Server.prototype.listen = function() { ... // 根據引數建立 handle控制程式碼 options = options._handle || options.handle || options; // (handle[, backlog][, cb]) where handle is an object with a handle if (options instanceof TCP) { this._handle = options; this[async_id_symbol] = this._handle.getAsyncId(); listenInCluster(this, null, -1, -1, backlogFromArgs); return this; } ... var backlog; if (typeof options.port === 'number' || typeof options.port === 'string') { if (!isLegalPort(options.port)) { throw new RangeError('"port" argument must be >= 0 and < 65536'); } backlog = options.backlog || backlogFromArgs; // start TCP server listening on host:port if (options.host) { lookupAndListen(this, options.port | 0, options.host, backlog, options.exclusive); } else { // Undefined host, listens on unspecified address // Default addressType 4 will be used to search for master server listenInCluster(this, null, options.port | 0, 4, backlog, undefined, options.exclusive); } return this; } ... throw new Error('Invalid listen argument: ' + util.inspect(options)); }; |
由於本文只探究cluster模式下HTTP伺服器的相關內容,因此我們只關注有關TCP伺服器部分,其他的Pipe(domain socket)服務不考慮。 listen函式可以偵聽埠、路徑和指定的fd,因此在listen函式的實現中判斷各種引數的情況,我們最為關心的就是偵聽埠的情況,在成功進入條件語句後發現所有的情況最後都執行了listenInCluster函式而返回,因此有必要繼續探究。 code3
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
function listenInCluster(server, address, port, addressType, backlog, fd, exclusive) { ... if (cluster.isMaster || exclusive) { server._listen2(address, port, addressType, backlog, fd); return; } // 後續程式碼為worker執行邏輯 const serverQuery = { address: address, port: port, addressType: addressType, fd: fd, flags: 0 }; ... cluster._getServer(server, serverQuery, listenOnMasterHandle); } |
listenInCluster函式傳入了各種引數,如server例項、ip、port、ip型別(IPv6和IPv4)、backlog(底層服務端socket處理請求的最大佇列)、fd等,它們不是必須傳入,比如建立一個TCP伺服器,就僅僅需要一個port即可。 簡化後的listenInCluster函式很簡單,cluster模組判斷當前程式為主程式時,執行_listen2函式;否則,在子程式中執行cluster._getServer函式,同時像函式傳遞serverQuery物件,即建立伺服器需要的相關資訊。 因此,我們可以大膽假設,子程式在cluster._getServer函式中向主程式傳送了建立伺服器所需要的資料,即serverQuery。實際上也確實如此: code4
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
cluster._getServer = function(obj, options, cb) { const message = util._extend({ act: 'queryServer', index: indexes[indexesKey], data: null }, options); send(message, function modifyHandle(reply, handle) => { if (typeof obj._setServerData === 'function') obj._setServerData(reply.data); if (handle) shared(reply, handle, indexesKey, cb); // Shared listen socket. else rr(reply, indexesKey, cb); // Round-robin. }); }; |
子程式在該函式中向已建立的IPC通道傳送內部訊息message,該訊息包含之前提到的serverQuery資訊,同時包含act: ‘queryServer’欄位,等待服務端響應後繼續執行回撥函式modifyHandle。 主程式接收到子程式傳送的內部訊息,會根據act: ‘queryServer’執行對應queryServer方法,完成伺服器的建立,同時傳送回覆訊息給子程式,子程式執行回撥函式modifyHandle,繼續接下來的操作。 至此,針對主程式在cluster模式下如何建立伺服器的流程已完全走通,主要的邏輯是在子程式伺服器的listen過程中實現。 ### net模組與socket 上節提到了node中建立伺服器無法與socket建立對應的問題,本節就該問題做進一步解釋。在net.Server.prototype.listen函式中呼叫了listenInCluster函式,listenInCluster會在主程式或者子程式的回撥函式中呼叫_listen2函式,對應底層服務端socket建立階段的正是在這裡。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
function setupListenHandle(address, port, addressType, backlog, fd) { // worker程式中,_handle為fake物件,無需建立 if (this._handle) { debug('setupListenHandle: have a handle already'); } else { debug('setupListenHandle: create a handle'); if (rval === null) rval = createServerHandle(address, port, addressType, fd); this._handle = rval; } this[async_id_symbol] = getNewAsyncId(this._handle); this._handle.onconnection = onconnection; var err = this._handle.listen(backlog || 511); } |
通過createServerHandle函式建立控制程式碼(控制程式碼可理解為使用者空間的socket),同時給屬性onconnection賦值,最後偵聽埠,設定backlog。 那麼,socket處理請求過程“socket(),bind()”步驟就是在createServerHandle完成。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function createServerHandle(address, port, addressType, fd) { var handle; // 針對網路連線,繫結地址 if (address || port || isTCP) { if (!address) { err = handle.bind6('::', port); if (err) { handle.close(); return createServerHandle('0.0.0.0', port); } } else if (addressType === 6) { err = handle.bind6(address, port); } else { err = handle.bind(address, port); } } return handle; } |
在createServerHandle中,我們看到了如何建立socket(createServerHandle在底層利用node自己封裝的類庫建立TCP handle),也看到了bind繫結ip和地址,那麼node的net模組如何接收客戶端請求呢? 必須深入c++模組才能瞭解node是如何實現在c++層面呼叫js層設定的onconnection回撥屬性,v8引擎提供了c++和js層的型別轉換和介面透出,在c++的tcp_wrap中:
1 2 3 4 5 6 7 8 9 10 11 |
void TCPWrap::Listen(const FunctionCallbackInfo& args) { TCPWrap* wrap; ASSIGN_OR_RETURN_UNWRAP(&wrap, args.Holder(), args.GetReturnValue().Set(UV_EBADF)); int backloxxg = args[0]->Int32Value(); int err = uv_listen(reinterpret_cast(&wrap->handle), backlog, OnConnection); args.GetReturnValue().Set(err); } |
我們關注uvlisten函式,它是libuv封裝後的函式,傳入了**handle,backlog和OnConnection回撥函式,其中handle_為node呼叫libuv介面建立的socket封裝,OnConnection函式為socket接收客戶端連線時執行的操作。我們可能會猜測在js層設定的onconnction函式最終會在OnConnection中呼叫,於是進一步深入探查node的connection_wrap c++模組:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
template void ConnectionWrap::OnConnection(uv_stream_t* handle, int status) { if (status == 0) { if (uv_accept(handle, client_handle)) return; // Successful accept. Call the onconnection callback in JavaScript land. argv[1] = client_obj; } wrap_data->MakeCallback(env->onconnection_string(), arraysize(argv), argv); } |
過濾掉多餘資訊便於分析。當新的客戶端連線到來時,libuv呼叫OnConnection,在該函式內執行uv_accept接收連線,最後將js層的回撥函式onconnection[通過env->onconnection_string()獲取js的回撥]和接收到的客戶端socket封裝傳入MakeCallback中。其中,argv陣列的第一項為錯誤資訊,第二項為已連線的clientSocket封裝,最後在MakeCallback中執行js層的onconnection函式,該函式的引數正是argv陣列傳入的資料,“錯誤程式碼和clientSocket封裝”。 js層的onconnection回撥
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
function onconnection(err, clientHandle) { var handle = this; if (err) { self.emit('error', errnoException(err, 'accept')); return; } var socket = new Socket({ handle: clientHandle, allowHalfOpen: self.allowHalfOpen, pauseOnCreate: self.pauseOnConnect }); socket.readable = socket.writable = true; self.emit('connection', socket); } |
這樣,node在C++層呼叫js層的onconnection函式,構建node層的socket物件,並觸發connection事件,完成底層socket與node net模組的連線與請求打通。 至此,我們打通了socket連線建立過程與net模組(js層)的流程的互動,這種封裝讓開發者在不需要查閱底層介面和資料結構的情況下,僅使用node提供的http模組就可以快速開發一個應用伺服器,將目光聚集在業務邏輯中。 > backlog是已連線但未進行accept處理的socket佇列大小。在linux 2.2以前,backlog大小包括了半連線狀態和全連線狀態兩種佇列大小。linux 2.2以後,分離為兩個backlog來分別限制半連線SYN_RCVD狀態的未完成連線佇列大小跟全連線ESTABLISHED狀態的已完成連線佇列大小。這裡的半連線狀態,即在三次握手中,服務端接收到客戶端SYN報文後併傳送SYN+ACK報文後的狀態,此時服務端等待客戶端的ACK,全連線狀態即服務端和客戶端完成三次握手後的狀態。backlog並非越大越好,當等待accept佇列過長,服務端無法及時處理排隊的socket,會造成客戶端或者前端伺服器如nignx的連線超時錯誤,出現“error: Broken Pipe”**。因此,node預設在socket層設定backlog預設值為511,這是因為nginx和redis預設設定的backlog值也為此,儘量避免上述錯誤。 ###
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!