在學(xué)習(xí) Node 的時(shí)候,一定會(huì)被告知 Node 是基于 Event Loop 的,以及事件驅(qū)動(dòng)、事件隊(duì)列、非阻塞 IO 等概念,最終得出一個(gè)結(jié)論:Node 非常適合 IO 密集型的應(yīng)用,能夠以很少的資源消耗實(shí)現(xiàn)高并發(fā)。
但為什么 Event Loop 架構(gòu)可以實(shí)現(xiàn)較高的并發(fā)呢?這個(gè)問(wèn)題我一直也不明白,于是我在網(wǎng)上查了一些文章,大概明白了一點(diǎn),便進(jìn)行整理,方便以后查看。
場(chǎng)景模擬
我們來(lái)模擬一個(gè)典型的請(qǐng)求-響應(yīng)模型:客戶端向服務(wù)器發(fā)起請(qǐng)求,服務(wù)端收到請(qǐng)求后對(duì)請(qǐng)求進(jìn)行處理,然后進(jìn)行數(shù)據(jù)庫(kù)讀取,最后將讀取的結(jié)果進(jìn)行響應(yīng)。示例圖如下:

在這個(gè)模型中,會(huì)經(jīng)過(guò)三個(gè)階段:
- 服務(wù)器收到請(qǐng)求并處理
- 讀取數(shù)據(jù)庫(kù)
- 服務(wù)器處理數(shù)據(jù)并響應(yīng)
在這三個(gè)階段中,第一個(gè)階段和最后一個(gè)階段會(huì)由 CPU 進(jìn)行計(jì)算,第二個(gè)階段則是 IO 操作,只占用極少的 CPU。
我們假定這三個(gè)階段各耗時(shí) 1ms,因此服務(wù)器處理每個(gè)請(qǐng)求所花的時(shí)間就為 3ms。
假如我們的服務(wù)器是單核 CPU,并只有一個(gè)線程,那么每秒鐘可以處理的請(qǐng)求約等于 333 個(gè),也就是服務(wù)器的 QPS 等于 333。
QPS 可以用來(lái)客觀描述服務(wù)器的并發(fā)能力,QPS 越大,服務(wù)器的并發(fā)能力越好。
單核單線程的情況
假定我們的服務(wù)器是單核單線程的,那么其處理情況如下所示:

服務(wù)器收到了兩個(gè)請(qǐng)求:請(qǐng)求1和請(qǐng)求2。處理流程如下:
- 收到請(qǐng)求1,進(jìn)行處理
- 讀取數(shù)據(jù)庫(kù),由于是單線程,CPU 需要等待 IO 讀取完成再進(jìn)行后面的操作
- 處理讀取到的數(shù)據(jù),響應(yīng)客戶端
- 按上面的流程繼續(xù)處理請(qǐng)求2
單線程的瓶頸在于無(wú)法充分利用 CPU 資源,在進(jìn)行 IO 讀取時(shí),CPU 實(shí)際上是處于空閑狀態(tài),必須等待 IO 讀取完成再進(jìn)行后面的處理。對(duì)于每個(gè)請(qǐng)求,CPU 都會(huì)有一個(gè)較大的等待時(shí)期。在單線程模型下,服務(wù)器的 QPS 為 333。
單核多線程的情況
假定我們服務(wù)器是單核 CPU,但是開啟了兩個(gè)線程,其處理情況如下所示:

采用單核多線程時(shí),情況就不一樣了,對(duì)于請(qǐng)求1和請(qǐng)求2,將由兩個(gè)不同的線程進(jìn)行處理,流程如下:
- (線程1)收到請(qǐng)求1的請(qǐng)求進(jìn)行處理
- (線程1)請(qǐng)求1開始 IO 讀取,CPU 空閑
- (線程2)CPU 切換到請(qǐng)求2所在的線程,并進(jìn)行請(qǐng)求處理
- (線程2)請(qǐng)求2開始 IO 讀取,CPU 空閑
- (線程1)請(qǐng)求1的 IO 讀取完畢,CPU 切換到請(qǐng)求1所在的線程,處理數(shù)據(jù)并響應(yīng)客戶端,CPU 空閑
- (線程2)請(qǐng)求2的 IO 讀取完畢,CPU 切換到請(qǐng)求2所在的線程,處理數(shù)據(jù)并響應(yīng)客戶端,CPU 空閑
- 按照上面的流程進(jìn)行請(qǐng)求3和請(qǐng)求4的處理
在多線程的模型下,由于 CPU 不用等待 IO 讀取完成,其核心得到了充分的利用,在這個(gè)模型下,處理完兩個(gè)請(qǐng)求所耗時(shí)為 4ms,平均處理一個(gè)請(qǐng)求所耗時(shí)為 2ms,QPS 為 500,并發(fā)能力比單線程模型好多了。
多線程模型的問(wèn)題
多線程模型最大的問(wèn)題,莫過(guò)于線程上下文切換所帶來(lái)的額外開銷了。上面展示的情況,是沒有上下文切換下的理想情況,但在真實(shí)的環(huán)境下,兩個(gè)線程間進(jìn)行切換,必定會(huì)產(chǎn)生開銷的,而且還不小。因此上面的處理情況可能是這樣:

因此,由于頻繁上下文切換造成的開銷,上面的多線程模型的并發(fā)能力比理想情況下要弱。
單線程異步 IO 的情況
在多線程模型中,通過(guò)使用線程切換,避免了 CPU 因等待 IO 操作的閑置,最大程度上利用了 CPU 資源,但是線程間的頻繁上下文切換也會(huì)產(chǎn)生很大的開銷,同樣會(huì)增加服務(wù)器的壓力。因此,要想避免線程上下文切換的帶來(lái)的開銷,就只有使用單線程。
在使用單線程的情況下,能否實(shí)現(xiàn)非阻塞的 IO 呢?

上面就是一個(gè)單線程非阻塞 IO 的模型,處理請(qǐng)求時(shí)的流程如下:
- 收到請(qǐng)求1,開始處理請(qǐng)求
- 進(jìn)行請(qǐng)求1的 IO 讀取,并注冊(cè)一個(gè)回調(diào)函數(shù)(處理數(shù)據(jù)并響應(yīng)客戶端),同時(shí)線程不阻塞,繼續(xù)處理請(qǐng)求2
- 進(jìn)行請(qǐng)求2的 IO 讀取,并注冊(cè)一個(gè)回調(diào)函數(shù)(處理數(shù)據(jù)并響應(yīng)客戶端),同時(shí)線程不阻塞,繼續(xù)處理剩下的請(qǐng)求
- 請(qǐng)求處理結(jié)束后,依次執(zhí)行 IO 讀取是注冊(cè)的回調(diào)函數(shù)(處理數(shù)據(jù)并響應(yīng)客戶端),完成處理
假設(shè)服務(wù)器只接受到兩個(gè)請(qǐng)求:請(qǐng)求1和請(qǐng)求2,按照上面的流程圖,處理這兩個(gè)請(qǐng)求的時(shí)間為 4ms,平均每個(gè)請(qǐng)求用時(shí) 2ms,此時(shí)服務(wù)器的 QPS 為 500。由于是單線程運(yùn)行,沒有頻繁上下文切換帶來(lái)的開銷,因此這個(gè)單線程異步 IO 的模型比多線程模型占用的資源更少,對(duì)服務(wù)器配置要求更低。同時(shí),從流程圖也可以看到,這種架構(gòu)具備很大的吞吐能力,十分適合 IO 密集型的應(yīng)用。
總結(jié)
本文對(duì)幾種常見的任務(wù)處理模型:?jiǎn)尉€程模型、多線程模型、單線程非阻塞 IO 模型進(jìn)行了對(duì)比,依據(jù)是應(yīng)用這些模型時(shí)的服務(wù)器 QPS 值,得出的結(jié)論是單線程非阻塞 IO 模型具備較強(qiáng)的并發(fā)處理能力,且占用更少的資源。
Node 的 Event Loop 基于這種單線程非阻塞 IO 模型,因此具備強(qiáng)大的并發(fā)能力,適合 IO 密集型的應(yīng)用(如游戲、電商秒殺活動(dòng)等)。
附:參考資料
Node.js為什么快
JavaScript 運(yùn)行機(jī)制詳解:再談Event Loop
【樸靈評(píng)注】JavaScript 運(yùn)行機(jī)制詳解:再談Event Loop
理解Node.js的event loop
完。