一文淺析 Node.js 單線程高併發原理

一文淺析 Node.js 單線程高併發原理

Node 並不是是真正意義上的單線程,它是主線程 "單線程",經過事件驅動模型把 I/O 和計算進行分離。javascript

它也有一個線程池(基於 C/C++ 實現的 Libuv 庫)專門負責執行那些耗時較長的 I/O 操做任務(如網絡請求、文件讀寫等),任務執行完成後會通知主線程。java

而對於 CPU 計算型任務,都是由主線程完成的。node

Node 的重要優點就是把 I/O 操做放到主線程以外,從而讓主線程騰出手去處理更多請求。web

所以 Node 擅長執行 I/O 密集型任務,不善於執行 CPU 密集型任務。瀏覽器

不知你們在接觸 Node 時是否有考慮過如下幾個問題:服務器

  • Node 真的是單線程嗎?
  • 若是是單線程,它是如何處理高併發請求?
  • Nodes 事件驅動是如何實現的?
  • 爲何瀏覽器中運行的 Javascript 能與操做系統進行底層交互?

Node 架構與運行機制

在解答上面的問題以前咱們先看看 NodeJS 的架構概覽圖:網絡

NodeJS 架構概覽圖

  • Node Standard Library:由 Javascript 編寫的 NodeJS 的標準庫,即 API。
  • Node Bindings:這一層包括了 C/C++ Bindings(膠水代碼),向下封裝了 V8 和 Libuv 接口,向上提供了基礎 API 接口,是鏈接Javascript 和 C++ 的橋樑。
  • V8:Google 推出的 Javascript VM,它爲 Javascript 提供了在非瀏覽器端運行的環境。
  • Libuv:是專門爲Node.js開發的一個封裝庫,提供跨平臺的異步I/O能力,負責node運行時的線程池調度。
  • C-ares:提供了異步處理 DNS 相關的能力。
  • http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、數據壓縮等其它能力。

NodeJS 的運行機制以下圖:多線程

NodeJS 運行機制

  1. V8 引擎解析應用 Javascript 腳本代碼
  2. 經過 Node Bindings 調用 C/C++ 庫
  3. 執行到當前事件時,會把事件放在調用堆棧處理
  4. 堆棧中的任何 I/O 請求都會交給 Libuv 處理,Libuv 維護着一個線程池,裏面是一些工做線程,請求會調用這些線程來完成任務,這些線程則調用底層 C/C++ 庫
  5. 請求處理完成後,Libuv 再把結果返回事件隊列等待主線程執行
  6. 期間,主線程繼續執行其它任務

跨操做系統交互

舉個簡單的例子,咱們想要打開一個文件,並進行一些操做,能夠寫下面這樣一段代碼:架構

var fs = require('fs');
fs.open('./test.txt', "w", function(err, fd) {    //..do something});
複製代碼

這段代碼的調用過程大體可描述爲:lib/fs.js → src/node_file.cc → uv_fs併發

lib/fs.js

async function open(path, flags, mode) {  
  mode = modeNum(mode, 0o666);  
  path = getPathFromURL(path);
  validatePath(path);
  validateUint32(mode, 'mode');
  return new FileHandle(
    await binding.openFileHandle(pathModule.toNamespacedPath(path),stringToFlags(flags),mode, kUsePromises));
}
複製代碼

src/node_file.cc

static void Open(const FunctionCallbackInfo& args) {  
  Environment* env = Environment::GetCurrent(args);  
  const int argc = args.Length();  
  if (req_wrap_async != nullptr) {  
    AsyncCall(env, req_wrap_async, args, "open", UTF8,AfterInteger,uv_fs_open, *path, flags, mode);
  } else {
    CHECK_EQ(argc, 5);    
    FSReqWrapSync req_wrap_sync;    
    FS_SYNC_TRACE_BEGIN(open);    
    int result = SyncCall(env, args[4], &req_wrap_sync,"open",uv_fs_open, *path, flags, mode);    
    FS_SYNC_TRACE_END(open);
    args.GetReturnValue().Set(result);
  }
}
複製代碼

uv_fs

dstfd = uv_fs_open(NULL,&fs_req,req->new_path,dst_flags,statsbuf.st_mode,NULL);
uv_fs_req_cleanup(&fs_req);
複製代碼

大體流程以下圖:

Node與操做系統交互流程

當咱們調用 fs.open 時,Node 經過 process.binding 調用 C/C++ 層面的 Open 函數,而後經過它調用 Libuv 中的具體方法 uv_fs_open,最後執行的結果經過回調的方式傳回,完成流程。

主線程"單線程"

在傳統 web 服務模型中,大多數都採用多線程來解決併發問題,由於 I/O 是阻塞的,單線程就意味着用戶要等待,顯然這是不合理的,因此建立多個線程來響應用戶請求。

Node 的單線程指的是主線程是"單線程",主線程按照編碼順序一步步執行程序代碼,若是中途遇到同步代碼阻塞,後續的程序代碼就會被卡住。

咱們能夠建立一個簡單的 Web 服務器來驗證一下:

var http = require('http');

function sleep (time) {
  var _exit = Date.now() + time * 1000;
  while (Date.now() < _exit) {
  }
}

http.createServer(function (req, res) {
  sleep(10);
  res.end('server sleep 10s');
}).listen(8080);
複製代碼

經過瀏覽器快速連續請求訪問兩次 http://localhost:8080 ,可發現第一次請求在約 10s 後獲得響應,而第二次請求在約 20s 後獲得響應。

這是由於 Javascript 是解析性語言,代碼按照編碼順序一行一行被壓進 stack 裏面執行。當主線程接收到 request 請後,程序被壓進同步執行的 sleep 代碼塊(模擬業務處理)。若是在這 10s 內有第二個 request 進來就會被壓進 stack 裏面等待第一個請求執行後再處理下一個請求。以下面堆棧圖:

主線程

這也驗證了 Node 中主線程是"單線程"。

事件驅動機制

既然 Node 主線程是"單線程",那爲什麼能同時處理萬級併發而不形成阻塞呢?這就是咱們常說的 Node 基於事件驅動機制。

Node 事件循環

每一個 Node.js 進程只有一個主線程在執行程序代碼,造成一個執行棧(Execution Context Stack)。

除了主線程以外,還維護着一個 "事件隊列" (Event Queue) ,當用戶的網絡請求或其它 I/O 異步操做到來時,Node 會把它放到 Event Queue 之中,此時並不會執行它,代碼也不會阻塞,會繼續往下走,直到主線程代碼執行完畢。

主線程代碼執行完成後,經過 Event Loop(事件循環機制)開始到 Event Queue 開頭取出事件並對每一個事件從線程池中分配一個線程去執行,直到事件隊列中全部事件都執行完畢。

當有事件執行完畢後,會通知主線程執行回調方法,線程歸還回線程池。

所以 Node.js 本質上的異步操做仍是由線程池完成的,它將全部的阻塞操做都交給了內部的線程池去實現,自己只負責不斷的往返調度,從而實現異步非阻塞 I/O,這即是 Node 單線程和事件驅動的精髓之處了。

Event Loop的執行順序

Node.js 每次事件循環都包含了 6 個階段,對應到 Libuv 源碼中的實現,以下圖所示:

Event Loop的執行順序

  • timers 階段:執行 setTimeout() 和 setInterval() 中到期的回調。
  • I/O callbacks 階段:上一輪循環中有少數的 I/O 回調會被延遲到這一輪的這一階段執行
  • idle, prepare 階段:隊列的移動,僅內部使用
  • poll 階段:最爲重要的階段,執行 I/O 回調,在適當的條件下會阻塞在這個階段
  • check 階段:執行 setImmediate() 的回調
  • close callbacks 階段:執行 socketclose 事件回調

**核心函數 uv_run 源碼 **

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;  
  int r;  
  int ran_pending; 
  r = uv__loop_alive(loop); 
  //檢查loop中是否有異步任務,若是沒有直接就結束 
  if (!r)
    uv__update_time(loop);
  //事件循環其實就是一個大while
  while (r != 0 && loop->stop_flag == 0) { 
    //更新事件階段
    uv__update_time(loop); 
    //處理timer回調
    uv__run_timers(loop); 
    //處理異步任務回調 
    ran_pending = uv__run_pending(loop);
    //node內部處理階段
    uv__run_idle(loop);
    uv__run_prepare(loop);    
    // 這裏先記住 timeout 是一個時間
    // uv_backend_timeout計算完畢後,傳遞給 uv__io_poll
    // 若是timeout = 0,則 uv__io_poll 會直接跳過
    timeout = 0;    
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);
    uv__io_poll(loop, timeout);    
    //就是跑 setImmediate
    uv__run_check(loop);    
    //關閉文件描述符等操做
    uv__run_closing_handles(loop);    
    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;
}
複製代碼

Event loop 就是從事件隊列裏面不停循環的讀取事件,驅動了全部的異步回調函數的執行,Event Loop 總共 6 個階段,每一個階段都有一個任務隊列,當全部階段被順序執行一次後,Event Loop 完成了一個 tick。

相關文章
相關標籤/搜索