nodejs筆記-異步I/O

1.為什么要使用異步I/O

1.1 用戶體驗(yàn)

瀏覽器中的Javascripts是在單線程上執(zhí)行的,并且和UI渲染公用一個(gè)線程。這就意味著在執(zhí)行Javascript時(shí)候UI的渲染和響應(yīng)是出于停滯的狀態(tài),如果腳本執(zhí)行時(shí)間超過(guò)100ms用戶就能感受到頁(yè)面卡頓。在B/S模型中如果通過(guò)同步方式獲取服務(wù)器資源Javascript需要等待資源的返回,這段時(shí)間UI將會(huì)停頓不響應(yīng)交互。而采用異步方式請(qǐng)求資源的同時(shí)Javascript和UI渲染可以繼續(xù)執(zhí)行。

通過(guò)異步執(zhí)行可以消除UI阻塞現(xiàn)象,但是獲取資源速度取決于服務(wù)器的響應(yīng),假設(shè)有這么個(gè)場(chǎng)景,獲取兩個(gè)資源數(shù)據(jù):

get('json_a');//需要消耗時(shí)間M
get('json_b');//需要消耗時(shí)間N

如果采用同步方式獲取資源的時(shí)間為M+N,如果采用異步方式時(shí)間則是max(M,N)。隨著網(wǎng)站的擴(kuò)大,數(shù)據(jù)將會(huì)分布在不同服務(wù)器上,分布式也將意味著M與N的值會(huì)線性增長(zhǎng)。同步與異步的耗時(shí)差距也會(huì)變大。

1.2 資源的分配

假設(shè)一組互不先關(guān)的任務(wù)需要執(zhí)行,主流方法有兩種:

  • 單線程串行依次執(zhí)行
  • 多線程并行完成

如果創(chuàng)建多線程的開(kāi)銷小于并行執(zhí)行,那么多線程的方式是首選。多線程的代價(jià)在于創(chuàng)建線程和執(zhí)行線程時(shí)的上下文切換。在復(fù)雜業(yè)務(wù)中多線程需要面臨鎖、狀態(tài)同步問(wèn)題。優(yōu)勢(shì)在于多線程在多核CPU上可以提升CPU利用率。

單線程串行執(zhí)行缺點(diǎn)在于性能,任意一個(gè)任務(wù)略慢都會(huì)影響下一個(gè)執(zhí)行。通常I/O與CPU計(jì)算之間是可以并行進(jìn)行的,但是同步編程導(dǎo)致I/O的進(jìn)行會(huì)讓后續(xù)任務(wù)等待,造成資源浪費(fèi)。

Node在兩者之間做出了自己的方案:利用單線程,遠(yuǎn)離多線程死鎖、狀態(tài)同步問(wèn)題;利用異步I/O,讓單線程遠(yuǎn)離阻塞,更好的利用CPU。

為了彌補(bǔ)單線程無(wú)法利用多核CPU缺點(diǎn),Node提供了類似前端瀏覽器的Web Workers的子進(jìn)程,子進(jìn)程可以通過(guò)工作進(jìn)程高效的利用CPU和I/O。

異步I/O調(diào)用示意圖

[異步I/O調(diào)用示意圖]

2.異步I/O實(shí)現(xiàn)

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

操作系統(tǒng)內(nèi)核對(duì)于I/O只有兩種方式:阻塞和非阻塞。調(diào)用阻塞I/O時(shí),程序需要等待I/O完成才返回結(jié)果,如圖:

調(diào)用阻塞I/O的過(guò)程

為了提高性能,內(nèi)核提供了非阻塞I/O,非阻塞I/O調(diào)用之后會(huì)立刻返回,如圖:

調(diào)用非阻塞I/O的過(guò)程

非阻塞I/O返回后,完整的I/O并沒(méi)有完成,立即返回的不是業(yè)務(wù)層期望的數(shù)據(jù),僅僅是當(dāng)前調(diào)用狀態(tài)。為了獲取完整的數(shù)據(jù),應(yīng)用需要反復(fù)調(diào)用I/O操作來(lái)確認(rèn)是否完成。這種反復(fù)調(diào)用判斷操作是否完成的計(jì)算叫做 輪詢。

現(xiàn)存的輪詢技術(shù)主要有這些:

  1. read
    最原始的一種方式,通過(guò)反復(fù)調(diào)用I/O狀態(tài)來(lái)完成數(shù)據(jù)讀取,在獲取最終數(shù)據(jù)前,CPU一直耗用在等待是,示意圖:
通過(guò)read進(jìn)行輪詢的示意圖
  1. select
    在read基礎(chǔ)上的改進(jìn)方案,通過(guò)文件描述符上的事件狀態(tài)來(lái)進(jìn)行判斷,select輪詢有一個(gè)限制,它采用一個(gè)1024長(zhǎng)度的數(shù)組來(lái)保存儲(chǔ)存狀態(tài),所以它最多可以檢查1024個(gè)文件描述符,示意圖:
通過(guò)select進(jìn)行輪詢示意圖
  1. poll
    采用鏈表的方式來(lái)避免數(shù)組長(zhǎng)度限制,能避免不需要的檢查。當(dāng)文件描述符多時(shí),性能還是十分低下,于select相似,性能有所改善,如圖:
通過(guò)poll進(jìn)行輪詢示意圖
  1. epoll
    Linux下效率最高的I/O事件通知機(jī)制,進(jìn)入輪詢時(shí)如果沒(méi)有檢查到I/O事件,將會(huì)進(jìn)行休眠,直到事件將他喚醒。利用的事件通知、執(zhí)行回調(diào)方式,而不是遍歷查詢,所以不會(huì)浪費(fèi)CPU,執(zhí)行效率比較高。示意圖:
通過(guò)epoll進(jìn)行輪詢示意圖
  1. kqueue
    與epoll類似,只存在FreeBSD系統(tǒng)下。

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

期望的完美異步I/O應(yīng)該是程序發(fā)起非阻塞調(diào)用,無(wú)需通過(guò)遍歷或者事件喚醒等輪詢方式,可以進(jìn)行下一個(gè)任務(wù),只需要在I/O完成后通過(guò)信號(hào)或回調(diào)將數(shù)據(jù)傳遞給應(yīng)用程序,示意圖:

理想異步I/O示意圖

2.3現(xiàn)實(shí)的異步I/O

通過(guò)讓部分線程進(jìn)行阻塞I/O或者非阻塞I/O加輪詢技術(shù)完成數(shù)據(jù)獲取。讓一個(gè)線程進(jìn)行處理計(jì)算,通過(guò)線程之間的通訊將I/O得到的數(shù)據(jù)進(jìn)行傳遞,實(shí)現(xiàn)異步I/O,示意圖:

異步I/O

最初Node在*nix平臺(tái)下采用libeio配合libev實(shí)現(xiàn)I/O異步I/O,Node v0.9.3中,自行實(shí)現(xiàn)了線程池完成異步I/O。
windows下通過(guò)IOCP來(lái)實(shí)現(xiàn)(實(shí)現(xiàn)原理仍然是線程池,只是由系統(tǒng)內(nèi)核接受管理)。

windows和*nix平臺(tái)的差異,Node提供了libuv作為封裝,兼容性判斷由這一層完成,Node編譯期間會(huì)判斷平臺(tái)條件。

3.Node的異步I/O

3.1事件循環(huán)

啟動(dòng)Node時(shí)會(huì)創(chuàng)建一個(gè)類似while(true)的循環(huán),每執(zhí)行一次循環(huán)過(guò)程稱之為Tick。每個(gè)Tick的過(guò)程就是檢查是否有待處理事件,如果有,就讀取出事件及其相關(guān)的回調(diào)函數(shù),如果存在關(guān)聯(lián)的回調(diào)函數(shù),就執(zhí)行。然后加入下一個(gè)循環(huán),如果不再有事件處理就退出進(jìn)程。如圖:

Tick流程圖

3.2觀察者

在每個(gè)Tick過(guò)程中,怎么判斷是否有事件需要處理呢?,這里引入了觀察者概念。
每個(gè)事件循環(huán)中有一個(gè)或多個(gè)觀察者,而判斷是否有事件要處理的過(guò)程就是向觀察者詢問(wèn)是否需要處理事件。

3.3請(qǐng)求對(duì)象

Javascript發(fā)起調(diào)用到內(nèi)核執(zhí)行完I/O操作的過(guò)程中,存在一種中間產(chǎn)物,叫做請(qǐng)求對(duì)象。
以fs.open()為例:

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

fs.open()是根據(jù)指定路徑和參數(shù)打開(kāi)一個(gè)文件,從而獲取一個(gè)文件描述符,這是后續(xù)所有I/O操作的初始操作。
Javascript層面的代碼調(diào)用C++核心模塊進(jìn)行下層操作。示意圖:

調(diào)用示意圖

實(shí)際上調(diào)用了uv_fs_open()方法。在調(diào)用過(guò)程中創(chuàng)建了一個(gè)FSReqWrap請(qǐng)求對(duì)象。從Javascriptc層傳入的參數(shù)和當(dāng)前方法都封裝在這個(gè)請(qǐng)求對(duì)象中,回調(diào)函數(shù)則被設(shè)置在對(duì)象的oncomplete_sym屬性上:

req_wrap->object_->Set(oncomplete_sym, callback);

對(duì)象包裝完畢,將FSReqWrap對(duì)象推入線程池中等待執(zhí)行。此時(shí)Javascript調(diào)用立即返回,Javascript線程可繼續(xù)執(zhí)行當(dāng)前任務(wù)的后續(xù)操作,當(dāng)前的I/O操作在線程池中等待執(zhí)行,不管是否是阻塞I/O,的不會(huì)影響Javascript線程的后續(xù)執(zhí)行。
請(qǐng)求對(duì)象是異步I/O過(guò)程的重要中間產(chǎn)物,所有狀態(tài)都保存在這個(gè)對(duì)象中,包括送入線程池執(zhí)行以及I/O操作完畢后的回調(diào)處理。

3.4執(zhí)行回調(diào)

線程池中的I/O操作調(diào)用完畢后,將獲取結(jié)果儲(chǔ)存在req->result屬性上,然后通知IOCP(windows下)告知操作已完成,并歸還線程到線程池。
在每次Tick的執(zhí)行中,它會(huì)檢查線程池中是否有執(zhí)行完的請(qǐng)求,如果存在,將請(qǐng)求對(duì)象加入I/O觀察者列隊(duì)中,然后將其當(dāng)做事件處理。
I/O觀察者回調(diào)函數(shù)的行為就是取出請(qǐng)求對(duì)象的result屬性作為參數(shù)然后執(zhí)行回調(diào),調(diào)用Javascript中傳入的回調(diào)函數(shù),至此,這個(gè)I/O流程完全接受,示意圖:

整個(gè)異步I/O流程

在Node中除了Javascript是單線程外,Node自身其他都是多線程的,除了用戶代碼無(wú)法并行執(zhí)行,所有I/O則是可以并行起來(lái)的。

4.非I/O的異步API

無(wú)關(guān)I/O的異步API

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

4.1定時(shí)器

setTimeout()與setInterval()與瀏覽器API一致,分別用于單次和多次定時(shí)執(zhí)行任務(wù)。調(diào)用它們時(shí)創(chuàng)建的定時(shí)器會(huì)被插入到定時(shí)器觀察者內(nèi)部的一個(gè)紅黑樹(shù)中,每次Tick執(zhí)行會(huì)到該紅黑樹(shù)中迭代取出定時(shí)器對(duì)象,檢測(cè)是否超時(shí),如果超時(shí)則形成一個(gè)事件,它的回調(diào)函數(shù)立即執(zhí)行。
定時(shí)器并非精確,雖然循環(huán)非??斓侨绻骋淮斡?jì)算占用循環(huán)事件特別多,那么下次循環(huán),它可能已經(jīng)超時(shí)很久了。
setTimeout()的行為圖:

setTimeout()的行為

4.2 process.nextTick()

如果需要一個(gè)立即異步執(zhí)行的任務(wù),可以這樣調(diào)用:

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

process.nextTick(() => {
    //todo
})

由于定時(shí)器需要調(diào)用紅黑樹(shù)所有比較浪費(fèi)性能。process.nextTick()方法比較輕量。每次調(diào)用process.nextTick()方法,只會(huì)把回調(diào)函數(shù)放入隊(duì)列中,在下一輪Tick時(shí)取出立即執(zhí)行。所有process.nextTick()更為高效。

4.3 setImmediate()

setImmediate()與process.nextTick()相似,都是將回調(diào)函數(shù)延遲執(zhí)行,process.nextTick()執(zhí)行回調(diào)優(yōu)先級(jí)高于setImmediate()。

process.nextTick(() => {
    console.log('process.nextTick');
})
setImmediate(() => {
    console.log('setImmediate');
})
console.log('正常執(zhí)行')
//執(zhí)行結(jié)果
正常執(zhí)行
process.nextTick
setImmediate

這是因?yàn)槭录h(huán)對(duì)觀察者的檢查是有先后順序的,process.nextTick()屬于idle觀察者,setImmediate()屬于check觀察者。
process.nextTick()的回調(diào)函數(shù)保存在一個(gè)數(shù)組中,setImmediate()的結(jié)果則是保存在鏈表中。process.nextTick()在每次循環(huán)中會(huì)將數(shù)組的回調(diào)函數(shù)全部執(zhí)行完畢,而setImmediate()每輪循環(huán)中執(zhí)行鏈表中的一個(gè)回調(diào)函數(shù) (當(dāng)前運(yùn)行node版本是windows8.7.0,新版的setImmediate處理回調(diào)函數(shù)已經(jīng)改變,在一輪循環(huán)中setImmediate中的回調(diào)函數(shù)被全部執(zhí)行)。
列如:

process.nextTick(() => {
    console.log('nextTick執(zhí)行1');
})
process.nextTick(() => {
    console.log('nextTick執(zhí)行2');
})
setImmediate(() => {
    console.log('setImmediate執(zhí)行1');
    process.nextTick(() => {
        console.log('插入執(zhí)行');
    })
})
setImmediate(() => {
    console.log('setImmediate執(zhí)行2');
})
console.log('正常執(zhí)行')
//執(zhí)行結(jié)果
正常執(zhí)行
nextTick執(zhí)行1
nextTick執(zhí)行2
setImmediate執(zhí)行1
setImmediate執(zhí)行2
插入執(zhí)行

5.事件驅(qū)動(dòng)與高性能服務(wù)器

利用Node構(gòu)建web服務(wù)器流程圖:

利用Node構(gòu)建web服務(wù)器流程圖

服務(wù)器模型的經(jīng)典模型:

  • 同步式
    一次只能處理一個(gè)請(qǐng)求,其余請(qǐng)求出于等待狀態(tài)
  • 每進(jìn)程/每請(qǐng)求
    為每個(gè)請(qǐng)求創(chuàng)建一個(gè)進(jìn)程,可以同時(shí)處理多個(gè)請(qǐng)求,不具備高擴(kuò)展性,系統(tǒng)資源有限。
  • 每線程/每請(qǐng)求
    為每個(gè)請(qǐng)求啟動(dòng)一個(gè)新線程。比啟動(dòng)新進(jìn)程輕量,但是高并發(fā)的時(shí)候內(nèi)存將很快耗光。(Apache采用這種模式),線程多了后上下文切換頻繁消耗資源。

Node采用事件驅(qū)動(dòng)方式無(wú)需為每個(gè)請(qǐng)求創(chuàng)建新線程,可以省掉很多開(kāi)銷(Nginx采用與Node相同的事件驅(qū)動(dòng)),即使在大并發(fā)的情況下也不受上下文切換開(kāi)銷的影響。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容