專欄的第五篇文章《Node.js的異步實(shí)現(xiàn)》。之前介紹了Node.js的事件機(jī)制,也許讀者對(duì)此尚會(huì)覺得意猶未盡,因?yàn)閮H僅只是簡(jiǎn)單的事件機(jī)制,并不能道盡Node.js的神奇。如果Node.js是一盤別開生面的磁帶,那么事件與異步分別是其A面和B面,它們共同組成了Node.js的別樣之處。本文將翻轉(zhuǎn)Node.js到B面,與你共同聆聽。
在操作系統(tǒng)中,程序運(yùn)行的空間分為內(nèi)核空間和用戶空間。我們常常提起的異步I/O,其實(shí)質(zhì)是用戶空間中的程序不用依賴內(nèi)核空間中的I/O操作實(shí)際完成,即可進(jìn)行后續(xù)任務(wù)。以下偽代碼模仿了一個(gè)從磁盤上獲取文件和一個(gè)從網(wǎng)絡(luò)中獲取文件的操作。異步I/O的效果就是getFileFromNet的調(diào)用不依賴于getFile調(diào)用的結(jié)束。
getFile("file_path");
getFileFromNet("url");
如果以上兩個(gè)任務(wù)的時(shí)間分別為m和n。采用同步方式的程序要完成這兩個(gè)任務(wù)的時(shí)間總花銷會(huì)是m + n。但是如果是采用異步方式的程序,在兩種I/O可以并行的狀況下(比如網(wǎng)絡(luò)I/O與文件I/O),時(shí)間開銷將會(huì)減小為max(m, n)。
有的語言為了設(shè)計(jì)得使應(yīng)用程序調(diào)用方便,將程序設(shè)計(jì)為同步I/O的模型。這意味著程序中的后續(xù)任務(wù)都需要等待I/O的完成。在等待I/O完成的過程中,程序無法充分利用CPU。為了充分利用CPU,和使I/O可以并行,目前有兩種方式可以達(dá)到目的:
前者在性能優(yōu)化上還有回旋的余地,后者的做法純粹是一種加三倍服務(wù)器的行為。?
而且現(xiàn)在的大型Web應(yīng)用中,單機(jī)的情形是十分稀少的,一個(gè)事務(wù)往往需要跨越網(wǎng)絡(luò)幾次才能完成最終處理。如果網(wǎng)絡(luò)速度不夠理想,m和n值都將會(huì)變大,這時(shí)同步I/O的語言模型將會(huì)露出其最脆弱的狀態(tài)。?
這種場(chǎng)景下的異步I/O將會(huì)體現(xiàn)其優(yōu)勢(shì),max(m, n)的時(shí)間開銷可以有效地緩解m和n值增長帶來的性能問題。而當(dāng)并行任務(wù)更多的時(shí)候,m + n + …與max(m, n, …)之間的孰優(yōu)孰劣更是一目了然。從這個(gè)公式中,可以了解到異步I/O在分布式環(huán)境中是多么重要,而Node.js天然地支持這種異步I/O,這是眾多云計(jì)算廠商對(duì)其青睞的根本原因。
我們聽到Node.js時(shí),我們常常會(huì)聽到異步,非阻塞,回調(diào),事件這些詞語混合在一起。其中,異步與非阻塞聽起來似乎是同一回事。從實(shí)際效果的角度說,異步和非阻塞都達(dá)到了我們并行I/O的目的。但是從計(jì)算機(jī)內(nèi)核I/O而言,異步/同步和阻塞/非阻塞實(shí)際上時(shí)兩回事。
當(dāng)進(jìn)行非阻塞I/O調(diào)用時(shí),要讀到完整的數(shù)據(jù),應(yīng)用程序需要進(jìn)行多次輪詢,才能確保讀取數(shù)據(jù)完成,以進(jìn)行下一步的操作。
輪詢技術(shù)的缺點(diǎn)在于應(yīng)用程序要主動(dòng)調(diào)用,會(huì)造成占用較多CPU時(shí)間片,性能較為低下?,F(xiàn)存的輪詢技術(shù)有以下這些:
read是性能最低的一種,它通過重復(fù)調(diào)用來檢查I/O的狀態(tài)來完成完整數(shù)據(jù)讀取。select是一種改進(jìn)方案,通過對(duì)文件描述符上的事件狀態(tài)來進(jìn)行判斷。操作系統(tǒng)還提供了poll、epoll等多路復(fù)用技術(shù)來提高性能。
輪詢技術(shù)滿足了異步I/O確保獲取完整數(shù)據(jù)的保證。但是對(duì)于應(yīng)用程序而言,它仍然只能算時(shí)一種同步,因?yàn)閼?yīng)用程序仍然需要主動(dòng)去判斷I/O的狀態(tài),依舊花費(fèi)了很多CPU時(shí)間來等待。
上一種方法重復(fù)調(diào)用read進(jìn)行輪詢直到最終成功,用戶程序會(huì)占用較多CPU,性能較為低下。而實(shí)際上操作系統(tǒng)提供了select方法來代替這種重復(fù)read輪詢進(jìn)行狀態(tài)判斷。select內(nèi)部通過檢查文件描述符上的事件狀態(tài)來進(jìn)行判斷數(shù)據(jù)是否完全讀取。但是對(duì)于應(yīng)用程序而言它仍然只能算是一種同步,因?yàn)閼?yīng)用程序仍然需要主動(dòng)去判斷I/O的狀態(tài),依舊花費(fèi)了很多CPU時(shí)間等待,select也是一種輪詢。
理想的異步I/O應(yīng)該是應(yīng)用程序發(fā)起異步調(diào)用,而不需要進(jìn)行輪詢,進(jìn)而處理下一個(gè)任務(wù),只需在I/O完成后通過信號(hào)或是回調(diào)將數(shù)據(jù)傳遞給應(yīng)用程序即可。
幸運(yùn)的是,在Linux下存在一種這種方式,它原生提供了一種異步非阻塞I/O方式(AIO)即是通過信號(hào)或回調(diào)來傳遞數(shù)據(jù)的。
不幸的是,只有Linux下有這么一種支持,而且還有缺陷(AIO僅支持內(nèi)核I/O中的O_DIRECT方式讀取,導(dǎo)致無法利用系統(tǒng)緩存。參見:http://forum.nginx.org/read.php?2,113524,113587#msg-113587
以上都是基于非阻塞I/O進(jìn)行的設(shè)定。另一種理想的異步I/O是采用阻塞I/O,但加入多線程,將I/O操作分到多個(gè)線程上,利用線程之間的通信來模擬異步。Glibc的AIO便是這樣的典型http://www.ibm.com/developerworks/linux/library/l-async/。然而遺憾在于,它存在一些難以忍受的缺陷和bug??梢院?jiǎn)單的概述為:Linux平臺(tái)下沒有完美的異步I/O支持。
所幸的是,libev的作者M(jìn)arc Alexander Lehmann重新實(shí)現(xiàn)了一個(gè)異步I/O的庫:libeio。libeio實(shí)質(zhì)依然是采用線程池與阻塞I/O模擬出來的異步I/O。
那么在Windows平臺(tái)下的狀況如何呢?而實(shí)際上,Windows有一種獨(dú)有的內(nèi)核異步IO方案:IOCP。IOCP的思路是真正的異步I/O方案,調(diào)用異步方法,然后等待I/O完成通知。IOCP內(nèi)部依舊是通過線程實(shí)現(xiàn),不同在于這些線程由系統(tǒng)內(nèi)核接手管理。IOCP的異步模型與Node.js的異步調(diào)用模型已經(jīng)十分近似。
以上兩種方案則正是Node.js選擇的異步I/O方案。由于Windows平臺(tái)和*nix平臺(tái)的差異,Node.js提供了libuv來作為抽象封裝層,使得所有平臺(tái)兼容性的判斷都由這一層次來完成,保證上層的Node.js與下層的libeio/libev及IOCP之間各自獨(dú)立。Node.js在編譯期間會(huì)判斷平臺(tái)條件,選擇性編譯unix目錄或是win目錄下的源文件到目標(biāo)程序中。
在JavaScript層面上調(diào)用的fs.open方法最終都透過node_file.cc調(diào)用到了libuv中的uv_fs_open方法,這里libuv作為封裝層,分別寫了兩個(gè)平臺(tái)下的代碼實(shí)現(xiàn),編譯之后,只會(huì)存在一種實(shí)現(xiàn)被調(diào)用。
在uv_fs_open的調(diào)用過程中,Node.js創(chuàng)建了一個(gè)FSReqWrap請(qǐng)求對(duì)象。從JavaScript傳入的參數(shù)和當(dāng)前方法都被封裝在這個(gè)請(qǐng)求對(duì)象中,其中回調(diào)函數(shù)則被設(shè)置在這個(gè)對(duì)象的oncomplete_sym屬性上。
req_wrap->object_->Set(oncomplete_sym, callback);
對(duì)象包裝完畢后,調(diào)用QueueUserWorkItem方法將這個(gè)FSReqWrap對(duì)象推入線程池中等待執(zhí)行。
QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTELONGFUNCTION)
QueueUserWorkItem接受三個(gè)參數(shù),第一個(gè)是要執(zhí)行的方法,第二個(gè)是方法的上下文,第三個(gè)是執(zhí)行的標(biāo)志。當(dāng)線程池中有可用線程的時(shí)候調(diào)用uv_fs_thread_proc方法執(zhí)行。該方法會(huì)根據(jù)傳入的類型調(diào)用相應(yīng)的底層函數(shù),以u(píng)v_fs_open為例,實(shí)際會(huì)調(diào)用到fs__open方法。調(diào)用完畢之后,會(huì)將獲取的結(jié)果設(shè)置在req->result上。然后調(diào)用PostQueuedCompletionStatus通知我們的IOCP對(duì)象操作已經(jīng)完成。
PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped))
PostQueuedCompletionStatus方法的作用是向創(chuàng)建的IOCP上相關(guān)的線程通信,線程根據(jù)執(zhí)行狀況和傳入的參數(shù)判定退出。
至此,由JavaScript層面發(fā)起的異步調(diào)用第一階段就此結(jié)束。
在調(diào)用uv_fs_open方法的過程中實(shí)際上應(yīng)用到了事件循環(huán)。以在Windows平臺(tái)下的實(shí)現(xiàn)中,啟動(dòng)Node.js時(shí),便創(chuàng)建了一個(gè)基于IOCP的事件循環(huán)loop,并一直處于執(zhí)行狀態(tài)。
uv_run(uv_default_loop());
每次循環(huán)中,它會(huì)調(diào)用IOCP相關(guān)的GetQueuedCompletionStatus方法檢查是否線程池中有執(zhí)行完的請(qǐng)求,如果存在,poll操作會(huì)將請(qǐng)求對(duì)象加入到loop的pending_reqs_tail屬性上。 另一邊這個(gè)循環(huán)也會(huì)不斷檢查loop對(duì)象上的pending_reqs_tail引用,如果有可用的請(qǐng)求對(duì)象,就取出請(qǐng)求對(duì)象的result屬性作為結(jié)果傳遞給oncomplete_sym執(zhí)行,以此達(dá)到調(diào)用JavaScript中傳入的回調(diào)函數(shù)的目的。 至此,整個(gè)異步I/O的流程完成結(jié)束。其流程如下:
更多建議: