Node.js 底層原理
作者介紹:陳躍標(biāo),ByteDance Web Infra 團(tuán)隊(duì)成員,目前主要負(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 啟動(dòng)過程 Node.js 事件循環(huán) 二 Node.js 核心模塊的實(shí)現(xiàn) 進(jìn)程和進(jìn)程間通信 線程和線程間通信 Cluster Libuv 線程池 信號(hào)處理 文件 TCP UDP DNS
1. Nodejs 組成
Node.js 主要由 V8、Libuv 和第三方庫組成:
Libuv:跨平臺(tái)的異步 IO 庫,但它提供的功能不僅僅是 IO,還包括進(jìn)程、線程、信號(hào)、定時(shí)器、進(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 是我們平時(shí)使用的那些模塊(http/fs)。 C++ 代碼分為三個(gè)部分,第一部分是封裝了 Libuv 的功能,第二部分則是不依賴于 Libuv ( crypto 部分 API 使用了 Libuv 線程池),比如 Buffer 模塊,第三部分是 V8 的代碼。 C 語言層的代碼主要是封裝了操作系統(tǒng)的功能,比如 TCP、UDP。
了解了 Node.js 的組成和代碼架構(gòu)后,我們看看 Node.js 啟動(dòng)的過程都做了什么。
3. Node.js啟動(dòng)過程
3.1 注冊(cè) C++ 模塊

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


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

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

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

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

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

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

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

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

另外我們看到,Poll IO 階段會(huì)可能會(huì)阻塞,是否阻塞和阻塞多久取決于事件循環(huán)系統(tǒng)當(dāng)前的狀態(tài)。當(dāng)發(fā)生阻塞的時(shí)候,為了保證定時(shí)器階段按時(shí)執(zhí)行,epoll 阻塞的時(shí)間需要設(shè)置為等于最快到期定時(shí)器節(jié)點(diǎn)的時(shí)間。
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)建一個(gè)人子進(jìn)程后,主進(jìn)程和子進(jìn)程獨(dú)立執(zhí)行,互不干擾。在主進(jìn)程的數(shù)據(jù)結(jié)構(gòu)中如圖所示,主進(jìn)程會(huì)記錄子進(jìn)程的信息,子進(jìn)程退出的時(shí)候會(huì)用到

同步方式

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

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

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

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

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

當(dāng)我們調(diào)用 new Worker 創(chuàng)建線程的時(shí)候
主線程會(huì)首先創(chuàng)建創(chuàng)建兩個(gè)通信的數(shù)據(jù)結(jié)構(gòu),接著往對(duì)端發(fā)送一個(gè)加載 JS 文件的消息。 然后調(diào)用底層接口創(chuàng)建一個(gè)線程。 這時(shí)候子線程就被創(chuàng)建出來了,子線程被創(chuàng)建后首先初始化自己的執(zhí)行環(huán)境和上下文。 接著從通信的數(shù)據(jù)結(jié)構(gòu)中讀取消息,然后加載對(duì)應(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 代表一個(gè)消息。 MessagePortData 是對(duì)操作 Message 的封裝和對(duì)消息的承載。 MessagePort 是代表通信的端點(diǎn),是對(duì) MessagePortData 的封裝。 MessageChannel 是代表通信的兩端,即兩個(gè) MessagePort。

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

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ìng)爭(zhēng)的方式獲取連接進(jìn)行處理。


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

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

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

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

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

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

9. 信號(hào)

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

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

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

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

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

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

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

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

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

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

往期推薦



最后
歡迎加我微信,拉你進(jìn)技術(shù)群,長(zhǎng)期交流學(xué)習(xí)...
歡迎關(guān)注「前端Q」,認(rèn)真學(xué)前端,做個(gè)專業(yè)的技術(shù)人...


