Node-js 底層原理
作者介紹:陳躍標,ByteDance Web Infra 團隊成員,目前主要負責 Node.js 基礎架構方向的工作
本文內容主要分爲兩大部分,第一部分是 Node.js 的基礎和架構,第二部分是 Node.js 核心模塊的實現。
-
一 Node.js 基礎和架構
-
Node.js 的組成
-
Node.js 代碼架構
-
Node.js 啓動過程
-
Node.js 事件循環
-
二 Node.js 核心模塊的實現
-
進程和進程間通信
-
線程和線程間通信
-
Cluster
-
Libuv 線程池
-
信號處理
-
文件
-
TCP
-
UDP
-
DNS
- Nodejs 組成
Node.js 主要由 V8、Libuv 和第三方庫組成:
-
Libuv:跨平臺的異步 IO 庫,但它提供的功能不僅僅是 IO,還包括進程、線程、信號、定時器、進程間通信,線程池等。
-
第三方庫:異步 DNS 解析( cares )、HTTP 解析器(舊版使用 http_parser,新版使用 llhttp)、HTTP2 解析器( nghttp2 )、 解壓壓縮庫 (zlib)、加密解密庫( openssl ) 等等。
-
V8:實現 JS 解析、執行和支持自定義拓展,得益於 V8 支持自定義拓展,纔有了 Node.js。
-
Node.js 代碼架構
上圖是 Node.js 的代碼架構,Node.js 的代碼主要分爲 JS、C++、C 三種:
-
JS 是我們平時使用的那些模塊 (http/fs)。
-
C++ 代碼分爲三個部分,第一部分是封裝了 Libuv 的功能,第二部分則是不依賴於 Libuv (crypto 部分 API 使用了 Libuv 線程池),比如 Buffer 模塊,第三部分是 V8 的代碼。
-
C 語言層的代碼主要是封裝了操作系統的功能,比如 TCP、UDP。
瞭解了 Node.js 的組成和代碼架構後,我們看看 Node.js 啓動的過程都做了什麼。
- Node.js 啓動過程
3.1 註冊 C++ 模塊
首先 Node.js 會調用 registerBuiltinModules 函數註冊 C++ 模塊,這個函數會調用一系列 registerxxx 的函數,我們發現在 Node.js 源碼裏找不到這些函數,因爲這些函數是在各個 C++ 模塊中,通過宏定義實現的,宏展開後就是上圖黃色框的內容,每個 registerxxx 函數的作用就是往 C++ 模塊的鏈表了插入一個節點,最後會形成一個鏈表。
那麼 Node.js 裏是如何訪問這些 C++ 模塊的呢?在 Node.js 中,是通過 internalBinding 訪問 C++ 模塊的,internalBinding 的邏輯很簡單,就是根據模塊名從模塊隊列中找到對應模塊。但是這個函數只能在 Node.js 內部使用,不能在用戶 JS 模塊使用,用戶可以通過 process.binding 訪問 C++ 模塊。
3.2 Environment 對象和綁定 Context
註冊完 C++ 模塊後就開始創建 Environment 對象,Environment 是 Node.js 執行時的環境對象,類似一個全局變量的作用,他記錄了 Node.js 在運行時的一些公共數據。創建完 Environment 後,Node.js 會把該對象綁定到 V8 的 Context 中,爲什麼要這樣做呢?主要是爲了在 V8 的執行上下文裏拿到 env 對象,因爲 V8 中只有 Isolate、Context 這些對象,如果我們想在 V8 的執行環境中獲取 Environment 對象的內容,就可以通過 Context 獲取 Environment 對象。
3.3 初始化模塊加載器
-
Node.js 首先傳入 C++ 模塊加載器,執行 loader.js,loader.js 主要是封裝了 C++ 模塊加載器和原生 JS 模塊加載器,並保存到 env 對象中。
-
接着傳入 C++ 和原生 JS 模塊加載器,執行 run_main_module.js。
-
在 run_main_module.js 中傳入普通 JS 和原生 JS 模塊加載器,執行用戶的 JS。
假設用戶 JS 如下:
-
require('net')
-
require('./myModule')
分別加載了一個用戶模塊和原生 JS 模塊,我們看看加載過程,執行 require 的時候:
-
Node.js 首先會判斷是否是原生 JS 模塊,如果不是則直接加載用戶模塊,否則,會使用原生模塊加載器加載原生 JS 模塊。
-
加載原生 JS 模塊的時候,如果用到了 C++ 模塊,則使用 internalBinding 去加載。
3.4 執行用戶代碼,Libuv 事件循環
接着 Node.js 就會執行用戶的 JS,通常用戶的 JS 會給事件循環生產任務,然後就進入了事件循環系統,比如我們 listen 一個服務器的時候,就會在事件循環中新建一個 TCP handle。Node.js 就會在這個事件循環中一直運行。
net.createServer(() => {}).listen(80)
- 事件循環
下面我們看一下事件循環的實現。事件循環主要分爲 7 個階段,timer 階段主要是處理定時器相關的任務,pending 階段主要是處理 Poll IO 階段回調裏產生的回調,check、prepare、idle 階段是自定義的階段,這三個階段的任務每次事件序循環都會被執行,Poll IO 階段主要是處理網絡 IO、信號、線程池等等任務,closing 階段主要是處理關閉的 handle,比如關閉服務器。
-
timer 階段: 用二叉堆實現,最快過期的在根節點。
-
pending 階段:處理 Poll IO 階段回調裏產生的回調
-
check、prepare、idle 階段:每次事件循環都會被執行。
-
Poll IO 階段:處理文件描述符相關事件。
-
closing 階段:執行調用 uv_close 函數時傳入的回調。下面我們詳細看一下每個階段的實現。
4.1 定時器階段
定時器的底層數據結構是二叉堆,最快到期的節點在最上面。在定時器階段的時候,就會逐個節點遍歷,如果節點超時了,那麼就執行他的回調,如果沒有超時,那麼後面的節點也不用判斷了,因爲當前節點是最快過期的,如果他都沒有過期,說明其他節點也沒有過期。節點的回調被執行後,就會被刪除,爲了支持 setInterval 的場景,如果設置 repeat 標記,那麼這個節點會被重新插入到二叉堆。
我們看到底層的實現稍微簡單,但是 Node.js 的定時器模塊實現就稍微複雜。
-
Node.js 在 JS 層維護了一個二叉堆。
-
堆的每個節點維護了一個鏈表,這個鏈表中,最久超時的排到後面。
-
另外 Node.js 還維護了一個 map,map 的 key 是相對超時時間,值就是對應的二叉堆節點。
-
堆的所有節點對應底層的一個超時節點。
當我們調用 setTimeout 的時候,首先根據 setTimeout 的入參,從 map 中找到二叉堆節點,然後插入鏈表的尾部,必要的時候,Node.js 會根據 js 二叉堆的最快超時時間來更新底層節點的超時時間。當事件循環處理定時器階段的時候,Node.js 會遍歷 JS 二叉堆,然後拿到過期的節點,再遍歷過期節點中的鏈表,逐個判斷是否需要執行回調,必要的時候調整 JS 二叉堆和底層的超時時間。
4.2 check、idle、prepare 階段
check、idle、prepare 階段相對比較簡單,每個階段維護一個隊列,然後在處理對應階段的時候,執行隊列中每個節點的回調,不過這三個階段比較特殊的是,隊列中的節點被執行後不會被刪除,而是一直在隊列裏,除非顯式刪除。
4.3 pending、closing 階段
pending 階段:在 Poll IO 回調裏產生的回調。closing 階段:執行關閉 handle 的回調。pending 和 closing 階段也是維護了一個隊列,然後在對應階段的時候執行每個節點的回調,最後刪除對應的節點。
4.4 Poll IO 階段
Poll IO 階段是最重要和複雜的一個階段,下面我們看一下實現。首先我們看一下 Poll IO 階段核心的數據結構:IO 觀察者,IO 觀察者是對文件描述符,感興趣事件和回調的封裝,主要是用在 epoll 中。
當我們有一個文件描述符需要被 epoll 監聽的時候
-
我們可以創建一個 IO 觀察者。
-
調用 uv__io_start 往事件循環中插入一個 IO 觀察者隊列。
-
Libuv 會記錄文件描述符和 IO 觀察者的映射關係。
-
在 Poll IO 階段的時候就會遍歷 IO 觀察者隊列,然後操作 epoll 去做相應的處理。
-
等從 epoll 返回的時候,我們就可以拿到哪些文件描述符的事件觸發了,最後根據文件描述符找到對應的 IO 觀察者並執行他的回調就行。
另外我們看到,Poll IO 階段會可能會阻塞,是否阻塞和阻塞多久取決於事件循環系統當前的狀態。當發生阻塞的時候,爲了保證定時器階段按時執行,epoll 阻塞的時間需要設置爲等於最快到期定時器節點的時間。
- 進程和進程間通信
5.1 創建進程
Node.js 中的進程是使用 fork+exec 模式創建的,fork 就是複製主進程的數據,exec 是加載新的程序執行。Node.js 提供了異步和同步創建進程兩種模式。
- 異步方式 異步方式就是創建一個人子進程後,主進程和子進程獨立執行,互不干擾。在主進程的數據結構中如圖所示,主進程會記錄子進程的信息,子進程退出的時候會用到
- 同步方式
同步創建子進程會導致主進程阻塞,具體的實現是
-
主進程中會新建一個新的事件循環結構體,然後基於這個新的事件循環創建一個子進程。
-
然後主進程就在新的事件循環中執行,舊的事件循環就被阻塞了。
-
子進程結束的時候,新的事件循環也就結束了,從而回到舊的事件循環。
5.2 進程間通信
接下來我們看一下父子進程間怎麼通信呢?在操作系統中,進程間的虛擬地址是獨立的,所以沒有辦法基於進程內存直接通信,這時候需要藉助內核提供的內存。進程間通信的方式有很多種,管道、信號、共享內存等等。
Node.js 選取的進程間通信方式是 Unix 域,Node.js 爲什麼會選取 Unix 域呢?因爲只有 Unix 域支持文件描述符傳遞,文件描述符傳遞是一個非常重要的能力。
首先我們看一下文件系統和進程的關係,在操作系統中,當進程打開一個文件的時候,他就是形成一個 fd->file->inode 這樣的關係,這種關係在 fork 子進程的時候會被繼承。
但是如果主進程在 fork 子進程之後,打開了一個文件,他想告訴子進程,那怎麼辦呢?如果僅僅是把文件描述符對應的數字傳給子進程,子進程是沒有辦法知道這個數字對應的文件的。如果通過 Unix 域發送的話,系統會把文件描述符和文件的關係也複製到子進程中。
具體實現
-
Node.js 底層通過 socketpair 創建兩個文件描述符,主進程拿到其中一個文件描述符,並且封裝 send 和 on meesage 方法進行進程間通信。
-
接着主進程通過環境變量把另一個文件描述符傳給子進程。
-
子進程同樣基於文件描述符封裝發送和接收數據的接口。這樣兩個進程就可以進行通信了。
- 線程和線程間通信
6.1 線程架構
Node.js 是單線程的,爲了方便用戶處理耗時的操作,Node.js 在支持多進程之後,又支持了多線程。Node.js 中多線程的架構如下圖所示,每個子線程本質上是一個獨立的事件循環,但是所有的線程會共享底層的 Libuv 線程池。
6.2 創建線程
接下來我們看看創建線程的過程。
當我們調用 new Worker 創建線程的時候
-
主線程會首先創建創建兩個通信的數據結構,接着往對端發送一個加載 JS 文件的消息。
-
然後調用底層接口創建一個線程。
-
這時候子線程就被創建出來了,子線程被創建後首先初始化自己的執行環境和上下文。
-
接着從通信的數據結構中讀取消息,然後加載對應的 js 文件執行,最後進入事件循環。
6.3 線程間通信
那麼 Node.js 中的線程是如何通信的呢?線程和進程不一樣,進程的地址空間是獨立的,不能直接通信,但是線程的地址是共享的,所以可以基於進程的內存直接進行通信。
下面我們看看 Node.js 是如何實現線程間通信的。瞭解 Node.js 線程間通信之前,我們先看一下一些核心數據結構。
-
Message 代表一個消息。
-
MessagePortData 是對操作 Message 的封裝和對消息的承載。
-
MessagePort 是代表通信的端點,是對 MessagePortData 的封裝。
-
MessageChannel 是代表通信的兩端,即兩個 MessagePort。
我們看到兩個 port 是互相關聯的,當需要給對端發送消息的時候,只需要往對端的消息隊列插入一個節點就行。我們來看看通信的具體過程
-
線程 1 調用 postMessage 發送消息。
-
postMessage 會先對消息進行序列化。
-
然後拿到對端消息隊列的鎖,並把消息插入隊列中。
-
成功發送消息後,還需要通知消息接收者所在的線程。
-
消息接收者會在事件循環的 Poll IO 階段處理這個消息。
- Cluster
我們知道 Node.js 是單進程架構的,不能很好地利用多核,Cluster 模塊使得 Node.js 支持多進程的服務器架構。Node.s 支持輪詢(主進程 accept )和共享(子進程 accept )兩種模式,可以通過環境變量進行設置。多進程的服務器架構通常有兩種模式,第一種是主進程處理連接,然後分發給子進程處理,第二種是子進程共享 socket,通過競爭的方式獲取連接進行處理。
我們看一下 Cluster 模塊是如何使用的。
這個是 Cluster 模塊的使用例子
-
主進程調用 fork 創建子進程。
-
子進程啓動一個服務器。通常來說,多個進程監聽同一個端口會報錯,我們看看 Node.js 裏是怎麼處理這個問題的。
7.1 主進程 accept
我們先看一下主進程 accept 這種模式。
-
首先主進程 fork 多個子進程處理。
-
然後在每個子進程裏調用 listen。
-
調用 listen 函數的時候,子進程會給主進程發送一個消息。
-
這時候主進程就會創建一個 socket,綁定地址,並置爲監聽狀態。
-
當連接到來的時候,主進程負責接收連接,然後然後通過文件描述符傳遞的方式分發給子進程處理。
7.2 子進程 accept
我們再看一下子進程 accept 這種模式。
-
首先主進程 fork 多個子進程處理。
-
然後在每個子進程裏調用 listen。
-
調用 listen 函數的時候,子進程會給主進程發送一個消息。
-
這時候主進程就會創建一個 socket,並綁定地址。但不會把它置爲監聽狀態,而是把這個 socket 通過文件描述符的方式返回給子進程。
-
當連接到來的時候,這個連接會被某一個子進程處理。
-
Libuv 線程池
爲什麼需要使用線程池?文件 IO、DNS、CPU 密集型不適合在 Node.js 主線程處理,需要把這些任務放到子線程處理。
瞭解線程池實現之前我們先看看 Libuv 的異步通信機制,異步通信指的是 Libuv 主線程和其他子線程之間的通信機制。比如 Libuv 主線程正在執行回調,子線程同時完成了一個任務,那麼如何通知主線程,這就需要用到異步通信機制。
-
Libuv 內部維護了一個異步通信的隊列,需要異步通信的時候,就往裏面插入一個 async 節點
-
同時 Libuv 還維護了一個異步通信相關的 IO 觀察者
-
當有異步任務完成的時候,就會設置對應 async 節點的 pending 字段爲 1,說明任務完成了。並且通知主線程。
-
主線程在 Poll IO 階段就會執行處理異步通信的回調,在回調裏會執行 pending 爲 1 的節點的回調。
下面我們來看一下線程池的實現。
-
線程池維護了一個待處理任務隊列,多個線程互斥地從隊列中摘下任務進行處理。
-
當給線程池提交一個任務的時候,就是往這個隊列裏插入一個節點。
-
當子線程處理完任務後,就會把這個任務插入到事件循環本身維護到一個已完成任務隊列中,並且通過異步通信的機制通知主線程。
-
主線程在 Poll IO 階段就會執行任務對應的回調。
- 信號
上圖是操作系統中信號的表示,操作系統使用一個 long 類型表示進程收到的信息,並且用一個數組來標記對應的處理函數。我們看一下信號模塊在 Libuv 中是如何實現的。
-
Libuv 中維護了一個紅黑樹,當我們監聽一個新的信號時就會新插入一個節點
-
在插入第一個節點時,Libuv 會封裝一個 IO 觀察者註冊到 epoll 中,用來監聽是否有信號需要處理
-
當信號發生的時候,就會根據信號類型從紅黑樹中找到對應的 handle,然後通知主線程
-
主線程在 Poll IO 階段就會逐個執行回調。
Node.js 中,是通過監聽 newListener 事件來實現信號的監聽的,newListener 是一種 hooks 的機制。每次監聽事件的時候,如果監聽了 newListener 事件,那就會觸發 newListener 事件。所以當執行 process.on(’SIGINT’) 時,就會調用 startListeningIfSignal (newListener 事件的處理器)註冊一個紅黑樹節點。並在 events 模塊保存了訂閱關係,信號觸發時,執行 process.emit(‘SIGINT’) 通知訂閱者。
- 文件
10.1 文件操作
Node.js 中文件操作分爲同步和異步模式,同步模式就是在主進程中直接調用文件系統的 API,這種方式可能會引起進程的阻塞,異步方式是藉助了 Libuv 線程池,把阻塞操作放到子線程中去處理,主線程可以繼續處理其他操作。
10.2 文件監聽
Node.js 中文件監聽提供了基於輪詢和訂閱發佈兩種模式。我們先看一下輪詢模式的實現,輪詢模式比較簡單,他是使用定時器實現的,Node.js 會定時執行回調,在回調中比較當前文件的元數據和上一次獲取的是否不一樣,如果是則說明文件改變了。
第二種監聽模式是更高效的 inotify 機制,inotify 是基於訂閱發佈模式的,避免了無效的輪詢。我們首先看一下操作系統的 inotify 機制,inotify 和 epoll 的使用是類似的:
-
首先通過接口獲取一個 inotify 實例對應的文件描述符。
-
然後通過增刪改查接口操作 inotify 實例,比如需要監聽一個文件的時候,就調用接口往 inotify 實例中新增一個訂閱關係。
-
當文件發生改變的時候,我們可以調用 read 接口獲取哪些文件發生了改變,inotify 通常結合 epoll 來使用。
接下來我們看看 Node.js 中是如何基於 inotify 機制 實現文件監聽的。
-
首先 Node.js 把 inotify 實例的文件描述符和回調封裝成 io 觀察者註冊到 epoll 中
-
當需要監聽一個文件的時候,Node.js 會調用系統函數往 inotify 實例中插入一個項,並且拿到一個 id,接着 Node.js 把這個 id 和文件信息封裝到一個結構體中,然後插入紅黑樹。
-
Node.js 維護了一棵紅黑樹,紅黑樹的每個節點記錄了被監聽的文件或目錄和事件觸發時的回調列表。
-
如果有事件觸發時,在 Poll IO 階段就會執行對應的回調,回調裏會判斷哪些文件發生了變化,然後根據 id 從紅黑樹中找到對應的接口,從而執行對應的回調。
-
TCP
我們通常會調用 http.createServer(cb).listen(port) 啓動一個服務器,那麼這個過程到底做了什麼呢?listen 函數其實是對網絡 API 的封裝:
-
首先獲取一個 socket。
-
然後綁定地址到該 socket 中。
-
接着調用 listen 函數把該 socket 改成監聽狀態。
-
最後把該 socket 註冊到 epoll 中,等待連接的到來。
那麼 Node.js 是如何處理連接的呢?當建立了一個 TCP 連接後,Node.js 會在 Poll IO 階段執行對應的回調:
-
Node.js 會調用 accept 摘下一個 TCP 連接。
-
接着會調 C++ 層,C++ 層會新建一個對象表示和客戶端通信的實例。
-
接着回調 JS 層,JS 也會新建一個對象表示通信的實例,主要是給用戶使用。
-
最後註冊等待可讀事件,等待客戶端發送數據過來。
這就是 Node.js 處理一個連接的過程,處理完一個連接後,Node.js 會判斷是否設置了 single_accept 標記,如果有則睡眠一段時間,給其他進程處理剩下的連接,一定程度上避免負責不均衡,如果沒有設置該標記,Node.js 會繼續嘗試處理下一個連接。這就是 Node.js 處理連接的整個過程。
- UDP
因爲 UDP 是非連接、不可靠的協議,在實現和使用上相對比較簡單,這裏講一下發送 UDP 數據的過程,當我們發送一個 UDP 數據包的時候,Libuv 會把數據先插入等待發送隊列,接着在 epoll 中註冊等待可寫事件,當可寫事件觸發的時候,Libuv 會遍歷等待發送隊列,逐個節點發送,成功發送後,Libuv 會把節點移到發送成功隊列,並往 pending 階段插入一個節點,在 pending 階段,Libuv 就會執行發送完成隊列裏每個節點的會調通知調用方發送結束。
- DNS
因爲通過域名查找 IP 或通過 IP 查找域名的 API 是阻塞式的,所以這兩個功能是藉助了 Libuv 的線程池實現的。發起一個查找操作的時候,Node.js 會往線程池提及一個任務,然後就繼續處理其他事情,同時,線程池的子線程會調用底層函數做 DNS 查詢,查詢結束後,子線程會把結果交給主線程。這就是整個查找過程。
其他的 DNS 操作是通過 cares 實現的,cares 是一個異步 DNS 庫,我們知道 DNS 是一個應用層協議,cares 就是實現了這個協議。我們看一下 Node.js 是怎麼使用 cares 實現 DNS 操作的。
-
首先 Node.js 初始化的時候,會初始化 cares 庫,其中最重要的是設置 socket 變更的回調。我們一會可以看到這個回調的作用。
-
當我們發起一個 DNS 操作的時候,Node.js 會調用 cares 的接口,cares 接口會創建一個 socket 併發起一個 DNS 查詢,接着通過狀態變更回調把 socket 傳給 Node.js。
-
Node.js 把這個 socket 註冊到 epoll 中,等待查詢結果,當查詢結果返回的時候,Node.js 會調用 cares 的函數進行解析,最後調用 JS 回調通知用戶。
-
總結
本文從整體的角度介紹了一下 Node.js 的實現,同時也介紹了一些核心模塊的實現。從本文中,我們也看到了很多底層的內容,Node.js 正是結合了 V8 和 操作系統的能力創建出來的 JS 運行時。深入去理解 Node.js 的原理和實現,可以更好地使用 Node.js。
更多內容可以參考:https://github.com/theanarkh/understand-nodejs
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/3lDUbsBbTJxO9UnEDuxwug