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