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