Nodejs 之運行機制原理
nodejs
Lastmod: 2019-12-15

Nodejs 出來時它的官網寫這以下的描述 :

Node.js is a JavaScript runtime built on Chrome’s V8 JavaScript engine. Node.js uses an event-driven, non-blocking I/O model that makes it lightweight and efficient. Node.js’ package ecosystem, npm, is the largest ecosystem of open source libraries in the world.

簡而言之 Nodejs 是運行在 V8 javascript 引擎,並且使用 Event driven 與 non-blocking I/O 模式所建立出來的東東。

而這裡我們就要深入的來理解 Nodejs 的運行機制。

  • Nodejs 核心設計 - 非阻塞 I/O 模式
  • Nodejs 架構與運行
  • Nodejs 為什麼需要使用 thread ?

Nodejs 核心設計 - 非阻塞 I/O 模式

上面介紹文有提到,它是以 非阻塞 non-blocking I/O 模式所建立出來的,但為什麼它要選用非阻塞 I/O 呢 ?

它想解決什麼問題呢 ?

它最原始想解決的就是 :

阻塞 I/O 模式在大併發請求下的貧頸。

首先我們先來看看傳統的阻塞 blocking I/O 模式問題

阻塞 I/O (Blocking I/O BIO) 伺服器的運行

首先我們先說明一下 I/O 這東西,I/O 是指輸入與輸出,只要是與外部記憶體或設置的溝通都算是 I/O 操作,像進行 http 請求或是讀檔案這種,都算是 I/O 操作。

而所謂的阻塞 I/O就是指,當執行 I/O 的操作會阻塞,也就是直到操作完成後,才會執行下一段指令,更準備的說法是,阻塞就是指這個 thread 或 process 無法處理其它事情,就算它 CPU 閒閒的也是一樣。

下面為一段模擬碼,process 或 thread 會在執行完 socket.read() 取得完資料後,才會執行下一段,這就是阻塞 I/O,而大部份的 I/O 在沒有特別處理的話,都是阻塞 I/O。

// 直到讀取完資料後,才會執行下一段。
var data = socket.read();

console.log(data);

而傳統阻塞 I/O 伺服器在收到一個 http(I/O) 後,每一個 http 會開啟一個 thread 或 process 來處,

為什麼呢 ?

因為每當建立一個連線,一定需要一個 process 來監聽 socket,然後那個 process 就阻塞住。如下程式碼,這一段在建立連線前,就需要先執行,而等到資料進來時,才會往下做。

request = socket.read(); // 這裡會阻塞住
doSomething(request); //有資料時才執行

因此大部份的傳統阻塞 I/O 伺服器在收到一個 http 請求後,都會開啟一個 thread 或 process 來進行處理,因為這樣就不會卡住了。

阻塞 I/O 伺服器的問題

上面有提到傳統的阻塞 I/O 伺服器每收到一個 http 請求就會開啟一個 thread 來運行。

這就是問題。

因為 thread 是很貴的資源

主要有以下的原因 :

  • 在 linux 系統下 thread 本質就是 process 創建和銷毀都非常的耗成本。
  • 它會暫用不少的 memory。
  • thread 的切換上下文成本很高。

所以當如果連線數來個十萬或上百萬的,那麼阻塞式 I/O 一定會倒給你看。

而這也是為什麼 Nodejs 要以非阻塞 I/O (Non-Blocking)為核心來進行設計。

非阻塞 I/O 模式 (Non-Blocking I/O NIO)

由於阻塞 I/O 有以下所提到的問題,因此後來就發展出所謂的非阻塞 I/O 模式 (Non-Blocking I/O NIO) 模式,他想完成的事情如下。

而實作 NIO 的設計模式有以下兩個 :

  • Reactor
  • proactor

我們此篇的主軸為Reactor,主要的原因為 Nodejs 主要就是使用 Reactor 來建立它的架構。( proactor 會開另一篇 )

它主要的概念圖就是咱們所謂的 event loop 機制,如下圖 :

簡單的說它有一個 Event loop 會一直不斷的去 Event Queue 中 check 是否有 I/O 事件,如果有的話就將它丟到指定的 handler 去,如下概念碼。

while(true){

    events = sockets.fetchIOEvents();
    
    for (var i=0; i < events.length; i++){
        if(events[0].type === 'write'){
            writeHandler(event[0].data);
        }
        
        if(events[0].type === 'read'){
            readHandler(event[0].data);
        }
        
        if(events[0].type === 'accept'){
            acceptHandler(event[0].dadta);
        }
    }
}

但這裡有問題想問問。

為什麼可以取得到 I/O 事件呢 ?

上面不是有提到要從 socket 中取得資料,需要使用 linux 底層的 socket.read() 阻塞方法,那為什麼概念碼的 fetchIOEvents 可以取得到所有 socket 的事件呢 ? 不是一個 process 只能監聽一個 socket 嗎 ?

主要的答案在於 :

多路複用( Multiplexing ) 技術,而各系統的實作為 epoll(linux)、kqueue(Mac)、IOCP(Window)

這幾個方法的功用就是,它們可以幫我們監控所有 socket 的 I/O 操作,當某些 socket 有事件產生時(ex. 有資料進來時)會自動的將它相關資料推送到一個 event queue 中,然後你可以使用它提供的方法,來取得事件相關資料。

我們這裡以 epoll 來說明它的使用方法,基本上它提供三個方法。

int epoll_create(int size); 
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
  • epoll_create : 它就是建立一個 epoll 對像,然後它的 size 就是 kernal 保証可以監控的最大 file descitpor 數。
  • epoll_ctl : 它就是將 socket 加入到 epoll 的監控中的方法。
  • epoll_wait : 它在給定的 timeout 時間內,如果監控的 socket有產生事件,則會返回事件。

下面為我們使用它的範例碼,這個範例的功用就是讓 epoll 監聽你有註冊的 socket,然後當有事件產生時,就可能從 epoll_wait 取得相對應的 socket 與事件,最後再將此事件執行到對應的 event handler。

struct events[10];

// 建立一個 epoll 用 file descriptor
epollfd = epoll_create();

// 註冊讓 epoll 監聽某 socket 的 EPOLLIN 事件
epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);

while(true){

    // 如果 epoll queue 中有事件產生,則會回傳產生事件的 socket 與 events。
    have_events_fds = epoll_wait( epollfd, events, MAX_EVENTS, -1 );

    // 讀取每個有產生事件的 socket,並執行對應的 eventHandler。
    for(int i=0; i < have_events_fds; i++){
        eventHandler(events[n]);
    }
}

阻塞 I/O 與非阻塞 I/O 的比較圖

這章節最後,我們來看看阻塞 I/O 與非阻塞 I/O 的比較圖。

阻塞 : 監控 socket,然後就開始卡住整個 process,直到有資料進來後才結束。 非阻塞 : 不斷的去問 socket 有沒有資料。

問個問題,非阻塞不斷的去尋問有沒有資料不會很耗資源嗎 ?

嗯對會,所以你實際上看 libuv 程式碼 event loop 這個地方,你會發現注意到,它事實上有給一個 timeout 參數,它會根據某些條件阻塞在那裡,不會一直重複的去問。

while(true){


timeout = 0;
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
      timeout = uv_backend_timeout(loop);

    uv__io_poll(loop, timeout); // 這裡


}

Nodejs 架構與基本運行

Nodejs 的基本組合與運行如下。其中 libuv 就是實現非阻塞 I/O 的核心庫,它讓我們可以跨平台的實現非阻塞 I/O。

根據上圖如果有一段程式碼執行下去,那它的運行流程如下 :

  1. 使用 V8 引擎解析 javascript 語法。
  2. 解析後呼叫對應對 node C++ 程式碼。
  3. 將所有同步的程式碼運行完。
  4. libuv 建立起 event loop 並且不斷的去輪詢 event queue 來執行那些可以呼叫的異步操作 callback。

我們簡單的看一下範例

假設我們有一段程式碼如下 :

console.log('Hi');

setTimeout(() => console.log('fuck u'), 0);

console.log('Mark');

它的運行流程為:

  1. 執行同步程式碼 console.log(‘Hi’)。
  2. 將 setTimeout 事件與 callback 丟到 Event Queue 中。
  3. 執行同步程式碼 console.log(‘Mark’)。
  4. 開啟執行 event loop。
  5. 發現 event queue 中有需要執行的 time 事件,執行 console.log(‘fuck u’)。

所以最後輸出的結果為 :

// Hi
// Mark
// fuck u

這裡問個問題 ~ Nodejs 是單進程的架構,可是為什麼架構圖中最後有 worker thread 的東西呢 ?

這就是我們接下來要章節要說明的東西。

Nodejs 為什麼需要使用 thread ?

根據官方文件,可以知道,有以下幾個東西需要使用 thread 來處理。

首先來說說 cpu 密集的這兩個CryptoZlib加密與壓縮套件,為什麼需要丟到 thread 做呢 ? 主要的原因為,基本上正常的運算都是屬於同步程式碼,也就是說會在 event loop 前執行,如果這時運算太花時間,那就代表他會卡住 event loop 運行,那如果是 callback 的呢 ? 它應該是在 event loop 內執行吧 ? 嗯沒錯,但問題就是,當它執行時,它就會卡 event loop,不要忘了 event loop 只是一個 while,然裡面執行的東西還是同步,包含 epoll 操作也是同步且阻塞的。

那接下來看 I/O-intensive 的DNSFileSystem

我們先來說說FileSystem的情況。

首先基本上 I/O 阻塞來源為 :

I/O 阻塞來源 = Network I/O + File I/O

那為什麼在 Nodejs 中,可以做到非阻塞呢 ?

因為他們兩個都有解法可處理。

libuv 解決 I/O 阻塞方法 = Network I/O (epoll) + File I/O (thread)

Network I/O 可以用 epoll 來處理

但重點是為什麼 File I/O 無法用 epoll 來處理呢 ?

目前筆者只知道,如果你將檔案的 file descriptor 註冊到 epoll 中,會發生以下的錯誤:

EPERM The target file fd does not support epoll.

簡單來說就是 epoll 不支援檔案類型的 file descriptor 監控。

這也是為什麼 Nodejs 實際上會偷偷的開幾個 worker thread 來處理 file system 這件事情。

file I/O 可以理解,但為什麼 dns 他不是 network I/O 嗎 ? 那為什麼它還需要 thread 來處理呢。

筆者覺得主要的原因在於,DNS 的操作事實上為 :

Network I/O + File I/O

這或需就是為什麼它需使用 thread 來完成非阻塞 I/O。

不過以上只是推測,不代表是正確答案。

注意 nodejs 只能說架構是非阻塞,但不代表不會阻塞

我們有以下兩個假設:

  • Thread number: 3
  • test.txt 讀檔時間: 2 sec

那你執行以下的程式碼。

const fs = require('fs');

const start = process.hrtime();

for (var i = 1; i <= 3; i++) {
  ((id) => {
    fs.readdir('test.txt', () => {
      let end = process.hrtime(start);
      console.log(util.format('read file %d finished in %ds', id, end[0] + end[1] / 1e9));
    });
  })(i);
}

然後你就會發結果如下,實際上在讀三個檔案時,就已經阻塞了,原因在於我們只有三個 thread,它們都在忙錄,因此第四個才會需要花 4 秒才完成。

read file 1 finished in 2 sec
read file 2 finished in 2 sec
read file 3 finished in 2 sec
read file 4 finished in 4 sec

參考資料


comments powered by Disqus