nodejs真的是單執行緒嗎?
[原文]
一、多執行緒與單執行緒
像java、python這個可以具有多執行緒的語言。多執行緒同步模式是這樣的,將cpu分成幾個執行緒,每個執行緒同步執行。
而node.js採用單執行緒非同步非阻塞模式,也就是說每一個計算獨佔cpu,遇到I/O請求不阻塞後面的計算,當I/O完成後,以事件的方式通知,繼續執行計算2。
事件驅動、非同步、單執行緒、非阻塞I/O,這是我們聽得最多的關於nodejs的介紹。看到上面的關鍵字,可能我們會好奇:
為什麼在瀏覽器中執行的 Javascript 能與作業系統進行如此底層的互動?
nodejs既然是單執行緒,如何實現非同步、非阻塞I/O?
nodejs全是非同步呼叫和非阻塞I/O,就真的不用管併發數了嗎?
nodejs事件驅動是如何實現的?和瀏覽器的event loop是一回事嗎?
nodejs擅長什麼?不擅長什麼?
二、nodejs內部揭祕
要弄清楚上面的問題,首先要弄清楚nodejs是怎麼工作的。
我們可以看到,Node.js 的結構大致分為三個層次:
1、 Node.js 標準庫,這部分是由 Javascript 編寫的,即我們使用過程中直接能呼叫的 API。在原始碼中的 lib 目錄下可以看到。
2、 Node bindings,這一層是 Javascript 與底層 C/C++ 能夠溝通的關鍵,前者通過 bindings 呼叫後者,相互交換資料。
3、這一層是支撐 Node.js 執行的關鍵,由 C/C++ 實現。
V8:Google 推出的 Javascript VM,也是 Node.js 為什麼使用的是 Javascript 的關鍵,它為 Javascript 提供了在非瀏覽器端執行的環境,它的高效是 Node.js 之所以高效的原因之一。
Libuv:它為 Node.js 提供了跨平臺,執行緒池,事件池,非同步 I/O 等能力,是 Node.js 如此強大的關鍵。
C-ares:提供了非同步處理 DNS 相關的能力。
http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、資料壓縮等其他的能力。
三、libuv簡介
可以看出,幾乎所有和作業系統打交道的部分都離不開 libuv的支援。libuv也是node實現跨作業系統的核心所在。
四、我們再來看看最開始我丟擲的問題
問題一:為什麼在瀏覽器中執行的 Javascript 能與作業系統進行如此底層的互動?
舉個簡單的例子,我們想要開啟一個檔案,並進行一些操作,可以寫下面這樣一段程式碼:
var fs = require('fs'); fs.open('./test.txt', "w", function(err, fd) { //..do something }); fs.open = function(path, flags, mode, callback) { // ... binding.open(pathModule._makeLong(path), stringToFlags(flags), mode, callback); };
這段程式碼的呼叫過程大致可描述為:lib/fs.js → src/node_file.cc →uv_fs
從JavaScript呼叫Node的核心模組,核心模組呼叫C++內建模組,內建模組通過 libuv進行系統呼叫,這是Node裡經典的呼叫方式。總體來說,我們在 Javascript 中呼叫的方法,最終都會通過node-bindings 傳遞到 C/C++ 層面,最終由他們來執行真正的操作。Node.js 即這樣與作業系統進行互動。
問題二:nodejs既然是單執行緒,如何實現非同步、非阻塞I/O?
順便回答標題nodejs真的是單執行緒嗎?其實只有js執行是單執行緒,I/O顯然是其它執行緒。
js執行執行緒是單執行緒,把需要做的I/O交給libuv,自己馬上返回做別的事情,然後libuv在指定的時刻回撥就行了。其實簡化的流程就是醬紫的!細化一點,nodejs會先從js程式碼通過node-bindings呼叫到C/C++程式碼,然後通過C/C++程式碼封裝一個叫 “請求物件” 的東西交給libuv,這個請求物件裡面無非就是需要執行的功能+回撥之類的東西,給libuv執行以及執行完實現回撥。
總結來說,一個非同步 I/O 的大致流程如下:
1、發起 I/O 呼叫
使用者通過 Javascript 程式碼呼叫 Node 核心模組,將引數和回撥函式傳入到核心模組;
Node 核心模組會將傳入的引數和回撥函式封裝成一個請求物件;
將這個請求物件推入到 I/O 執行緒池等待執行;
Javascript 發起的非同步呼叫結束,Javascript 執行緒繼續執行後續操作。
2、執行回撥
I/O 操作完成後,會取出之前封裝在請求物件中的回撥函式,執行這個回撥函式,以完成 Javascript 回撥的目的。(這裡回撥的細節下面講解)
從這裡,我們可以看到,我們其實對 Node.js 的單執行緒一直有個誤會。事實上,它的單執行緒指的是自身 Javascript 執行環境的單執行緒,Node.js 並沒有給 Javascript 執行時建立新執行緒的能力,最終的實際操作,還是通過 Libuv 以及它的事件迴圈來執行的。這也就是為什麼 Javascript 一個單執行緒的語言,能在 Node.js 裡面實現非同步操作的原因,兩者並不衝突。
問題三:nodejs全是非同步呼叫和非阻塞I/O,就真的不用管併發數了嗎?
之前我們就提到了執行緒池的概念,發現nodejs並不是單執行緒的,而且還有並行事件發生。同時,執行緒池預設大小是 4 ,也就是說,同時能有4個執行緒去做檔案i/o的工作,剩下的請求會被掛起等待直到執行緒池有空閒。 所以nodejs對於併發數,是由限制的。
執行緒池的大小可以通過 UV_THREADPOOL_SIZE 這個環境變數來改變 或者在nodejs程式碼中通過 process.env.UV_THREADPOOL_SIZE來重新設定。
問題四:nodejs事件驅動是如何實現的?和瀏覽器的event loop是一回事嗎?
event loop是一個執行模型,在不同的地方有不同的實現。瀏覽器和nodejs基於不同的技術實現了各自的event loop。
簡單來說:
nodejs的event是基於libuv,而瀏覽器的event loop則在html5的規範中明確定義。
libuv已經對event loop作出了實現,而html5規範中只是定義了瀏覽器中event loop的模型,具體實現留給了瀏覽器廠商。
我們上面提到了libuv接過了js傳遞過來的 I/O請求,那麼何時來處理回撥呢?
libuv有一個事件迴圈(event loop)的機制,來接受和管理回撥函式的執行。
event loop是libuv的核心所在,上面我們提到 js 會把回撥和任務交給libuv,libuv何時來呼叫回撥就是 event loop 來控制的。event loop 首先會在內部維持多個事件佇列(或者叫做觀察者 watcher),比如 時間佇列、網路佇列等等,使用者可以在watcher中註冊回撥,當事件發生時事件轉入pending狀態,再下一次迴圈的時候按順序取出來執行,而libuv會執行一個相當於 while true的無限迴圈,不斷的檢查各個watcher上面是否有需要處理的pending狀態事件,如果有則按順序去觸發佇列裡面儲存的事件,同時由於libuv的事件迴圈每次只會執行一個回撥,從而避免了 競爭的發生。Libuv的 event loop執行圖:
nodejs的event loop分為6個階段,每個階段的作用如下:
timers:執行setTimeout() 和 setInterval()中到期的callback。
I/O callbacks:上一輪迴圈中有少數的I/Ocallback會被延遲到這一輪的這一階段執行
idle, prepare:僅內部使用
poll:最為重要的階段,執行I/O callback,在適當的條件下會阻塞在這個階段
check:執行setImmediate的callback
close callbacks:執行close事件的callback,例如socket.on("close",func)
event loop的每一次迴圈都需要依次經過上述的階段。 每個階段都有自己的callback佇列,每當進入某個階段,都會從所屬的佇列中取出callback來執行,當佇列為空或者被執行callback的數量達到系統的最大數量時,進入下一階段。這六個階段都執行完畢稱為一輪迴圈。
附帶event loop 原始碼:
int uv_run(uv_loop_t* loop, uv_run_mode mode) { int timeout; int r; int ran_pending; /* 從uv__loop_alive中我們知道event loop繼續的條件是以下三者之一: 1,有活躍的handles(libuv定義handle就是一些long-lived objects,例如tcp server這樣) 2,有活躍的request 3,loop中的closing_handles */ r = uv__loop_alive(loop); if (!r) uv__update_time(loop); while (r != 0 && loop->stop_flag == 0) { uv__update_time(loop);//更新時間變數,這個變數在uv__run_timers中會用到 uv__run_timers(loop);//timers階段 ran_pending = uv__run_pending(loop);//從libuv的文件中可知,這個其實就是I/O callback階段,ran_pending指示佇列是否為空 uv__run_idle(loop);//idle階段 uv__run_prepare(loop);//prepare階段 timeout = 0; /** 設定poll階段的超時時間,以下幾種情況下超時會被設為0,這意味著此時poll階段不會被阻塞,在下面的poll階段我們還會詳細討論這個 1,stop_flag不為0 2,沒有活躍的handles和request 3,idle、I/O callback、close階段的handle佇列不為空 否則,設為timer階段的callback佇列中,距離當前時間最近的那個 **/ if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT) timeout = uv_backend_timeout(loop); uv__io_poll(loop, timeout);//poll階段 uv__run_check(loop);//check階段 uv__run_closing_handles(loop);//close階段 //如果mode == UV_RUN_ONCE(意味著流程繼續向前)時,在所有階段結束後還會檢查一次timers,這個的邏輯的原因不太明確 if (mode == UV_RUN_ONCE) { uv__update_time(loop); uv__run_timers(loop); } r = uv__loop_alive(loop); if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT) break; } if (loop->stop_flag != 0) loop->stop_flag = 0; return r; }
這裡我們再詳細瞭解一下poll階段:
poll 階段有兩個主要功能:
1、執行下限時間已經達到的timers的回撥
2、處理 poll 佇列裡的事件。
當event loop進入 poll 階段,並且 沒有設定的timers(there are no timers scheduled),會發生下面兩件事之一:
1、如果 poll 佇列不空,event loop會遍歷佇列並同步執行回撥,直到佇列清空或執行的回撥數到達系統上限;
2、如果 poll 佇列為空,則發生以下兩件事之一:
(1)如果程式碼已經被setImmediate()設定了回撥, event loop將結束 poll 階段進入 check 階段來執行 check 佇列(裡的回撥)。
(2)如果程式碼沒有被setImmediate()設定回撥,event loop將阻塞在該階段等待回撥被加入 poll 佇列,並立即執行。
但是,當event loop進入 poll 階段,並且 有設定的timers,一旦 poll 佇列為空(poll 階段空閒狀態):
event loop將檢查timers,如果有1個或多個timers的下限時間已經到達,event loop將繞回 timers 階段。
event loop的一個例子講述:
var fs = require('fs'); function someAsyncOperation (callback) { // 假設這個任務要消耗 95ms fs.readFile('/path/to/file', callback); } var timeoutScheduled = Date.now(); setTimeout(function () { var delay = Date.now() - timeoutScheduled; console.log(delay + "ms have passed since I was scheduled"); }, 100); // someAsyncOperation要消耗 95 ms 才能完成 someAsyncOperation(function () { var startCallback = Date.now(); // 消耗 10ms... while (Date.now() - startCallback < 10) { ; // do nothing } });
當event loop進入 poll 階段,它有個空佇列(fs.readFile()尚未結束)。所以它會等待剩下的毫秒,直到最近的timer的下限時間到了。當它等了95ms,fs.readFile()首先結束了,然後它的回撥被加到 poll的佇列並執行——這個回撥耗時10ms。之後由於沒有其它回撥在佇列裡,所以event loop會檢視最近達到的timer的下限時間,然後回到 timers 階段,執行timer的回撥。
所以在示例裡,回撥被設定 和 回撥執行間的間隔是105ms。
到這裡我們再總結一下,整個非同步IO的流程:
問題五、nodejs擅長什麼?不擅長什麼?
Node.js 通過 libuv 來處理與作業系統的互動,並且因此具備了非同步、非阻塞、事件驅動的能力。因此,NodeJS能響應大量的併發請求。所以,NodeJS適合運用在高併發、I/O密集、少量業務邏輯的場景。
上面提到,如果是 I/O 任務,Node.js 就把任務交給執行緒池來非同步處理,高效簡單,因此 Node.js 適合處理I/O密集型任務。但不是所有的任務都是 I/O 密集型任務,當碰到CPU密集型任務時,即只用CPU計算的操作,比如要對資料加解密(node.bcrypt.js),資料壓縮和解壓(node-tar),這時 Node.js 就會親自處理,一個一個的計算,前面的任務沒有執行完,後面的任務就只能乾等著 。我們看如下程式碼:
var start = Date.now();//獲取當前時間戳 setTimeout(function () { console.log(Date.now() - start); for (var i = 0; i < 1000000000; i++){//執行長迴圈 } }, 1000); setTimeout(function () { console.log(Date.now() - start); }, 2000);
最終我們的列印結果是:(結果可能因為你的機器而不同)
1000
3738
對於我們期望2秒後執行的setTimeout函式其實經過了3738毫秒之後才執行,換而言之,因為執行了一個很長的for迴圈,所以我們整個Node.js主執行緒被阻塞了,如果在我們處理100個使用者請求中,其中第一個有需要這樣大量的計算,那麼其餘99個就都會被延遲執行。如果作業系統本身就是單核,那也就算了,但現在大部分伺服器都是多 CPU 或多核的,而 Node.js 只有一個 EventLoop,也就是隻佔用一個 CPU 核心,當 Node.js 被CPU 密集型任務佔用,導致其他任務被阻塞時,卻還有 CPU 核心處於閒置狀態,造成資源浪費。
其實雖然Node.js可以處理數以千記的併發,但是一個Node.js程序在某一時刻其實只是在處理一個請求。
因此,Node.js 並不適合 CPU 密集型任務。
參考文章:
https://www.cnblogs.com/chris...
https://www.cnblogs.com/onepi...
https://blog.csdn.net/scandly...
http://liyangready.github.io/...
https://blog.csdn.net/xjtrodd...
https://blog.csdn.net/sinat_2...