nodejs筆記-非同步I/O

mai-kuraki發表於2018-02-03

1.為什麼要使用非同步I/O

1.1 使用者體驗

瀏覽器中的Javascripts是在單執行緒上執行的,並且和UI渲染公用一個執行緒。這就意味著在執行Javascript時候UI的渲染和響應是出於停滯的狀態,如果指令碼執行時間超過100ms使用者就能感受到頁面卡頓。在B/S模型中如果通過同步方式獲取伺服器資源Javascript需要等待資源的返回,這段時間UI將會停頓不響應互動。而採用非同步方式請求資源的同時Javascript和UI渲染可以繼續執行。

通過非同步執行可以消除UI阻塞現象,但是獲取資源速度取決於伺服器的響應,假設有這麼個場景,獲取兩個資源資料:

get('json_a');//需要消耗時間M
get('json_b');//需要消耗時間N
複製程式碼

如果採用同步方式獲取資源的時間為M+N,如果採用非同步方式時間則是max(M,N)。隨著網站的擴大,資料將會分佈在不同伺服器上,分散式也將意味著M與N的值會線性增長。同步與非同步的耗時差距也會變大。

1.2 資源的分配

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

  • 單執行緒序列依次執行
  • 多執行緒並行完成

如果建立多執行緒的開銷小於並行執行,那麼多執行緒的方式是首選。多執行緒的代價在於建立執行緒和執行執行緒時的上下文切換。在複雜業務中多執行緒需要面臨鎖、狀態同步問題。優勢在於多執行緒在多核CPU上可以提升CPU利用率。

單執行緒序列執行缺點在於效能,任意一個任務略慢都會影響下一個執行。通常I/O與CPU計算之間是可以並行進行的,但是同步程式設計導致I/O的進行會讓後續任務等待,造成資源浪費。

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完成才返回結果,如圖:

呼叫阻塞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相同的事件驅動),即使在大併發的情況下也不受上下文切換開銷的影響。

相關文章