聊聊 Node.js 的底層原理
之前分享了 Node.js 的底層原理,主要是簡(jiǎn)單介紹了 Node.js 的一些基礎(chǔ)原理和一些核心模塊的實(shí)現(xiàn),本文從 Node.js 整體方面介紹 Node.js 的底層原理。
內(nèi)容主要包括五個(gè)部分。第一部分是首先介紹一下 Node.js 的組成和代碼架構(gòu)。然后介紹一下 Node.js 中的 Libuv, 還有 V8 和模塊加載器。最后介紹一下 Node.js 的服務(wù)器架構(gòu)。
1 Node.js 的組成和代碼架構(gòu)
下面先來看一下Node.js 的組成。Node.js 主要是由 V8、Libuv 和一些第三方庫組成。
1). V8 我們都比較熟悉,它是一個(gè) JS 引擎。但是它不僅實(shí)現(xiàn)了 JS 解析和執(zhí)行,它還是自定義拓展。比如說我們可以通過 V8 提供一些 C++ API 去定義一些全局變量,這樣話我們?cè)?JS 里面去使用這個(gè)變量了。正是因?yàn)?V8 支持這個(gè)自定義的拓展,所以才有了 Node.js 等 JS 運(yùn)行時(shí)。
2). Libuv 是一個(gè)跨平臺(tái)的異步 IO 庫。它主要的功能是它封裝了各個(gè)操作系統(tǒng)的一些 API, 提供網(wǎng)絡(luò)還有文件進(jìn)程的這些功能。我們知道在 JS 里面是沒有網(wǎng)絡(luò)文件這些功能的,在前端時(shí),是由瀏覽器提供的,而在 Node.js 里,這些功能是由 Libuv 提供的。
3). 另外 Node.js 里面還引用了很多第三方庫,比如 DNS 解析庫,還有 HTTP 解析器等等。
接下來看一下 Node.js 代碼整體的架構(gòu)。
Node.js 代碼主要是分為三個(gè)部分,分別是C、C++ 和 JS。
1. JS 代碼就是我們平時(shí)在使用的那些 JS 的模塊,比方說像 http 和 fs 這些模塊。
2. C++ 代碼主要分為三個(gè)部分,第一部分主要是封裝 Libuv 和第三方庫的 C++ 代碼,比如net 和 fs 這些模塊都會(huì)對(duì)應(yīng)一個(gè) C++ 模塊,它主要是對(duì)底層的一些封裝。第二部分是不依賴 Libuv 和第三方庫的 C++ 代碼,比方像 Buffer 模塊的實(shí)現(xiàn)。第三部分 C++ 代碼是 V8 本身的代碼。
3. C 語言代碼主要是包括 Libuv 和第三方庫的代碼,它們都是純 C 語言實(shí)現(xiàn)的代碼。
了解了 Nodejs 的組成和代碼架構(gòu)之后,再來看一下 Node.js 中各個(gè)主要部分的實(shí)現(xiàn)。
2 Node.js 中的 Libuv
首先來看一下 Node.js 中的 Libuv,下面從三個(gè)方面介紹 Libuv。
1). 介紹 Libuv 的模型和限制
2). 介紹線程池解決的問題和帶來的問題
3). 介紹事件循環(huán)
2.1 Libuv 的模型和限制
Libuv 本質(zhì)上是一個(gè)生產(chǎn)者消費(fèi)者的模型。
從上面這個(gè)圖中,我們可以看到在 Libuv 中有很多種生產(chǎn)任務(wù)的方式,比如說在一個(gè)回調(diào)里,在 Node.js 初始化的時(shí)候,或者在線程池完成一些操作的時(shí)候,這些方式都可以生產(chǎn)任務(wù)。然后 Libuv 會(huì)不斷的去消費(fèi)這些任務(wù),從而驅(qū)動(dòng)著整個(gè)進(jìn)程的運(yùn)行,這就是我們一直說的事件循環(huán)。
但是生產(chǎn)者的消費(fèi)者模型存在一個(gè)問題,就是消費(fèi)者和生產(chǎn)者之間,怎么去同步?比如說在沒有任務(wù)消費(fèi)的時(shí)候,這個(gè)消費(fèi)者他應(yīng)該在干嘛?第一種方式是消費(fèi)者可以睡眠一段時(shí)間,睡醒之后,他會(huì)去判斷有沒有任務(wù)需要消費(fèi),如果有的話就繼續(xù)消費(fèi),如果沒有的話他就繼續(xù)睡眠。很顯然這種方式其實(shí)是比較低效的。第二種方式是消費(fèi)者會(huì)把自己掛起,也就是說這個(gè)消費(fèi)所在的進(jìn)程會(huì)被掛起,然后等到有任務(wù)的時(shí)候,操作系統(tǒng)就會(huì)喚醒它,相對(duì)來說,這種方式是更高效的,Libuv 里也正是使用這種方式。
這個(gè)邏輯主要是由事件驅(qū)動(dòng)模塊實(shí)現(xiàn)的,下面看一下事件驅(qū)動(dòng)的大致的流程。
應(yīng)用層代碼可以通過事件驅(qū)動(dòng)模塊訂閱 fd 的事件,如果這個(gè)事件還沒有準(zhǔn)備好的話,那么這個(gè)進(jìn)程就會(huì)被掛起。然后等到這個(gè) fd 所對(duì)應(yīng)的事件觸發(fā)了之后,就會(huì)通過事件驅(qū)動(dòng)模塊回調(diào)應(yīng)用層的代碼。
下面以 Linux 的 事件驅(qū)動(dòng)模塊 epoll 為例,來看一下使用流程。
1. 首先通過 epoll_create 去創(chuàng)建一個(gè)epoll 實(shí)例。
2. 然后通過 epoll_ctl 這個(gè)函數(shù)訂閱、修改或者取消訂閱一個(gè) fd 的一些事件。
3. 最后通過 epoll_wait 去判斷當(dāng)前訂閱的事件有沒有發(fā)生,如果有事情要發(fā)生的話,那么就直接執(zhí)行上層回調(diào),如果沒有事件發(fā)生的話,這種時(shí)候可以選擇不阻塞,定時(shí)阻塞或者一直阻塞,直到有事件發(fā)生。要不要阻塞或者說阻塞多久,是根據(jù)當(dāng)前系統(tǒng)的情況。比如 Node.js 里面如果有定時(shí)器的節(jié)點(diǎn)的話,那么 Node.js 就會(huì)定時(shí)阻塞,這樣就可以保證定時(shí)器可以按時(shí)執(zhí)行。
接下來再深入一點(diǎn)去看一下 epoll 的大致的實(shí)現(xiàn)。
當(dāng)應(yīng)用層代碼調(diào)用事件驅(qū)動(dòng)模塊訂閱 fd 的事件時(shí),比如說這里是訂閱一個(gè)可讀事件。那么事件驅(qū)動(dòng)模塊它就會(huì)往這個(gè) fd 的隊(duì)列里面注冊(cè)一個(gè)回調(diào),如果當(dāng)前這個(gè)事件還沒有觸發(fā),這個(gè)進(jìn)程它就會(huì)被阻塞。等到有一塊數(shù)據(jù)寫入了這個(gè) fd 時(shí),也就是說這個(gè) fd 有可讀事件了,操作系統(tǒng)就會(huì)執(zhí)行事件驅(qū)動(dòng)模塊的回調(diào),事件驅(qū)動(dòng)模塊就會(huì)相應(yīng)的執(zhí)行用層代碼的回調(diào)。
但是 epoll 存在一些限制。首先第一個(gè)是不支持文件操作的,比方說文件讀寫這些,因?yàn)椴僮飨到y(tǒng)沒有實(shí)現(xiàn)。第二個(gè)是不適合執(zhí)行耗時(shí)操作,比如大量 CPU 計(jì)算、引起進(jìn)程阻塞的任務(wù),因?yàn)?epoll 通常是搭配單線程的,如果在單線程里執(zhí)行耗時(shí)任務(wù),就會(huì)導(dǎo)致后面的任務(wù)無法執(zhí)行。
2.2 線程池解決的問題和帶來的問題
針對(duì)這個(gè)問題,Libuv 提供的解決方案就是使用線程池。下面來看一下引入了線程池之后, 線程池和主線程的關(guān)系。
從這個(gè)圖中我們可以看到,當(dāng)應(yīng)用層提交任務(wù)時(shí),比方說像 CPU 計(jì)算還有文件操作,這種時(shí)候不是交給主線程去處理的,而是直接交給線程池處理的。線程池處理完之后它會(huì)通知主線程。
但是引入了多線程后會(huì)帶來一個(gè)問題,就是怎么去保證上層代碼跑在單個(gè)線程里面。因?yàn)槲覀冎?JS 它是單線程的,如果線程池處理完一個(gè)任務(wù)之后,直接執(zhí)行上層回調(diào),那么上層代碼就會(huì)完全亂了。這種時(shí)候就需要一個(gè)異步通知的機(jī)制,也就是說當(dāng)一個(gè)線程它處理完任務(wù)的時(shí)候,它不是直接去執(zhí)行上程回調(diào)的,而是通過異步機(jī)制去通知主線程來執(zhí)行這個(gè)回調(diào)。
Libuv 中具體通過 fd 的方式去實(shí)現(xiàn)的。當(dāng)線程池完成任務(wù)時(shí),它會(huì)以原子的方式去修改這個(gè) fd 為可讀的,然后在主線程事件循環(huán)的 Poll IO 階段時(shí),它就會(huì)執(zhí)行這個(gè)可讀事件的回調(diào),從而執(zhí)行上層的回調(diào)??梢钥吹剑琋ode.js 雖然是跑在多線程上面的,但是所有的 JS 代碼都是跑在單個(gè)線程里的,這也是我們經(jīng)常討論的 Node.js 是單線程還是多線程的,從不同的角度去看就會(huì)得到不同的答案。
下面的圖就是異步任務(wù)處理的一個(gè)大致過程。
比如我們想讀一個(gè)文件的時(shí)候,這時(shí)候主線程會(huì)把這個(gè)任務(wù)直接提交到線程池里面去處理,然后主線程就可以繼續(xù)去做自己的事情了。當(dāng)在線程池里面的線程完成這個(gè)任務(wù)之后,它就會(huì)往這個(gè)主線程的隊(duì)列里面插入一個(gè)節(jié)點(diǎn),然后主線程在 Poll IO 階段時(shí),它就會(huì)去執(zhí)行這個(gè)節(jié)點(diǎn)里面的回調(diào)。
2.3 事件循環(huán)
了解 Libuv 的一些核心實(shí)現(xiàn)之后,下面我們?cè)倏匆幌?Libuv 中一個(gè)著名的事件循環(huán)。事件循環(huán)主要分為七個(gè)階段,
1. 第一是 timer 階段,timer 階段是處理定時(shí)器相關(guān)的一些任務(wù),比如 Node.js 中的 setTimeout和 setInterval。
2. 第二是 pending 的階段, pending 階段主要處理 Poll IO 階段執(zhí)行回調(diào)時(shí)產(chǎn)生的回調(diào)。
3. 第三是 check、prepare 和 idle 三個(gè)階段,這三個(gè)階段主要處理一些自定義的任務(wù)。setImmediate 屬于 check 階段。
4. 第四是 Poll IO 階段,Poll IO 階段主要要處理跟文件描述符相關(guān)的一些事件。5. 第五是 close 階段, 它主要是處理,調(diào)用了 uv_close 時(shí)傳入的回調(diào)。比如關(guān)閉一個(gè) TCP 連接時(shí)傳入的回調(diào),它就會(huì)在這個(gè)階段被執(zhí)行。
下面這個(gè)圖是各個(gè)階段在事件循環(huán)的順序圖。
下面我們來看一下每個(gè)階段的實(shí)現(xiàn)。
1. 定時(shí)器
Libuv 在底層里面維護(hù)了一個(gè)最小堆,每個(gè)定時(shí)節(jié)點(diǎn)就是堆里面的一個(gè)節(jié)點(diǎn)(Node.js 只用了 Libuv 的一個(gè)定時(shí)器節(jié)點(diǎn)),越早超時(shí)的節(jié)點(diǎn)就在越上面。然后等到定時(shí)期階段的時(shí)候, Libuv 就會(huì)從上往下去遍歷這個(gè)最小堆判斷當(dāng)前節(jié)點(diǎn)有沒有超時(shí),如果沒有到期的話,那么后面節(jié)點(diǎn)也不需要去判斷了,因?yàn)樽钤绲狡诘墓?jié)點(diǎn)都沒到期,那么它后面節(jié)點(diǎn)也顯然不會(huì)到期。如果當(dāng)前節(jié)點(diǎn)到期了,那么就會(huì)執(zhí)行它的回調(diào),并且把它移出這個(gè)最小堆。但是為了支持類似 setInterval 這種場(chǎng)景。如果這個(gè)節(jié)點(diǎn)設(shè)置了repeat 標(biāo)記,那么這個(gè)節(jié)點(diǎn)它會(huì)被重新插入到最小堆中,等待下一次的超時(shí)。
2. check、idle、prepare 階段和 pending、close 階段。
這五個(gè)階段的實(shí)現(xiàn)其實(shí)類似的,它們都對(duì)應(yīng)自己的一個(gè)任務(wù)隊(duì)列。當(dāng)產(chǎn)生任務(wù)的時(shí)候,它就會(huì)往這個(gè)隊(duì)列里面插入一個(gè)節(jié)點(diǎn),等到相應(yīng)的階段時(shí),它就會(huì)去遍歷這個(gè)隊(duì)列里面的每個(gè)節(jié)點(diǎn),并且執(zhí)行它的回調(diào)。但是 check idle 還有 prepare 階段有一個(gè)比較特別的地方,就是當(dāng)這些階段的節(jié)點(diǎn)回調(diào)被執(zhí)行之后,它還會(huì)重新插入隊(duì)列里面,也是說這三個(gè)階段它對(duì)應(yīng)的任務(wù)在每一輪的事件循環(huán)都會(huì)被執(zhí)行。
3. Poll IO 階段 Poll IO 本質(zhì)上是對(duì)前面講的事件驅(qū)動(dòng)模塊的封裝。下面來看一下整體的流程。
當(dāng)我們訂閱一個(gè) fd 的事件時(shí),Libuv 就會(huì)通過 epoll 去注冊(cè)這個(gè) fd 對(duì)應(yīng)的事件。如果這時(shí)候事件沒有就緒,那么進(jìn)程就會(huì)阻塞在 epoll_wait 中。等到這事件觸發(fā)的時(shí)候,進(jìn)程就會(huì)被喚醒,喚醒之后,它就遍歷 epoll 返回了事件列表,并執(zhí)行上層回調(diào)。
現(xiàn)在有一個(gè)底層能力,那么這個(gè)底層能力是怎么暴露給上層的 JS 去使用呢?這種時(shí)候就需要用到 JS 引擎 V8了。
3. Node.js 中的 V8
下面從三個(gè)方面介紹 V8。
1. 介紹 V8 在 Node.js 的作用和 V8 的一些基礎(chǔ)概念
2. 介紹如何通過 V8 執(zhí)行 JS 和拓展 JS
3. 介紹如何通過 V8 實(shí)現(xiàn) JS 和 C++ 通信
3.1 V8 在 Node.js 的作用和基礎(chǔ)概念
V8 在 Node.js 里面主要是有兩個(gè)作用,第一個(gè)是負(fù)責(zé)解析和執(zhí)行 JS。第二個(gè)是支持拓展 JS 能力,作為這個(gè) JS 和 C++ 的橋梁。下面我們先來看一下 V8 里面那些重要的概念。
1. Isolate:首先第一個(gè)是 Isolate 它是代表一個(gè) V8 的實(shí)例,它相當(dāng)于這一個(gè)容器。通常一個(gè)線程里面會(huì)有一個(gè)這樣的實(shí)例。比如說在 Node.js主線程里面,它就會(huì)有一個(gè) Isolate 實(shí)例。
2. Context:Context 是代表我們執(zhí)行代碼的一個(gè)上下文,它主要是保存像 Object,F(xiàn)unction 這些我們平時(shí)經(jīng)常會(huì)用到的內(nèi)置的類型。如果我們想拓展 JS 功能,就可以通過這個(gè)對(duì)象實(shí)現(xiàn)。
3. ObjectTemplate:ObjectTemplate 是用于定義對(duì)象的模板,然后我們就可以基于這個(gè)模板去創(chuàng)建對(duì)象。
4. FunctionTemplate:FunctionTemplate 和 ObjectTemplate 是類似的,它主要是用于定義一個(gè)函數(shù)的模板,然后就可以基于這個(gè)函數(shù)模板去創(chuàng)建一個(gè)函數(shù)。
5. FunctionCallbackInfo:用于實(shí)現(xiàn) JS 和 C++ 通信的對(duì)象。
6. Handle:Handle 是用管理在 V8 堆里面那些對(duì)象,因?yàn)橄裎覀兤綍r(shí)定義的對(duì)象和數(shù)組,它是存在 V8 堆內(nèi)存里面的。Handle 就是用于管理這些對(duì)象。
7. HandleScope:HandleScope 是一個(gè) Handle 容器,HandleScope 里面可以定義很多 Handle,它主要是利用自己的生命周期管理多個(gè) Handle。
下面我們通過一個(gè)代碼來看一下 HandleScope 和 Handle 它們之間的關(guān)系。
首先第一步新建一個(gè) HandleScope,就會(huì)在一個(gè)棧里面定義一個(gè) HandleScope 對(duì)象。然后第二步新建了一個(gè) Handle 并且把它指向一個(gè)堆對(duì)象。這時(shí)候就會(huì)在棧里面分配一個(gè)叫 Local 對(duì)象,然后在堆里面分配一塊 slot 所代表的內(nèi)存和一個(gè) Object 對(duì)象,并且建立關(guān)聯(lián)關(guān)系。當(dāng)執(zhí)行完這個(gè)函數(shù)的時(shí)候,這個(gè)棧就會(huì)被清空,相應(yīng)的這個(gè) slot 代表的內(nèi)存也會(huì)被釋放,但是 Object 所代表這個(gè)對(duì)象,它是不會(huì)立馬被釋放的,它會(huì)等待 GC 的回收。
3.2 通過 V8 執(zhí)行 JS 和拓展 JS
了解了 V8 的基礎(chǔ)概念之后,來看一下怎么通過 V8 執(zhí)行一段 JS 的代碼。
首先第一步新建一個(gè) Isolate,它這表示一個(gè)隔離的實(shí)例。第二步定義一個(gè) HandleScope 對(duì)象,因?yàn)槲覀兿旅嫘枰x Handle。第三步定義一個(gè) Context,這是代碼執(zhí)行所在的上下文。第四步定義一些需要被執(zhí)行的 JS 代碼。第五步通過 Script 對(duì)象的 Compile 函數(shù)編譯 JS 代碼。編譯完之后,我們會(huì)得到一個(gè) Script 對(duì)象,然后執(zhí)行這個(gè)對(duì)象的 Run 函數(shù)就可以完成代碼的執(zhí)行。
接下來再看一下怎么去拓展 JS 原有的一些能力。
首先第一步是通過 Context 上下文對(duì)象拿到一個(gè)全局的對(duì)象,類似于在前端里面的 window 對(duì)象。第二步通過 ObjectTemplate 新建一個(gè)對(duì)象的模板,然后接著會(huì)給這個(gè)對(duì)象模板設(shè)置一個(gè) test 屬性, 值是函數(shù)。接著通過這個(gè)對(duì)象模板新建一個(gè)對(duì)象,并且把這個(gè)對(duì)象設(shè)置到一個(gè)全局變量里面去。這樣我們就可以在 JS 層去訪問這個(gè)全局對(duì)象。
下面我們通過使用剛才定義那個(gè)全局對(duì)象來看一下 JS 和 C++ 是怎么通信的。
3.3 通過 V8 實(shí)現(xiàn) JS 和 C++ 層通信
當(dāng)在 JS 層調(diào)用剛才定義 test 函數(shù)時(shí),就會(huì)相應(yīng)的執(zhí)行 C++ 層的 test 函數(shù)。這個(gè)函數(shù)有一個(gè)入?yún)⑹? FunctionCallbackInfo,在 C++ 中可以通過這個(gè)對(duì)象拿到 JS 傳來一些參數(shù),這樣就完成了 JS 層到 C++ 層通信。經(jīng)過一系列處理之后,還是可以通過這個(gè)對(duì)象給 JS 層設(shè)置需要返回給 JS 的內(nèi)容,這樣可以完成了 C++ 層到 JS 層的通信。
現(xiàn)在有了底層能力,有了這一層的接口,但是我們是怎么去加載后執(zhí)行 JS 代碼呢?這時(shí)候就需要模塊加載器。
4 Node.js 中的模塊加載器
Node.js 中有五種模塊加載器。
1. JSON 模塊加載器
2. 用戶 JS 模塊加載器
3. 原生 JS 模塊加載器
4. 內(nèi)置 C++ 模塊加載器
5. Addon 模塊加載器
現(xiàn)在來看下每種模塊加載器。
4.1 JSON 模塊加載器
JSON 模塊加載器實(shí)現(xiàn)比較簡(jiǎn)單,Node.js 從硬盤里面把 JSON 文件讀到內(nèi)存里面去,然后通過 JSON.parse 函數(shù)進(jìn)行解析,就可以拿到里面的數(shù)據(jù)。
4.2 用戶 JS 模塊
用戶 JS 模塊就是我們平時(shí)寫的一些 JS 代碼。當(dāng)通過 require 函數(shù)加載一個(gè)用戶 JS 模塊時(shí),Node.js 就會(huì)從硬盤讀取這個(gè)模塊的內(nèi)容到內(nèi)存中,然后通過 V8 提供了一個(gè)函數(shù)叫 CompileFunctionInContext 把讀取的代碼封裝成一個(gè)函數(shù),接著新建立一個(gè) Module 對(duì)象。這個(gè)對(duì)象里面有兩個(gè)屬性叫 exports 和 require 函數(shù),這兩個(gè)對(duì)象就是我們平時(shí)在代碼里面所使用的變量,接著會(huì)把這個(gè)對(duì)象作為函數(shù)的參數(shù),并且執(zhí)行這個(gè)函數(shù),執(zhí)行完這個(gè)函數(shù)的時(shí)候,就可以通過 module.exports 拿到這個(gè)函數(shù)(模塊)里面導(dǎo)出的內(nèi)容。這里需要注意的是這里的 require 函數(shù)是可以加載原生 JS 模塊和用戶模塊的,所以我們平時(shí)在我們代碼里面,可以通過require 加載我們自己寫的模塊,或者 Node.js 本身提供的 JS 模塊。
4.3 原生 JS 模塊
接下來看下原生 JS 模塊加載器。原生JS 模塊是 Node.js 本身提供了一些 JS 模塊,比如經(jīng)常使用的 http 和 fs。當(dāng)通過 require 函數(shù)加載 http 這個(gè)模塊的時(shí)候,Node.js 就會(huì)從內(nèi)存里讀取這個(gè)模塊所對(duì)應(yīng)內(nèi)容。因?yàn)樵?JS 模塊默認(rèn)是打包進(jìn)內(nèi)存里面的,所以直接從內(nèi)存里面讀就可以了,不需要從硬盤里面去讀。然后還是通過 V8 提供的 CompileFunctionInContext 這個(gè)函數(shù)把讀取的代碼封裝成一個(gè)函數(shù),接著新建一個(gè) NativeModule 對(duì)象,同樣這個(gè)對(duì)象里面也是有個(gè) exports 屬性,接著它會(huì)把這個(gè)對(duì)象傳到這個(gè)函數(shù)里面去執(zhí)行,執(zhí)行完這函數(shù)之后,就可以通過 module.exports 拿到這個(gè)函數(shù)里面導(dǎo)出的內(nèi)容。需要注意是這里傳入的 require 函數(shù)是一個(gè)叫 NativeModuleRequire 函數(shù),這個(gè)函數(shù)它就只能加載原生 JS 模塊。另外這里還傳了另外一個(gè) internalBinding 函數(shù),這個(gè)函數(shù)是用于加載 C++ 模塊的,所以在原生 JS 模塊里面,是可以加載 C++ 模塊的。
4.4 C++ 模塊
Node.js 在初始化的時(shí)候會(huì)注冊(cè) C++ 模塊,并且形成一個(gè) C++ 模塊鏈表。當(dāng)加載 C++ 模塊時(shí),Node.js 就通過模塊名,從這個(gè)鏈表里面找到對(duì)應(yīng)的節(jié)點(diǎn),然后去執(zhí)行它里面的鉤子函數(shù),執(zhí)行完之后就可以拿到 C++ 模塊導(dǎo)出的內(nèi)容。
4.5 Addon 模塊
接著再來看一下 Addon 模塊, Addon 模塊本質(zhì)上是一個(gè)動(dòng)態(tài)鏈接庫。當(dāng)通過 require 加載Addon 模塊的時(shí)候,Node.js 會(huì)通過 dlopen 這個(gè)函數(shù)去加載這個(gè)動(dòng)態(tài)鏈接庫。下圖是我們定義一個(gè) Addon 模塊時(shí)的一個(gè)標(biāo)準(zhǔn)格式。
它里面有一些 C語言宏,宏展開之后里面內(nèi)容像下圖所示。
里面主要定義了一個(gè)結(jié)構(gòu)體和一個(gè)函數(shù),這個(gè)函數(shù)會(huì)把這個(gè)結(jié)構(gòu)體賦值給 Node.js 的一個(gè)全局變量,然后 Nodejs 它就可以通過全局變量拿到這個(gè)結(jié)構(gòu)體,并且執(zhí)行它里面的一個(gè)鉤子函數(shù),執(zhí)行完之后就可以拿到它里面要導(dǎo)出的一些內(nèi)容。
現(xiàn)在有了底層的能力,也有了這一次層的接口,也有了代碼加載器。最后我們來看一下 Node.js 作為一個(gè)服務(wù)器的時(shí)候,它的架構(gòu)是怎么樣的?
5 Node.js 的服務(wù)器架構(gòu)
下面從兩個(gè)方面介紹 Node.js 的服務(wù)器架構(gòu)
1. 介紹服務(wù)器處理 TCP 連接的模型
2. 介紹 Node.js 中的實(shí)現(xiàn)和存在的問題
5.1 處理 TCP 連接的模型
首先來看一下網(wǎng)絡(luò)編程中怎么去創(chuàng)建一個(gè) TCP 服務(wù)器。
- int fd = socket(…);
- bind(fd, 監(jiān)聽地址);
- listen(fd);
首先建一個(gè) socket, 然后把需要監(jiān)聽的地址綁定到這個(gè) socket 中,最后通過 listen 函數(shù)啟動(dòng)服務(wù)器。啟動(dòng)服務(wù)器之后,那么怎么去處理 TCP 連接呢?
1). 串行處理(accept 和 handle 都會(huì)引起進(jìn)程阻塞)
第一種處理方式是串行處理,串行方式就是在一個(gè) while 循環(huán)里面,通過 accept 函數(shù)不斷地摘取 TCP 連接,然后處理它。這種方式的缺點(diǎn)就是它每次只能處理一個(gè)連接,處理完一個(gè)連接之后,才能繼續(xù)處理下一個(gè)連接。
2). 多進(jìn)程/多線程
第二種方式是多進(jìn)程或者多線程的方式。這種方式主要是利用多個(gè)進(jìn)程或者線程同時(shí)處理多個(gè)連接。但這種模式它的缺點(diǎn)就是當(dāng)流量非常大的時(shí)候,進(jìn)程數(shù)或者線程數(shù)它會(huì)成為這種架構(gòu)下面的一個(gè)瓶頸,因?yàn)槲覀儾荒軣o限的創(chuàng)建進(jìn)程或者線程,像 Apache 還有 PHP 就是這種架構(gòu)的。
3). 單進(jìn)程單線程 + 事件驅(qū)動(dòng)( Reactor & Proactor ) 第三種就是單線程 + 事件驅(qū)動(dòng)的模式。這種模式下有兩種類型,第一種叫 Reactor, 第二種叫 Proactor。Reactor 模式就是應(yīng)用程序可以通過事件驅(qū)動(dòng)模塊注冊(cè) fd 的讀寫事件,然后事件觸發(fā)的時(shí)候,它就會(huì)通過事件驅(qū)動(dòng)模塊回調(diào)上層的代碼。
Proactor 模式就是應(yīng)用程序可以通過事件驅(qū)動(dòng)模塊注冊(cè) fd 的讀寫完成事件,然后這個(gè)讀寫完成事件后就會(huì)通過事件驅(qū)動(dòng)模塊回調(diào)上層代碼。
我們看到這兩種模式的區(qū)別是,數(shù)據(jù)讀寫是由內(nèi)核完成的,還是由應(yīng)用程序完成的。很顯然,通過內(nèi)核去完成是更高效的,但是因?yàn)?Proactor 這種模式它兼容性還不是很好,所以目前用的還不算太多,主要目前主流的一些服務(wù)器,它用的都是 Reactor 模式。比方說像 Node.js、Redis 和 Nginx 這些服務(wù)器用的都是這種模式。
剛才提到 Node.js 是單進(jìn)程單線程加事件驅(qū)動(dòng)的架構(gòu)。那么單線程的架構(gòu)它怎么去利用多核呢?這種時(shí)候就需要用到多進(jìn)程的這種模式了,每一個(gè)進(jìn)程里面會(huì)包含一個(gè)Reactor 模式。但是引入多進(jìn)程之后,它會(huì)帶來一個(gè)問題,就是多進(jìn)程之間它怎么去監(jiān)聽同一個(gè)端口。
5.2 Node.js 的實(shí)現(xiàn)和問題
下面來看下針對(duì)多進(jìn)程監(jiān)聽同一個(gè)端口的一些解決方式。
1. 主進(jìn)程監(jiān)聽端口并接收請(qǐng)求,輪詢分發(fā)(輪詢模式)
2. 子進(jìn)程競(jìng)爭(zhēng)接收請(qǐng)求(共享模式)
3. 子進(jìn)程負(fù)載均衡處理連接(SO_REUSEPORT 模式)
第一種方式就是主進(jìn)程去監(jiān)聽這個(gè)端口,并且接收連接。它接收連接之后,通過一定的算法(比如輪詢)分發(fā)給各個(gè)子進(jìn)程。這種模式。它的一個(gè)缺點(diǎn)就是當(dāng)流量非常大的時(shí)候,這個(gè)主進(jìn)程就會(huì)成為瓶頸,因?yàn)樗赡芏紒聿患敖邮栈蛘叻职l(fā)這個(gè)連接給子進(jìn)程去處理。
第二種就是主進(jìn)程創(chuàng)建監(jiān)聽 socket, 然后子進(jìn)程通過 fork 的方式繼承這個(gè)監(jiān)聽的 socket, 當(dāng)有一個(gè)連接到來的時(shí)候,操作系統(tǒng)就喚醒所有的子進(jìn)程,所有子進(jìn)程會(huì)以競(jìng)爭(zhēng)的方式接收連接。這種模式,它的缺點(diǎn)主要是有兩個(gè),第一個(gè)就是負(fù)載均衡的問題,因?yàn)椴僮飨到y(tǒng)喚醒了所有的進(jìn)程,可能會(huì)導(dǎo)致某一個(gè)進(jìn)程一直在處理連接,其他其它進(jìn)程都沒機(jī)會(huì)處理連接。然后另外一個(gè)問題就是驚群的問題,因?yàn)椴僮飨到y(tǒng)喚起了所有的進(jìn)程,但是只有一個(gè)進(jìn)程它會(huì)處理這個(gè)連接,然后剩下進(jìn)程就會(huì)被無效地喚醒。這種方式會(huì)造成一定的性能的損失。
第三種通過 SO_REUSEPORT 這個(gè)標(biāo)記來解決剛才提到的兩個(gè)問題。在這種模式下,每個(gè)子進(jìn)程都會(huì)有一個(gè)獨(dú)立的監(jiān)聽 socket 和連接隊(duì)列。當(dāng)有一個(gè)連接到來的時(shí)候,操作系統(tǒng)會(huì)把這個(gè)連接分發(fā)給某一個(gè)子進(jìn)程并且喚醒它。這樣就可以解決驚群的問題,因?yàn)樗粫?huì)喚醒一個(gè)子進(jìn)程。又因?yàn)椴僮飨到y(tǒng)分發(fā)這個(gè)連接的時(shí)候,內(nèi)部是有一個(gè)負(fù)載均衡的算法。所以這樣的話又可以解決負(fù)載均衡的問題。
接下來我們看一下 Node.js 中的實(shí)現(xiàn)。
1). 輪詢模式。在這種模式下,主進(jìn)程會(huì) fork 多個(gè)子進(jìn)程,然后每個(gè)子進(jìn)程里面都會(huì)調(diào)用 listen 函數(shù)。但是 listen 函數(shù)不會(huì)監(jiān)聽一個(gè)端口,它會(huì)請(qǐng)求主進(jìn)程監(jiān)聽這個(gè)端口,當(dāng)有連接到來的時(shí)候,這個(gè)主進(jìn)程就會(huì)接收這個(gè)連接,然后通過文件描述符的方式傳給各個(gè)子進(jìn)程去處理。
2). 共享模式 共享模式下,主進(jìn)程同樣還是會(huì) fork 多個(gè)子進(jìn)程,然后每個(gè)子進(jìn)程里面還是會(huì)執(zhí)行 listen 函數(shù),但同樣的這個(gè) listen 函數(shù)不會(huì)監(jiān)聽一個(gè)端口,它會(huì)請(qǐng)求主進(jìn)程創(chuàng)建一個(gè) socket 并綁定到一個(gè)需要監(jiān)聽的地址,接著主進(jìn)程會(huì)把這個(gè) socket 通過文件描述符傳遞的方式傳給多個(gè)子進(jìn)程,這樣就可以達(dá)到多個(gè)子進(jìn)程同時(shí)監(jiān)聽同一個(gè)端口的效果。
通過剛才介紹,我們可以知道 Node.js 的服務(wù)器架構(gòu)存在的問題。如果我們使用輪詢模式,當(dāng)流量比較大的時(shí)候,那么這個(gè)主進(jìn)程就會(huì)成為系統(tǒng)瓶頸。如果我們使用共享模式,就會(huì)存在驚群和負(fù)載均衡的問題。不過在 Libuv 里面,可以通過設(shè)置 UV_TCP_SINGLE_ACCEPT 環(huán)境變量來一定程度緩解這個(gè)問題。當(dāng)我們?cè)O(shè)置了這個(gè)環(huán)境變量。Libuv 在接收完一個(gè)連接的時(shí)候,它就會(huì)休眠一會(huì),讓其它進(jìn)程也有接收連接的機(jī)會(huì)。
最后來總結(jié)一下,本文的內(nèi)容。Node.js 里面通過 Libuv 解決了操作系統(tǒng)相關(guān)的問題。通過 V8 解決了執(zhí)行 JS 和拓展 JS 功能的問題。通過模塊加載器解決了代碼加載還有組織的問題。通過多進(jìn)程的服務(wù)器架構(gòu),使得 Node.js 可以利用多核,并且解決了多個(gè)進(jìn)程監(jiān)聽同一個(gè)端口的問題。
下面是一些資料,有興趣的同學(xué)也可以看一下。
1. 基于 epoll + V8 的JS 運(yùn)行時(shí) Just:
https://github.com/theanarkh/read-just-0.1.4-code
2. 基于 io_uring+ V8 的 JS 運(yùn)行時(shí) No.js:
https://github.com/theanarkh/No.js
3. 理解 Node.js 原理:
https://github.com/theanarkh/understand-nodejs

























































