Node.js 底層原理
作者介紹:陳躍標(biāo),ByteDance Web Infra 團(tuán)隊成員,目前主要負(fù)責(zé) Node.js 基礎(chǔ)架構(gòu)方向的工作
本文內(nèi)容主要分為兩大部分,第一部分是 Node.js 的基礎(chǔ)和架構(gòu),第二部分是 Node.js 核心模塊的實(shí)現(xiàn)。
一 Node.js 基礎(chǔ)和架構(gòu) Node.js 的組成 Node.js 代碼架構(gòu) Node.js 啟動過程 Node.js 事件循環(huán) 二 Node.js 核心模塊的實(shí)現(xiàn) 進(jìn)程和進(jìn)程間通信 線程和線程間通信 Cluster Libuv 線程池 信號處理 文件 TCP UDP DNS
1. Nodejs 組成
Node.js 主要由 V8、Libuv 和第三方庫組成:
Libuv:跨平臺的異步 IO 庫,但它提供的功能不僅僅是 IO,還包括進(jìn)程、線程、信號、定時器、進(jìn)程間通信,線程池等。 第三方庫:異步 DNS 解析( cares )、HTTP 解析器(舊版使用 http_parser,新版使用 llhttp)、HTTP2 解析器( nghttp2 )、 解壓壓縮庫( zlib )、加密解密庫( openssl )等等。 V8:實(shí)現(xiàn) JS 解析、執(zhí)行和支持自定義拓展,得益于 V8 支持自定義拓展,才有了 Node.js。
2. Node.js代碼架構(gòu)

上圖是 Node.js 的代碼架構(gòu),Node.js的代碼主要分為 JS、C++、C 三種:
JS 是我們平時使用的那些模塊(http/fs)。 C++ 代碼分為三個部分,第一部分是封裝了 Libuv 的功能,第二部分則是不依賴于 Libuv ( crypto 部分 API 使用了 Libuv 線程池),比如 Buffer 模塊,第三部分是 V8 的代碼。 C 語言層的代碼主要是封裝了操作系統(tǒng)的功能,比如 TCP、UDP。
了解了 Node.js 的組成和代碼架構(gòu)后,我們看看 Node.js 啟動的過程都做了什么。
3. Node.js啟動過程
3.1 注冊 C++ 模塊

首先 Node.js 會調(diào)用 registerBuiltinModules 函數(shù)注冊 C++ 模塊,這個函數(shù)會調(diào)用一系列 registerxxx 的函數(shù),我們發(fā)現(xiàn)在 Node.js 源碼里找不到這些函數(shù),因?yàn)檫@些函數(shù)是在各個 C++ 模塊中,通過宏定義實(shí)現(xiàn)的,宏展開后就是上圖黃色框的內(nèi)容,每個 registerxxx 函數(shù)的作用就是往 C++ 模塊的鏈表了插入一個節(jié)點(diǎn),最后會形成一個鏈表。
那么 Node.js 里是如何訪問這些 C++ 模塊的呢?在 Node.js 中,是通過 internalBinding 訪問 C++ 模塊的,internalBinding 的邏輯很簡單,就是根據(jù)模塊名從模塊隊列中找到對應(yīng)模塊。但是這個函數(shù)只能在 Node.js 內(nèi)部使用,不能在用戶 JS 模塊使用,用戶可以通過 process.binding 訪問 C++ 模塊。
3.2 Environment 對象和綁定 Context
注冊完 C++ 模塊后就開始創(chuàng)建 Environment 對象,Environment 是 Node.js 執(zhí)行時的環(huán)境對象,類似一個全局變量的作用,他記錄了 Node.js 在運(yùn)行時的一些公共數(shù)據(jù)。創(chuàng)建完 Environment 后,Node.js 會把該對象綁定到 V8 的 Context 中,為什么要這樣做呢?主要是為了在 V8 的執(zhí)行上下文里拿到 env 對象,因?yàn)?V8 中只有 Isolate、Context 這些對象,如果我們想在 V8 的執(zhí)行環(huán)境中獲取 Environment 對象的內(nèi)容,就可以通過 Context 獲取 Environment 對象。


3.3 初始化模塊加載器
Node.js 首先傳入 C++ 模塊加載器,執(zhí)行 loader.js,loader.js 主要是封裝了 C++ 模塊加載器和原生 JS 模塊加載器,并保存到 env 對象中。 接著傳入 C++ 和原生 JS 模塊加載器,執(zhí)行 run_main_module.js。 在 run_main_module.js 中傳入普通 JS 和原生 JS 模塊加載器,執(zhí)行用戶的 JS。
假設(shè)用戶 JS 如下:
require('net') require('./myModule')
分別加載了一個用戶模塊和原生 JS 模塊,我們看看加載過程,執(zhí)行 require 的時候:
Node.js 首先會判斷是否是原生 JS 模塊,如果不是則直接加載用戶模塊,否則,會使用原生模塊加載器加載原生 JS 模塊。 加載原生 JS 模塊的時候,如果用到了 C++ 模塊,則使用 internalBinding 去加載。

3.4 執(zhí)行用戶代碼,Libuv 事件循環(huán)
接著 Node.js 就會執(zhí)行用戶的 JS,通常用戶的 JS 會給事件循環(huán)生產(chǎn)任務(wù),然后就進(jìn)入了事件循環(huán)系統(tǒng),比如我們 listen 一個服務(wù)器的時候,就會在事件循環(huán)中新建一個 TCP handle。Node.js 就會在這個事件循環(huán)中一直運(yùn)行。
net.createServer(() => {}).listen(80)

4. 事件循環(huán)
下面我們看一下事件循環(huán)的實(shí)現(xiàn)。事件循環(huán)主要分為 7 個階段,timer 階段主要是處理定時器相關(guān)的任務(wù),pending 階段主要是處理 Poll IO 階段回調(diào)里產(chǎn)生的回調(diào),check、prepare、idle 階段是自定義的階段,這三個階段的任務(wù)每次事件序循環(huán)都會被執(zhí)行,Poll IO 階段主要是處理網(wǎng)絡(luò) IO、信號、線程池等等任務(wù),closing 階段主要是處理關(guān)閉的 handle,比如關(guān)閉服務(wù)器。

timer 階段: 用二叉堆實(shí)現(xiàn),最快過期的在根節(jié)點(diǎn)。 pending 階段:處理 Poll IO 階段回調(diào)里產(chǎn)生的回調(diào) check、prepare、idle 階段:每次事件循環(huán)都會被執(zhí)行。 Poll IO 階段:處理文件描述符相關(guān)事件。 closing 階段:執(zhí)行調(diào)用 uv_close 函數(shù)時傳入的回調(diào)。下面我們詳細(xì)看一下每個階段的實(shí)現(xiàn)。
4.1 定時器階段
定時器的底層數(shù)據(jù)結(jié)構(gòu)是二叉堆,最快到期的節(jié)點(diǎn)在最上面。在定時器階段的時候,就會逐個節(jié)點(diǎn)遍歷,如果節(jié)點(diǎn)超時了,那么就執(zhí)行他的回調(diào),如果沒有超時,那么后面的節(jié)點(diǎn)也不用判斷了,因?yàn)楫?dāng)前節(jié)點(diǎn)是最快過期的,如果他都沒有過期,說明其他節(jié)點(diǎn)也沒有過期。節(jié)點(diǎn)的回調(diào)被執(zhí)行后,就會被刪除,為了支持 setInterval 的場景,如果設(shè)置 repeat 標(biāo)記,那么這個節(jié)點(diǎn)會被重新插入到二叉堆。

我們看到底層的實(shí)現(xiàn)稍微簡單,但是 Node.js 的定時器模塊實(shí)現(xiàn)就稍微復(fù)雜。

Node.js 在 JS 層維護(hù)了一個二叉堆。 堆的每個節(jié)點(diǎn)維護(hù)了一個鏈表,這個鏈表中,最久超時的排到后面。 另外 Node.js 還維護(hù)了一個 map,map 的 key 是相對超時時間,值就是對應(yīng)的二叉堆節(jié)點(diǎn)。 堆的所有節(jié)點(diǎn)對應(yīng)底層的一個超時節(jié)點(diǎn)。
當(dāng)我們調(diào)用 setTimeout 的時候,首先根據(jù) setTimeout 的入?yún)ⅲ瑥?map 中找到二叉堆節(jié)點(diǎn),然后插入鏈表的尾部,必要的時候,Node.js 會根據(jù) js 二叉堆的最快超時時間來更新底層節(jié)點(diǎn)的超時時間。當(dāng)事件循環(huán)處理定時器階段的時候,Node.js 會遍歷 JS 二叉堆,然后拿到過期的節(jié)點(diǎn),再遍歷過期節(jié)點(diǎn)中的鏈表,逐個判斷是否需要執(zhí)行回調(diào),必要的時候調(diào)整 JS 二叉堆和底層的超時時間。
4.2 check、idle、prepare 階段
check、idle、prepare 階段相對比較簡單,每個階段維護(hù)一個隊列,然后在處理對應(yīng)階段的時候,執(zhí)行隊列中每個節(jié)點(diǎn)的回調(diào),不過這三個階段比較特殊的是,隊列中的節(jié)點(diǎn)被執(zhí)行后不會被刪除,而是一直在隊列里,除非顯式刪除。

4.3 pending、closing 階段
pending 階段:在 Poll IO 回調(diào)里產(chǎn)生的回調(diào)。closing 階段:執(zhí)行關(guān)閉 handle 的回調(diào)。pending 和closing 階段也是維護(hù)了一個隊列,然后在對應(yīng)階段的時候執(zhí)行每個節(jié)點(diǎn)的回調(diào),最后刪除對應(yīng)的節(jié)點(diǎn)。

4.4 Poll IO 階段
Poll IO 階段是最重要和復(fù)雜的一個階段,下面我們看一下實(shí)現(xiàn)。首先我們看一下 Poll IO 階段核心的數(shù)據(jù)結(jié)構(gòu):IO 觀察者,IO 觀察者是對文件描述符,感興趣事件和回調(diào)的封裝,主要是用在 epoll 中。

當(dāng)我們有一個文件描述符需要被 epoll 監(jiān)聽的時候
我們可以創(chuàng)建一個 IO 觀察者。 調(diào)用 uv__io_start 往事件循環(huán)中插入一個 IO 觀察者隊列。 Libuv 會記錄文件描述符和 IO 觀察者的映射關(guān)系。 在 Poll IO 階段的時候就會遍歷 IO 觀察者隊列,然后操作 epoll 去做相應(yīng)的處理。 等從 epoll 返回的時候,我們就可以拿到哪些文件描述符的事件觸發(fā)了,最后根據(jù)文件描述符找到對應(yīng)的 IO 觀察者并執(zhí)行他的回調(diào)就行。

另外我們看到,Poll IO 階段會可能會阻塞,是否阻塞和阻塞多久取決于事件循環(huán)系統(tǒng)當(dāng)前的狀態(tài)。當(dāng)發(fā)生阻塞的時候,為了保證定時器階段按時執(zhí)行,epoll 阻塞的時間需要設(shè)置為等于最快到期定時器節(jié)點(diǎn)的時間。
5. 進(jìn)程和進(jìn)程間通信
5.1 創(chuàng)建進(jìn)程
Node.js 中的進(jìn)程是使用 fork+exec 模式創(chuàng)建的,fork 就是復(fù)制主進(jìn)程的數(shù)據(jù),exec 是加載新的程序執(zhí)行。Node.js 提供了異步和同步創(chuàng)建進(jìn)程兩種模式。
異步方式 異步方式就是創(chuàng)建一個人子進(jìn)程后,主進(jìn)程和子進(jìn)程獨(dú)立執(zhí)行,互不干擾。在主進(jìn)程的數(shù)據(jù)結(jié)構(gòu)中如圖所示,主進(jìn)程會記錄子進(jìn)程的信息,子進(jìn)程退出的時候會用到

同步方式

同步創(chuàng)建子進(jìn)程會導(dǎo)致主進(jìn)程阻塞,具體的實(shí)現(xiàn)是
主進(jìn)程中會新建一個新的事件循環(huán)結(jié)構(gòu)體,然后基于這個新的事件循環(huán)創(chuàng)建一個子進(jìn)程。 然后主進(jìn)程就在新的事件循環(huán)中執(zhí)行,舊的事件循環(huán)就被阻塞了。 子進(jìn)程結(jié)束的時候,新的事件循環(huán)也就結(jié)束了,從而回到舊的事件循環(huán)。
5.2 進(jìn)程間通信
接下來我們看一下父子進(jìn)程間怎么通信呢?在操作系統(tǒng)中,進(jìn)程間的虛擬地址是獨(dú)立的,所以沒有辦法基于進(jìn)程內(nèi)存直接通信,這時候需要借助內(nèi)核提供的內(nèi)存。進(jìn)程間通信的方式有很多種,管道、信號、共享內(nèi)存等等。

Node.js 選取的進(jìn)程間通信方式是 Unix 域,Node.js 為什么會選取 Unix 域呢?因?yàn)橹挥?Unix 域支持文件描述符傳遞,文件描述符傳遞是一個非常重要的能力。
首先我們看一下文件系統(tǒng)和進(jìn)程的關(guān)系,在操作系統(tǒng)中,當(dāng)進(jìn)程打開一個文件的時候,他就是形成一個fd->file->inode 這樣的關(guān)系,這種關(guān)系在 fork 子進(jìn)程的時候會被繼承。

但是如果主進(jìn)程在 fork 子進(jìn)程之后,打開了一個文件,他想告訴子進(jìn)程,那怎么辦呢?如果僅僅是把文件描述符對應(yīng)的數(shù)字傳給子進(jìn)程,子進(jìn)程是沒有辦法知道這個數(shù)字對應(yīng)的文件的。如果通過 Unix 域發(fā)送的話,系統(tǒng)會把文件描述符和文件的關(guān)系也復(fù)制到子進(jìn)程中。
具體實(shí)現(xiàn)
Node.js 底層通過 socketpair 創(chuàng)建兩個文件描述符,主進(jìn)程拿到其中一個文件描述符,并且封裝 send和 on meesage 方法進(jìn)行進(jìn)程間通信。 接著主進(jìn)程通過環(huán)境變量把另一個文件描述符傳給子進(jìn)程。 子進(jìn)程同樣基于文件描述符封裝發(fā)送和接收數(shù)據(jù)的接口。這樣兩個進(jìn)程就可以進(jìn)行通信了。

6. 線程和線程間通信
6.1 線程架構(gòu)
Node.js 是單線程的,為了方便用戶處理耗時的操作,Node.js 在支持多進(jìn)程之后,又支持了多線程。Node.js 中多線程的架構(gòu)如下圖所示,每個子線程本質(zhì)上是一個獨(dú)立的事件循環(huán),但是所有的線程會共享底層的 Libuv 線程池。

6.2 創(chuàng)建線程
接下來我們看看創(chuàng)建線程的過程。

當(dāng)我們調(diào)用 new Worker 創(chuàng)建線程的時候
主線程會首先創(chuàng)建創(chuàng)建兩個通信的數(shù)據(jù)結(jié)構(gòu),接著往對端發(fā)送一個加載 JS 文件的消息。 然后調(diào)用底層接口創(chuàng)建一個線程。 這時候子線程就被創(chuàng)建出來了,子線程被創(chuàng)建后首先初始化自己的執(zhí)行環(huán)境和上下文。 接著從通信的數(shù)據(jù)結(jié)構(gòu)中讀取消息,然后加載對應(yīng)的js文件執(zhí)行,最后進(jìn)入事件循環(huán)。
6.3 線程間通信
那么 Node.js 中的線程是如何通信的呢?線程和進(jìn)程不一樣,進(jìn)程的地址空間是獨(dú)立的,不能直接通信,但是線程的地址是共享的,所以可以基于進(jìn)程的內(nèi)存直接進(jìn)行通信。

下面我們看看 Node.js 是如何實(shí)現(xiàn)線程間通信的。了解 Node.js 線程間通信之前,我們先看一下一些核心數(shù)據(jù)結(jié)構(gòu)。
Message 代表一個消息。 MessagePortData 是對操作 Message 的封裝和對消息的承載。 MessagePort 是代表通信的端點(diǎn),是對 MessagePortData 的封裝。 MessageChannel 是代表通信的兩端,即兩個 MessagePort。

我們看到兩個 port 是互相關(guān)聯(lián)的,當(dāng)需要給對端發(fā)送消息的時候,只需要往對端的消息隊列插入一個節(jié)點(diǎn)就行。我們來看看通信的具體過程
線程 1 調(diào)用 postMessage 發(fā)送消息。 postMessage 會先對消息進(jìn)行序列化。 然后拿到對端消息隊列的鎖,并把消息插入隊列中。 成功發(fā)送消息后,還需要通知消息接收者所在的線程。 消息接收者會在事件循環(huán)的 Poll IO 階段處理這個消息。

7. Cluster
我們知道 Node.js 是單進(jìn)程架構(gòu)的,不能很好地利用多核,Cluster 模塊使得 Node.js 支持多進(jìn)程的服務(wù)器架構(gòu)。Node.s 支持輪詢(主進(jìn)程 accept )和共享(子進(jìn)程 accept )兩種模式,可以通過環(huán)境變量進(jìn)行設(shè)置。多進(jìn)程的服務(wù)器架構(gòu)通常有兩種模式,第一種是主進(jìn)程處理連接,然后分發(fā)給子進(jìn)程處理,第二種是子進(jìn)程共享 socket,通過競爭的方式獲取連接進(jìn)行處理。


我們看一下 Cluster 模塊是如何使用的。

這個是 Cluster 模塊的使用例子
主進(jìn)程調(diào)用 fork 創(chuàng)建子進(jìn)程。 子進(jìn)程啟動一個服務(wù)器。通常來說,多個進(jìn)程監(jiān)聽同一個端口會報錯,我們看看 Node.js 里是怎么處理這個問題的。
7.1 主進(jìn)程accept

我們先看一下主進(jìn)程 accept 這種模式。
首先主進(jìn)程 fork 多個子進(jìn)程處理。 然后在每個子進(jìn)程里調(diào)用 listen。 調(diào)用 listen 函數(shù)的時候,子進(jìn)程會給主進(jìn)程發(fā)送一個消息。 這時候主進(jìn)程就會創(chuàng)建一個 socket,綁定地址,并置為監(jiān)聽狀態(tài)。 當(dāng)連接到來的時候,主進(jìn)程負(fù)責(zé)接收連接,然后然后通過文件描述符傳遞的方式分發(fā)給子進(jìn)程處理。
7.2 子進(jìn)程 accept

我們再看一下子進(jìn)程 accept 這種模式。
首先主進(jìn)程 fork 多個子進(jìn)程處理。 然后在每個子進(jìn)程里調(diào)用 listen。 調(diào)用listen函數(shù)的時候,子進(jìn)程會給主進(jìn)程發(fā)送一個消息。 這時候主進(jìn)程就會創(chuàng)建一個 socket,并綁定地址。但不會把它置為監(jiān)聽狀態(tài),而是把這個 socket 通過文件描述符的方式返回給子進(jìn)程。 當(dāng)連接到來的時候,這個連接會被某一個子進(jìn)程處理。
8. Libuv線程池
為什么需要使用線程池?文件 IO、DNS、CPU 密集型不適合在 Node.js 主線程處理,需要把這些任務(wù)放到子線程處理。

了解線程池實(shí)現(xiàn)之前我們先看看 Libuv 的異步通信機(jī)制,異步通信指的是 Libuv 主線程和其他子線程之間的通信機(jī)制。比如 Libuv 主線程正在執(zhí)行回調(diào),子線程同時完成了一個任務(wù),那么如何通知主線程,這就需要用到異步通信機(jī)制。

Libuv 內(nèi)部維護(hù)了一個異步通信的隊列,需要異步通信的時候,就往里面插入一個 async 節(jié)點(diǎn) 同時 Libuv 還維護(hù)了一個異步通信相關(guān)的 IO 觀察者 當(dāng)有異步任務(wù)完成的時候,就會設(shè)置對應(yīng) async 節(jié)點(diǎn)的 pending 字段為 1,說明任務(wù)完成了。并且通知主線程。 主線程在 Poll IO 階段就會執(zhí)行處理異步通信的回調(diào),在回調(diào)里會執(zhí)行 pending 為 1 的節(jié)點(diǎn)的回調(diào)。
下面我們來看一下線程池的實(shí)現(xiàn)。
線程池維護(hù)了一個待處理任務(wù)隊列,多個線程互斥地從隊列中摘下任務(wù)進(jìn)行處理。 當(dāng)給線程池提交一個任務(wù)的時候,就是往這個隊列里插入一個節(jié)點(diǎn)。 當(dāng)子線程處理完任務(wù)后,就會把這個任務(wù)插入到事件循環(huán)本身維護(hù)到一個已完成任務(wù)隊列中,并且通過異步通信的機(jī)制通知主線程。 主線程在 Poll IO 階段就會執(zhí)行任務(wù)對應(yīng)的回調(diào)。

9. 信號

上圖是操作系統(tǒng)中信號的表示,操作系統(tǒng)使用一個 long 類型表示進(jìn)程收到的信息,并且用一個數(shù)組來標(biāo)記對應(yīng)的處理函數(shù)。我們看一下信號模塊在 Libuv 中是如何實(shí)現(xiàn)的。

Libuv 中維護(hù)了一個紅黑樹,當(dāng)我們監(jiān)聽一個新的信號時就會新插入一個節(jié)點(diǎn) 在插入第一個節(jié)點(diǎn)時,Libuv 會封裝一個 IO 觀察者注冊到 epoll 中,用來監(jiān)聽是否有信號需要處理 當(dāng)信號發(fā)生的時候,就會根據(jù)信號類型從紅黑樹中找到對應(yīng)的 handle,然后通知主線程 主線程在 Poll IO 階段就會逐個執(zhí)行回調(diào)。
Node.js 中,是通過監(jiān)聽 newListener 事件來實(shí)現(xiàn)信號的監(jiān)聽的,newListener 是一種 hooks 的機(jī)制。每次監(jiān)聽事件的時候,如果監(jiān)聽了 newListener 事件,那就會觸發(fā) newListener 事件。所以當(dāng)執(zhí)行 process.on(’SIGINT’) 時,就會調(diào)用 startListeningIfSignal (newListener事件的處理器)注冊一個紅黑樹節(jié)點(diǎn)。并在 events 模塊保存了訂閱關(guān)系,信號觸發(fā)時,執(zhí)行 process.emit(‘SIGINT’) 通知訂閱者。

10. 文件
10.1 文件操作
Node.js 中文件操作分為同步和異步模式,同步模式就是在主進(jìn)程中直接調(diào)用文件系統(tǒng)的 API,這種方式可能會引起進(jìn)程的阻塞,異步方式是借助了 Libuv 線程池,把阻塞操作放到子線程中去處理,主線程可以繼續(xù)處理其他操作。

10.2 文件監(jiān)聽
Node.js 中文件監(jiān)聽提供了基于輪詢和訂閱發(fā)布兩種模式。我們先看一下輪詢模式的實(shí)現(xiàn),輪詢模式比較簡單,他是使用定時器實(shí)現(xiàn)的,Node.js 會定時執(zhí)行回調(diào),在回調(diào)中比較當(dāng)前文件的元數(shù)據(jù)和上一次獲取的是否不一樣,如果是則說明文件改變了。

第二種監(jiān)聽模式是更高效的 inotify 機(jī)制,inotify 是基于訂閱發(fā)布模式的,避免了無效的輪詢。我們首先看一下操作系統(tǒng)的 inotify 機(jī)制,inotify 和 epoll 的使用是類似的:
首先通過接口獲取一個 inotify 實(shí)例對應(yīng)的文件描述符。 然后通過增刪改查接口操作 inotify 實(shí)例,比如需要監(jiān)聽一個文件的時候,就調(diào)用接口往 inotify 實(shí)例中新增一個訂閱關(guān)系。 當(dāng)文件發(fā)生改變的時候,我們可以調(diào)用 read 接口獲取哪些文件發(fā)生了改變,inotify 通常結(jié)合 epoll 來使用。
接下來我們看看 Node.js 中是如何基于 inotify 機(jī)制 實(shí)現(xiàn)文件監(jiān)聽的。

首先 Node.js 把 inotify 實(shí)例的文件描述符和回調(diào)封裝成 io 觀察者注冊到 epoll 中 當(dāng)需要監(jiān)聽一個文件的時候,Node.js 會調(diào)用系統(tǒng)函數(shù)往 inotify 實(shí)例中插入一個項,并且拿到一個 id,接著 Node.js 把這個 id 和文件信息封裝到一個結(jié)構(gòu)體中,然后插入紅黑樹。 Node.js 維護(hù)了一棵紅黑樹,紅黑樹的每個節(jié)點(diǎn)記錄了被監(jiān)聽的文件或目錄和事件觸發(fā)時的回調(diào)列表。 如果有事件觸發(fā)時,在 Poll IO 階段就會執(zhí)行對應(yīng)的回調(diào),回調(diào)里會判斷哪些文件發(fā)生了變化,然后根據(jù)id從紅黑樹中找到對應(yīng)的接口,從而執(zhí)行對應(yīng)的回調(diào)。
11. TCP
我們通常會調(diào)用 http.createServer(cb).listen(port) 啟動一個服務(wù)器,那么這個過程到底做了什么呢?listen 函數(shù)其實(shí)是對網(wǎng)絡(luò) API 的封裝:
首先獲取一個 socket。 然后綁定地址到該 socket 中。 接著調(diào)用 listen 函數(shù)把該 socket 改成監(jiān)聽狀態(tài)。 最后把該 socket 注冊到 epoll 中,等待連接的到來。
那么 Node.js 是如何處理連接的呢?當(dāng)建立了一個 TCP 連接后,Node.js 會在 Poll IO 階段執(zhí)行對應(yīng)的回調(diào):
Node.js 會調(diào)用 accept 摘下一個 TCP 連接。 接著會調(diào) C++ 層,C++ 層會新建一個對象表示和客戶端通信的實(shí)例。 接著回調(diào) JS 層,JS 也會新建一個對象表示通信的實(shí)例,主要是給用戶使用。 最后注冊等待可讀事件,等待客戶端發(fā)送數(shù)據(jù)過來。
這就是 Node.js 處理一個連接的過程,處理完一個連接后,Node.js 會判斷是否設(shè)置了 single_accept 標(biāo)記,如果有則睡眠一段時間,給其他進(jìn)程處理剩下的連接,一定程度上避免負(fù)責(zé)不均衡,如果沒有設(shè)置該標(biāo)記,Node.js 會繼續(xù)嘗試處理下一個連接。這就是 Node.js 處理連接的整個過程。

12. UDP
因?yàn)?UDP 是非連接、不可靠的協(xié)議,在實(shí)現(xiàn)和使用上相對比較簡單,這里講一下發(fā)送 UDP 數(shù)據(jù)的過程,當(dāng)我們發(fā)送一個 UDP 數(shù)據(jù)包的時候,Libuv 會把數(shù)據(jù)先插入等待發(fā)送隊列,接著在 epoll 中注冊等待可寫事件,當(dāng)可寫事件觸發(fā)的時候,Libuv 會遍歷等待發(fā)送隊列,逐個節(jié)點(diǎn)發(fā)送,成功發(fā)送后,Libuv 會把節(jié)點(diǎn)移到發(fā)送成功隊列,并往 pending 階段插入一個節(jié)點(diǎn),在 pending 階段,Libuv 就會執(zhí)行發(fā)送完成隊列里每個節(jié)點(diǎn)的會調(diào)通知調(diào)用方發(fā)送結(jié)束。

13. DNS
因?yàn)橥ㄟ^域名查找 IP 或通過 IP 查找域名的 API 是阻塞式的,所以這兩個功能是借助了 Libuv 的線程池實(shí)現(xiàn)的。發(fā)起一個查找操作的時候,Node.js 會往線程池提及一個任務(wù),然后就繼續(xù)處理其他事情,同時,線程池的子線程會調(diào)用底層函數(shù)做 DNS 查詢,查詢結(jié)束后,子線程會把結(jié)果交給主線程。這就是整個查找過程。

其他的 DNS 操作是通過 cares 實(shí)現(xiàn)的,cares 是一個異步 DNS 庫,我們知道 DNS 是一個應(yīng)用層協(xié)議,cares 就是實(shí)現(xiàn)了這個協(xié)議。我們看一下 Node.js 是怎么使用 cares 實(shí)現(xiàn) DNS 操作的。

首先 Node.js 初始化的時候,會初始化 cares 庫,其中最重要的是設(shè)置 socket 變更的回調(diào)。我們一會可以看到這個回調(diào)的作用。 當(dāng)我們發(fā)起一個 DNS 操作的時候,Node.js 會調(diào)用 cares 的接口,cares 接口會創(chuàng)建一個 socket 并發(fā)起一個 DNS 查詢,接著通過狀態(tài)變更回調(diào)把 socket 傳給 Node.js。 Node.js 把這個 socket 注冊到 epoll 中,等待查詢結(jié)果,當(dāng)查詢結(jié)果返回的時候,Node.js 會調(diào)用 cares 的函數(shù)進(jìn)行解析,最后調(diào)用 JS 回調(diào)通知用戶。
14. 總結(jié)
本文從整體的角度介紹了一下 Node.js 的實(shí)現(xiàn),同時也介紹了一些核心模塊的實(shí)現(xiàn)。從本文中,我們也看到了很多底層的內(nèi)容,Node.js 正是結(jié)合了 V8 和 操作系統(tǒng)的能力創(chuàng)建出來的 JS 運(yùn)行時。深入去理解 Node.js的原理和實(shí)現(xiàn),可以更好地使用 Node.js。
更多內(nèi)容可以參考:https://github.com/theanarkh/understand-nodejs
