Node.js採用事件驅動和非同步I/O的方式,實作了單執行緒、高並發的JavaScript運行環境。既然單執行緒意味著一次只能做一件事,那麼Node.js如何透過一個執行緒實現高並發和非同步I/O呢?本文將圍繞這個問題探討 Node.js 的單線程模型。
一般來說,高並發的解決方案是提供多執行緒模型。伺服器為每個客戶端請求分配一個執行緒並使用同步 I/O。系統透過執行緒切換來彌補同步I/O呼叫的時間成本。例如,Apache就使用這種策略。由於 I/O 操作通常非常耗時,因此這種方法很難獲得高效能。不過,它非常簡單,可以實現複雜的互動邏輯。
事實上,大多數 Web 伺服器端並不執行太多運算。接收到請求後,將請求傳遞給其他服務(例如讀取資料庫),然後等待結果返回,最後將結果傳送給客戶端。因此,Node.js 使用單線程模型來處理這種情況。它不是為每個傳入的請求分配一個線程,而是使用一個主線程來處理所有請求,然後非同步處理 I/O 操作,避免了創建、銷毀線程以及線程之間切換的開銷和複雜性。
Node.js 在主執行緒中維護一個事件佇列。當收到請求時,它會作為事件新增到此佇列中,然後繼續接收其他請求。當主執行緒空閒時(沒有請求傳入),它開始循環遍歷事件佇列以檢查是否有事件需要處理。有兩種情況:對於非I/O任務,主執行緒會直接處理,並透過回呼函數傳回上層;對於I/O任務,它會從執行緒池中取出一個執行緒來處理事件,指定一個回呼函數,然後繼續循環佇列中的其他事件。
一旦執行緒中的I/O任務完成,就執行指定的回呼函數,並將完成的事件放在事件佇列的末尾,等待事件循環。當主線程再次循環到這個事件時,直接處理並返回給上層。這個過程稱為Event Loop,其運作原理如下圖所示:
此圖展示了Node.js的整體運作原理。 Node.js 從左到右、從上到下分為四層:應用層、V8 引擎層、Node API 層、LIBUV 層。
無論是Linux平台或Windows平台,Node.js內部都使用執行緒池來完成非同步I/O操作,LIBUV統一了不同平台差異的呼叫。所以,Node.js 中的單執行緒僅意味著 JavaScript 在單執行緒中運行,而不是 Node.js 整體是單線程的。
Node.js 實作非同步的核心在於事件。也就是說,它將每個任務視為一個事件,然後透過事件循環來模擬非同步效果。為了更具體、更清楚地理解和接受這個事實,我們下面用偽代碼來描述它的運作方式。
由於它是一個佇列,所以它是先進先出(FIFO)的資料結構。我們用JS數組來描述,如下:
/** * Define the event queue * Enqueue: push() * Dequeue: shift() * Empty queue: length === 0 */ let globalEventQueue = [];
我們用陣列來模擬佇列結構:陣列的第一個元素是佇列的頭,最後一個元素是佇列的尾端。 push() 在佇列尾部插入一個元素,shift() 從佇列頭部刪除一個元素。這樣就實作了一個簡單的事件隊列。
每個請求都會被攔截並進入處理函數,如下圖:
/** * Receive user requests * Every request will enter this function * Pass parameters request and response */ function processHttpRequest(request, response) { // Define an event object let event = createEvent({ params: request.params, // Pass request parameters result: null, // Store request results callback: function() {} // Specify a callback function }); // Add the event to the end of the queue globalEventQueue.push(event); }
該函數只是將使用者的請求封裝為一個事件,放入佇列中,然後繼續接收其他請求。
當主執行緒空閒時,開始循環事件佇列。所以我們要定義一個函數來循環事件佇列:
/** * The main body of the event loop, executed by the main thread when appropriate * Loop through the event queue * Handle non-IO tasks * Handle IO tasks * Execute callbacks and return to the upper layer */ function eventLoop() { // If the queue is not empty, continue to loop while (this.globalEventQueue.length > 0) { // Take an event from the head of the queue let event = this.globalEventQueue.shift(); // If it's a time-consuming task if (isIOTask(event)) { // Take a thread from the thread pool let thread = getThreadFromThreadPool(); // Hand it over to the thread to handle thread.handleIOTask(event); } else { // After handling non-time-consuming tasks, directly return the result let result = handleEvent(event); // Finally, return to V8 through the callback function, and then V8 returns to the application event.callback.call(null, result); } } }
主執行緒持續監聽事件佇列。對於I/O任務,它交給線程池處理,對於非I/O任務,它自己處理並返回。
執行緒池收到任務後,直接處理I/O操作,例如讀取資料庫:
/** * Define the event queue * Enqueue: push() * Dequeue: shift() * Empty queue: length === 0 */ let globalEventQueue = [];
當I/O任務完成時,執行回調,將請求結果儲存到事件中,並將事件放回佇列中,等待循環。最後,當前線程被釋放。當主執行緒再次循環到該事件時,直接處理。
總結上面的過程,我們發現Node.js只使用一個主執行緒來接收請求。接收到請求後,不要直接處理,而是放入事件佇列中,然後繼續接收其他請求。當它空閒時,它會透過事件循環處理這些事件,從而達到非同步的效果。當然,對於I/O任務,還是需要依賴系統層面的線程池來處理。
因此,我們可以簡單地理解為 Node.js 本身是一個多執行緒平台,但它在單執行緒中處理 JavaScript 層級的任務。
到目前為止,我們應該對 Node.js 的單線程模型有了一個簡單清晰的認識。它透過事件驅動模型實現高並發和非同步I/O。然而,Node.js 也有不擅長的地方。
如上所述,對於I/O任務,Node.js將其交給執行緒池進行非同步處理,高效且簡單。因此,Node.js 適合處理 I/O 密集型任務。但並非所有任務都是 I/O 密集型的。當遇到CPU密集型任務,也就是只依賴CPU計算的操作,如資料加解密(node.bcrypt.js)、資料壓縮解壓縮(node-tar)時,Node.js會一一處理一。如果前面的任務沒有完成,後面的任務就只能等待。如下圖:
在事件佇列中,如果前面的CPU運算任務沒有完成,後面的任務就會被阻塞,導致反應緩慢。如果作業系統是單核心的話,可能還可以忍受。但現在大多數伺服器都是多CPU或多核心的,而Node.js只有一個EventLoop,也就是說只佔用一個CPU核。當 Node.js 被 CPU 密集型任務佔用,導致其他任務阻塞時,仍有 CPU 核心閒置,造成資源浪費。
所以,Node.js 不適合 CPU 密集型任務。
最後介紹一下最適合部署Node.js服務的平台:Leapcell。
在文件中探索更多內容!
Leapcell Twitter:https://x.com/LeapcellHQ
以上是Node.js 事件循環內部:深入探究的詳細內容。更多資訊請關注PHP中文網其他相關文章!