- 新時期的Node.js入門
- 李鍇
- 3497字
- 2019-12-12 17:05:39
1.2 Node的內部機制
本節的內容會涉及一些操作系統的概念,在開始之前,這里有一些前提,記住這些前提會能讓你更好地理解本節的內容:
- 在任務完成之前,CPU在任何情況下都不會暫停或者停止執行,CPU如何執行和同步或是異步、阻塞或者非阻塞都沒有必然關系。
- 操作系統始終保證CPU處在運行狀態,這是通過系統調度來實現的,具體一點就是通過在不同進程/線程間切換實現的。
1.2.1 何為回調
1.回調的定義
一個回調是指通過函數參數的參數傳遞到其他代碼的,某段可執行代碼的引用。
說得通俗一點,就是將一個函數作為參數傳遞給另一個函數,并且作為參數的函數可以被執行,其本質上是一個高階函數。在數學和計算機科學中,高階函數是至少滿足下列一個條件的函數:
- 接受一個或多個函數作為輸入。
- 輸出一個函數。
JavaScript中一個很常見的例子就是map方法,該方法接受一個函數作為參數,依次作用于的數組的每一個元素。

可以用如圖1-1所示來描述回調的過程。

圖1-1
回調方法和主線程處于同一層級,假設主線程發起了一個底層的系統調用,那么操作系統轉而去執行這個系統調用,當調用結束后,又回到主線程上調用其后的方法,這也是為什么其會被稱為回調(call then back)。
關于回調函數在何時執行并沒有具體的要求,回調函數的調用既可以是同步的(例如map方法),也可以是異步的(例如setTimeout方法中的匿名函數)。
2.異步過程中的回調
單線程運行的語言在設計時要考慮這樣的問題:如果遇到一個耗時的操作,例如磁盤IO,要不要等待操作完成了再執行下一步操作?
有的語言選擇了在完成之前繼續等待,例如PHP。
Node選擇另一種方式,當遇到IO操作時,Node代碼在發起一個調用后繼續向下執行,IO操作完成后,再執行對應的回調函數(異步),雖然代碼運行在單線程環境下,但依靠異步+回調的方式,也能實現對高并發的支持。
代碼1.1 回調函數示意

代碼1.1中的callback方法即為一個簡單的回調方法。readFile發起一個系統調用,隨后執行結束,當系統調用完成后,再通過回調函數獲得文件的內容。
1.2.2 同步/異步和阻塞/非阻塞
1.同步與異步
同步和異步描述的是進程/線程的調用方式。
同步調用指的是進程/線程發起調用后,一直等待調用返回后才繼續執行下一步操作,這并不代表CPU在這段時間內也會一直等待,操作系統多半會切換到另一個進程/線程上去,等到調用返回后再切換回原來的進程/線程。
異步就相反,發起調用后,進程/線程繼續向下執行,當調用返回后,通過某種手段來通知調用者。
注意:同步和異步中的“調用返回”,是指內核進程將數據復制到調用進程(Linux環境下)。
我們常常說JavaScript是一門異步的語言,但ECMAScript里并沒有關于異步的規范,JavaScript的異步更多是依靠瀏覽器runtime內部其他線程來實現,并非JavaScript本身的功能,是瀏覽器提供的支持讓JavaScript看起來像是一個異步的語言。
2.阻塞與非阻塞
阻塞與非阻塞的概念是針對IO狀態而言的,關注程序在等待IO調用返回這段時間的狀態。
關于Node中的IO,這里依然借用官網的說法:

需要注意的是,Node也沒有使用asynchronous(異步)之類的詞匯,而是使用了non-blocking(非阻塞)這樣的描述。
阻塞/非阻塞和同步/異步完全是兩組概念,它們之間沒有任何的必然聯系。很多人認為,阻塞=同步,非阻塞=異步,這種觀念是不正確的。我們下面介紹的IO編程模型中,除了純粹的AIO之外,阻塞和非阻塞IO都是同步的。
在介紹IO編程模型之前,先回答兩個問題。
(1)什么是IO操作
輸入/輸出(I/O)是在內存和外部設備(如磁盤、終端和網絡)之間復制數據的過程。
在實踐中IO操作幾乎無處不在,因為大多數程序都要產生輸出結果才有意義(往往是輸出到磁盤或者屏幕),除非你只在內存中計算一個斐波那契數列而且不采取其他任何操作。
在Node中,IO特指Node程序在Libuv支持下與系統磁盤和網絡交互的過程。
(2)IO調用的結果怎么返回給調用的進程/線程
通過內核進程復制給調用進程,在Linux下,用戶進程沒辦法直接訪問內核空間,通常是內核調用copy_to_user方法來傳遞數據的,大致的流程就是IO的數據會先被內核空間讀取,然后內核將數據復制給用戶進程。還有一種零復制技術,大致是內核進程和用戶進程共享一塊內存地址,這避免了內存的復制,讀者可自行搜索相關內容。
3.IO編程模型
編程模型是指操作系統在處理IO時所采用的方式,這通常是為了解決IO速度比較慢的問題而誕生的。
一般來說,編程模型有以下幾種:
- blocking I/O
- non-blocking I/O
- I/O multiplexing(select and poll)
- signal driven I/O(SIGIO)
- asynchronous I/O(the POSIX aio_functions)
上面的5種模型中,signal driven I/O模型不常用,我們主要討論其他4種,它們均特指Linux下的IO模型。
(1)阻塞IO(blocking I/O)
對于IO來說,通常可以分為兩個階段,準備數據和返回結果,阻塞型IO在進程發出一個系統調用請求之后,進程就一直等待上述兩個階段完成,等待拿到返回結果之后再重新運行。
(2)非阻塞IO(nonblocking I/O)
和上面的過程相似,不同之處是當進程發起一個調用后,如果數據還沒有就緒,就會馬上返回一個結果告訴進程現在還沒有就緒,和阻塞IO的區別是用戶進程會不斷查詢內核狀態。這個過程依舊是同步的。
(3)IO multiplexing/Event Driven
這種IO通常也被稱為事件驅動IO,同樣是以輪詢的方式來查詢內核的執行狀態,和非阻塞IO的區別是一個進程可能會管理多個IO請求,當某個IO調用有了結果之后,就返回對應的結果。
注意:select和poll都是IO復用的機制,另外Node使用epoll(改進后的poll),這里不再詳細介紹。
(4)Asynchronous I/O
異步IO的概念讀者應該很熟悉了,和前面的模型相比,當進程發出調用后,內核會立刻返回結果,進程會繼續做其他的事情,直到操作系統返回數據,給用戶進程發送一個信號。注意,異步IO并沒有涉及任何關于回調函數的概念,此外,這里的異步IO只存在于Linux系統下。
讀者可能會感到好奇,那么既然如此,為什么在官網上Node沒有標榜自己是異步IO,而是寫成非阻塞IO呢?
很簡單,因為非阻塞是實打實的,而Node中的“異步I/O”是依靠Libuv模擬出來的。我們會在下一節介紹。
用一句話來概括阻塞/非阻塞和同步/異步:
同步調用會造成調用進程的IO阻塞,異步調用不會造成調用進程的IO阻塞(引用自《Unix網絡編程》第三版6.2)。
1.2.3 單線程和多線程
其他語言(例如Java、C++等)都有多線程的語言特性,即開發者可以派生出多個線程來協同工作,在這種情況下,用戶的代碼是運行在多線程環境下的。
Node并沒有提供多線程的支持,這代表用戶編寫的代碼只能運行在當前線程中,用于運行代碼的事件循環也是單線程運行的。開發者無法在一個獨立進程中增加新的線程,但是可以派生出多個進程來達到并行完成工作的目的。
另一方面,Node的底層實現并非是單線程的,libuv會通過類似線程池的實現來模擬不同操作系統下的異步調用,這對開發者來說是不可見的。
Libuv中的多線程
開發者編寫的代碼運行在單線程環境中,這句話是沒錯的,但如果說整個Node都是依靠單線程運行的,那就不正確了,因為libuv中是有線程池的概念存在的。
Libuv是一個跨平臺的異步IO庫,它結合了UNIX下的libev和Windows下的IOCP的特性,最早由Node的作者開發,專門為Node提供多平臺下的異步IO支持。libuv本身是由C/C++語言實現的,Node中的非阻塞IO以及事件循環的底層機制,都是由libuv來實現的。
圖1-2講述了libuv的架構。

圖1-2
在Windows環境下,libuv直接使用Windows的IOCP(I/O Completion Port)來實現異步IO。在非Windows環境下,libuv使用多線程來模擬異步IO。
Node的異步調用是由libuv來支持的,以readFile為例,讀取文件的系統調用是由libuv來完成的,Node只負責調用libuv的接口,等數據返回后再執行對應的回調方法。
1.2.4 并行和并發
并行(Parallel)與并發(Concurrent)是兩個很常見的概念,兩者雖然中文譯名相似,但實質上差別很大。
在介紹下面的內容之前,有必要對這兩概念進行解釋,下面是一個簡單的比喻:
我們假設業務場景是排隊取火車票。
并發是假設有兩對人排隊,但只有一個取票機,為了公平起見,先由隊列一排頭的人上前取票,再由隊列二的一個人上前取票,兩個隊列都在向前移動。
并行同樣是兩隊人排隊取票,不同的是開放了兩個取票機,那么兩個隊列可以同時向前移動,速度是一個窗口的兩倍以上(避免了一個窗口在兩個隊列間切換)。
并發和并行對應了兩種需求,一個是希望計算機做更多的事(處理多個隊列),另一個是希望計算機能更快地完成任務(讓隊列以更快的速度向前移動)。
如圖1-3所示給出了并發和并行,以及順序程序(即串行程序)之間的關系與區別(該圖引用自《深入理解計算機系統》12.6的圖12-30)。

圖1-3
Node中的并發
單線程支持高并發,通常都是依靠異步+事件驅動(循環)來實現的,異步使得代碼在面臨多個請求時不會發生阻塞,事件循環提供了IO調用結束后調用回調函數的能力。
Java可以依靠多線程實現并發,Node本身不支持開發者書寫多線程的代碼,事件循環也是單線程運行的,但是通過異步和事件驅動能夠很好地實現并發。
有一句話非常出名:“除了你的代碼,一切都是并行的”,網絡上很多文章都會提到這句話。但稍微思考一下,就會發現這句話里的“并行”值得深究。我們稍后會繼續介紹,在那之前,先來看事件循環相關的內容。