nodejs筆記-異步I/O

1.爲何要使用異步I/O

1.1 用戶體驗

瀏覽器中的Javascripts是在單線程上執行的,而且和UI渲染公用一個線程。這就意味着在執行Javascript時候UI的渲染和響應是出於停滯的狀態,若是腳本執行時間超過100ms用戶就能感覺到頁面卡頓。在B/S模型中若是經過同步方式獲取服務器資源Javascript須要等待資源的返回,這段時間UI將會停頓不響應交互。而採用異步方式請求資源的同時Javascript和UI渲染能夠繼續執行。前端

經過異步執行能夠消除UI阻塞現象,可是獲取資源速度取決於服務器的響應,假設有這麼個場景,獲取兩個資源數據:node

get('json_a');//須要消耗時間M
get('json_b');//須要消耗時間N
複製代碼

若是採用同步方式獲取資源的時間爲M+N,若是採用異步方式時間則是max(M,N)。隨着網站的擴大,數據將會分佈在不一樣服務器上,分佈式也將意味着M與N的值會線性增加。同步與異步的耗時差距也會變大。web

1.2 資源的分配

假設一組互不先關的任務須要執行,主流方法有兩種:編程

  • 單線程串行依次執行
  • 多線程並行完成

若是建立多線程的開銷小於並行執行,那麼多線程的方式是首選。多線程的代價在於建立線程和執行線程時的上下文切換。在複雜業務中多線程須要面臨鎖、狀態同步問題。優點在於多線程在多核CPU上能夠提高CPU利用率。json

單線程串行執行缺點在於性能,任意一個任務略慢都會影響下一個執行。一般I/O與CPU計算之間是能夠並行進行的,可是同步編程致使I/O的進行會讓後續任務等待,形成資源浪費。windows

Node在二者之間作出了本身的方案:利用單線程,遠離多線程死鎖、狀態同步問題;利用異步I/O,讓單線程遠離阻塞,更好的利用CPU。數組

爲了彌補單線程沒法利用多核CPU缺點,Node提供了相似前端瀏覽器的Web Workers的子進程,子進程能夠經過工做進程高效的利用CPU和I/O。瀏覽器

異步I/O調用示意圖
[異步I/O調用示意圖]

2.異步I/O實現

2.1異步I/O與非阻塞I/O

操做系統內核對於I/O只有兩種方式:阻塞和非阻塞。調用阻塞I/O時,程序須要等待I/O完成才返回結果,如圖:bash

調用阻塞I/O的過程

爲了提升性能,內核提供了非阻塞I/O,非阻塞I/O調用以後會馬上返回,如圖:服務器

調用非阻塞I/O的過程

非阻塞I/O返回後,完整的I/O並無完成,當即返回的不是業務層指望的數據,僅僅是當前調用狀態。爲了獲取完整的數據,應用須要反覆調用I/O操做來確認是否完成。這種反覆調用判斷操做是否完成的計算叫作 輪詢

現存的輪詢技術主要有這些:

  1. read
    最原始的一種方式,經過反覆調用I/O狀態來完成數據讀取,在獲取最終數據前,CPU一直耗用在等待是,示意圖:

經過read進行輪詢的示意圖
2. select 在read基礎上的改進方案,經過文件描述符上的事件狀態來進行判斷,select輪詢有一個限制,它採用一個1024長度的數組來保存儲存狀態,因此它最多能夠檢查1024個文件描述符,示意圖:

經過select進行輪詢示意圖
3. poll 採用鏈表的方式來避免數組長度限制,能避免不須要的檢查。當文件描述符多時,性能仍是十分低下,於select類似,性能有所改善,如圖:

經過poll進行輪詢示意圖
4. epoll Linux下效率最高的I/O事件通知機制,進入輪詢時若是沒有檢查到I/O事件,將會進行休眠,直到事件將他喚醒。利用的事件通知、執行回調方式,而不是遍歷查詢,因此不會浪費CPU,執行效率比較高。示意圖:

經過epoll進行輪詢示意圖
5. kqueue 與epoll相似,只存在FreeBSD系統下。

2.2 理想的非阻塞異步I/O

指望的完美異步I/O應該是程序發起非阻塞調用,無需經過遍歷或者事件喚醒等輪詢方式,能夠進行下一個任務,只須要在I/O完成後經過信號或回調將數據傳遞給應用程序,示意圖:

理想異步I/O示意圖

2.3現實的異步I/O

經過讓部分線程進行阻塞I/O或者非阻塞I/O加輪詢技術完成數據獲取。讓一個線程進行處理計算,經過線程之間的通信將I/O獲得的數據進行傳遞,實現異步I/O,示意圖:

異步I/O

最初Node在*nix平臺下采用libeio配合libev實現I/O異步I/O,Node v0.9.3中,自行實現了線程池完成異步I/O。
windows下經過IOCP來實現(實現原理仍然是線程池,只是由系統內核接受管理)。

windows和*nix平臺的差別,Node提供了libuv做爲封裝,兼容性判斷由這一層完成,Node編譯期間會判斷平臺條件。

3.Node的異步I/O

3.1事件循環

啓動Node時會建立一個相似while(true)的循環,每執行一次循環過程稱之爲Tick。每一個Tick的過程就是檢查是否有待處理事件,若是有,就讀取出事件及其相關的回調函數,若是存在關聯的回調函數,就執行。而後加入下一個循環,若是再也不有事件處理就退出進程。如圖:

Tick流程圖

3.2觀察者

在每一個Tick過程當中,怎麼判斷是否有事件須要處理呢?,這裏引入了觀察者概念。
每一個事件循環中有一個或多個觀察者,而判斷是否有事件要處理的過程就是向觀察者詢問是否須要處理事件。

3.3請求對象

Javascript發起調用到內核執行完I/O操做的過程當中,存在一種中間產物,叫作請求對象。
以fs.open()爲例:

fs.open = function(path, flags, mode, callback) {
    //...
    binding.open(pathModule._makeLong(path), stringToFlags(flags), mode, callback);
}
複製代碼

fs.open()是根據指定路徑和參數打開一個文件,從而獲取一個文件描述符,這是後續全部I/O操做的初始操做。
Javascript層面的代碼調用C++核心模塊進行下層操做。示意圖:

調用示意圖
實際上調用了uv_fs_open()方法。在調用過程當中建立了一個FSReqWrap請求對象。從Javascriptc層傳入的參數和當前方法都封裝在這個請求對象中,回調函數則被設置在對象的oncomplete_sym屬性上:

req_wrap->object_->Set(oncomplete_sym, callback);
複製代碼

對象包裝完畢,將FSReqWrap對象推入線程池中等待執行。此時Javascript調用當即返回,Javascript線程可繼續執行當前任務的後續操做,當前的I/O操做在線程池中等待執行,不論是否是阻塞I/O,的不會影響Javascript線程的後續執行。
請求對象是異步I/O過程的重要中間產物,全部狀態都保存在這個對象中,包括送入線程池執行以及I/O操做完畢後的回調處理。

3.4執行回調

線程池中的I/O操做調用完畢後,將獲取結果儲存在req->result屬性上,而後通知IOCP(windows下)告知操做已完成,並歸還線程到線程池。
在每次Tick的執行中,它會檢查線程池中是否有執行完的請求,若是存在,將請求對象加入I/O觀察者列隊中,而後將其當作事件處理。
I/O觀察者回調函數的行爲就是取出請求對象的result屬性做爲參數而後執行回調,調用Javascript中傳入的回調函數,至此,這個I/O流程徹底接受,示意圖:

整個異步I/O流程

在Node中除了Javascript是單線程外,Node自身其餘都是多線程的,除了用戶代碼沒法並行執行,全部I/O則是能夠並行起來的。

4.非I/O的異步API

無關I/O的異步API

  • setTimeout()
  • setInterval()
  • setImmediate()
  • process.nextTick()

4.1定時器

setTimeout()與setInterval()與瀏覽器API一致,分別用於單次和屢次定時執行任務。調用它們時建立的定時器會被插入到定時器觀察者內部的一個紅黑樹中,每次Tick執行會到該紅黑樹中迭代取出定時器對象,檢測是否超時,若是超時則造成一個事件,它的回調函數當即執行。 定時器並不是精確,雖然循環很是快可是若是某一次計算佔用循環事件特別多,那麼下次循環,它可能已經超時好久了。
setTimeout()的行爲圖:

setTimeout()的行爲

4.2 process.nextTick()

若是須要一個當即異步執行的任務,能夠這樣調用:

setTimeout(() =>{
    //todo
}, 0);

process.nextTick(() => {
    //todo
})
複製代碼

因爲定時器須要調用紅黑樹全部比較浪費性能。process.nextTick()方法比較輕量。每次調用process.nextTick()方法,只會把回調函數放入隊列中,在下一輪Tick時取出當即執行。全部process.nextTick()更爲高效。

4.3 setImmediate()

setImmediate()與process.nextTick()類似,都是將回調函數延遲執行,process.nextTick()執行回調優先級高於setImmediate()。

process.nextTick(() => {
    console.log('process.nextTick');
})
setImmediate(() => {
    console.log('setImmediate');
})
console.log('正常執行')
//執行結果
正常執行
process.nextTick
setImmediate
複製代碼

這是由於事件循環對觀察者的檢查是有前後順序的,process.nextTick()屬於idle觀察者,setImmediate()屬於check觀察者。
process.nextTick()的回調函數保存在一個數組中,setImmediate()的結果則是保存在鏈表中。process.nextTick()在每次循環中會將數組的回調函數所有執行完畢,而setImmediate()每輪循環中執行鏈表中的一個回調函數 (當前運行node版本是windows8.7.0,新版的setImmediate處理回調函數已經改變,在一輪循環中setImmediate中的回調函數被所有執行)
列如:

process.nextTick(() => {
    console.log('nextTick執行1');
})
process.nextTick(() => {
    console.log('nextTick執行2');
})
setImmediate(() => {
    console.log('setImmediate執行1');
    process.nextTick(() => {
    	console.log('插入執行');
    })
})
setImmediate(() => {
    console.log('setImmediate執行2');
})
console.log('正常執行')
//執行結果
正常執行
nextTick執行1
nextTick執行2
setImmediate執行1
setImmediate執行2
插入執行
複製代碼

5.事件驅動與高性能服務器

利用Node構建web服務器流程圖:

利用Node構建web服務器流程圖

服務器模型的經典模型:

  • 同步式
    一次只能處理一個請求,其他請求出於等待狀態
  • 每進程/每請求
    爲每一個請求建立一個進程,能夠同時處理多個請求,不具有高擴展性,系統資源有限。
  • 每線程/每請求 爲每一個請求啓動一個新線程。比啓動新進程輕量,可是高併發的時候內存將很快耗光。(Apache採用這種模式),線程多了後上下文切換頻繁消耗資源。

Node採用事件驅動方式無需爲每一個請求建立新線程,能夠省掉不少開銷(Nginx採用與Node相同的事件驅動),即便在大併發的狀況下也不受上下文切換開銷的影響。

相關文章
相關標籤/搜索