1.為什么要使用異步I/O
1.1 用戶體驗
瀏覽器中的Javascripts是在單線程上執(zhí)行的付翁,并且和UI渲染公用一個線程项秉。這就意味著在執(zhí)行Javascript時候UI的渲染和響應(yīng)是出于停滯的狀態(tài)樟凄,如果腳本執(zhí)行時間超過100ms用戶就能感受到頁面卡頓僵蛛。在B/S模型中如果通過同步方式獲取服務(wù)器資源Javascript需要等待資源的返回荞驴,這段時間UI將會停頓不響應(yīng)交互琐谤。而采用異步方式請求資源的同時Javascript和UI渲染可以繼續(xù)執(zhí)行。
通過異步執(zhí)行可以消除UI阻塞現(xiàn)象魄懂,但是獲取資源速度取決于服務(wù)器的響應(yīng)沿侈,假設(shè)有這么個場景,獲取兩個資源數(shù)據(jù):
get('json_a');//需要消耗時間M
get('json_b');//需要消耗時間N
如果采用同步方式獲取資源的時間為M+N市栗,如果采用異步方式時間則是max(M,N)缀拭。隨著網(wǎng)站的擴大,數(shù)據(jù)將會分布在不同服務(wù)器上填帽,分布式也將意味著M與N的值會線性增長蛛淋。同步與異步的耗時差距也會變大。
1.2 資源的分配
假設(shè)一組互不先關(guān)的任務(wù)需要執(zhí)行篡腌,主流方法有兩種:
- 單線程串行依次執(zhí)行
- 多線程并行完成
如果創(chuàng)建多線程的開銷小于并行執(zhí)行褐荷,那么多線程的方式是首選。多線程的代價在于創(chuàng)建線程和執(zhí)行線程時的上下文切換嘹悼。在復(fù)雜業(yè)務(wù)中多線程需要面臨鎖叛甫、狀態(tài)同步問題。優(yōu)勢在于多線程在多核CPU上可以提升CPU利用率绘迁。
單線程串行執(zhí)行缺點在于性能合溺,任意一個任務(wù)略慢都會影響下一個執(zhí)行。通常I/O與CPU計算之間是可以并行進行的缀台,但是同步編程導(dǎo)致I/O的進行會讓后續(xù)任務(wù)等待棠赛,造成資源浪費。
Node在兩者之間做出了自己的方案:利用單線程膛腐,遠離多線程死鎖睛约、狀態(tài)同步問題;利用異步I/O哲身,讓單線程遠離阻塞辩涝,更好的利用CPU。
為了彌補單線程無法利用多核CPU缺點勘天,Node提供了類似前端瀏覽器的Web Workers的子進程怔揩,子進程可以通過工作進程高效的利用CPU和I/O。
[異步I/O調(diào)用示意圖]
2.異步I/O實現(xiàn)
2.1異步I/O與非阻塞I/O
操作系統(tǒng)內(nèi)核對于I/O只有兩種方式:阻塞和非阻塞脯丝。調(diào)用阻塞I/O時商膊,程序需要等待I/O完成才返回結(jié)果,如圖:
為了提高性能宠进,內(nèi)核提供了非阻塞I/O晕拆,非阻塞I/O調(diào)用之后會立刻返回,如圖:
非阻塞I/O返回后材蹬,完整的I/O并沒有完成实幕,立即返回的不是業(yè)務(wù)層期望的數(shù)據(jù)吝镣,僅僅是當前調(diào)用狀態(tài)。為了獲取完整的數(shù)據(jù)昆庇,應(yīng)用需要反復(fù)調(diào)用I/O操作來確認是否完成末贾。這種反復(fù)調(diào)用判斷操作是否完成的計算叫做 輪詢。
現(xiàn)存的輪詢技術(shù)主要有這些:
-
read
最原始的一種方式凰锡,通過反復(fù)調(diào)用I/O狀態(tài)來完成數(shù)據(jù)讀取未舟,在獲取最終數(shù)據(jù)前圈暗,CPU一直耗用在等待是掂为,示意圖:
-
select
在read基礎(chǔ)上的改進方案,通過文件描述符上的事件狀態(tài)來進行判斷员串,select輪詢有一個限制勇哗,它采用一個1024長度的數(shù)組來保存儲存狀態(tài),所以它最多可以檢查1024個文件描述符寸齐,示意圖:
-
poll
采用鏈表的方式來避免數(shù)組長度限制欲诺,能避免不需要的檢查。當文件描述符多時渺鹦,性能還是十分低下扰法,于select相似,性能有所改善毅厚,如圖:
-
epoll
Linux下效率最高的I/O事件通知機制塞颁,進入輪詢時如果沒有檢查到I/O事件,將會進行休眠吸耿,直到事件將他喚醒祠锣。利用的事件通知、執(zhí)行回調(diào)方式咽安,而不是遍歷查詢伴网,所以不會浪費CPU,執(zhí)行效率比較高妆棒。示意圖:
-
kqueue
與epoll類似澡腾,只存在FreeBSD系統(tǒng)下。
2.2 理想的非阻塞異步I/O
期望的完美異步I/O應(yīng)該是程序發(fā)起非阻塞調(diào)用糕珊,無需通過遍歷或者事件喚醒等輪詢方式动分,可以進行下一個任務(wù),只需要在I/O完成后通過信號或回調(diào)將數(shù)據(jù)傳遞給應(yīng)用程序放接,示意圖:
2.3現(xiàn)實的異步I/O
通過讓部分線程進行阻塞I/O或者非阻塞I/O加輪詢技術(shù)完成數(shù)據(jù)獲取刺啦。讓一個線程進行處理計算,通過線程之間的通訊將I/O得到的數(shù)據(jù)進行傳遞纠脾,實現(xiàn)異步I/O,示意圖:
最初Node在*nix平臺下采用libeio配合libev實現(xiàn)I/O異步I/O,Node v0.9.3中玛瘸,自行實現(xiàn)了線程池完成異步I/O蜕青。
windows下通過IOCP來實現(xiàn)(實現(xiàn)原理仍然是線程池,只是由系統(tǒng)內(nèi)核接受管理)糊渊。
windows和*nix平臺的差異右核,Node提供了libuv作為封裝,兼容性判斷由這一層完成渺绒,Node編譯期間會判斷平臺條件贺喝。
3.Node的異步I/O
3.1事件循環(huán)
啟動Node時會創(chuàng)建一個類似while(true)的循環(huán),每執(zhí)行一次循環(huán)過程稱之為Tick宗兼。每個Tick的過程就是檢查是否有待處理事件躏鱼,如果有,就讀取出事件及其相關(guān)的回調(diào)函數(shù)殷绍,如果存在關(guān)聯(lián)的回調(diào)函數(shù)染苛,就執(zhí)行。然后加入下一個循環(huán)主到,如果不再有事件處理就退出進程茶行。如圖:
3.2觀察者
在每個Tick過程中,怎么判斷是否有事件需要處理呢登钥?畔师,這里引入了觀察者概念。
每個事件循環(huán)中有一個或多個觀察者牧牢,而判斷是否有事件要處理的過程就是向觀察者詢問是否需要處理事件看锉。
3.3請求對象
Javascript發(fā)起調(diào)用到內(nèi)核執(zhí)行完I/O操作的過程中,存在一種中間產(chǎn)物结执,叫做請求對象度陆。
以fs.open()為例:
fs.open = function(path, flags, mode, callback) {
//...
binding.open(pathModule._makeLong(path), stringToFlags(flags), mode, callback);
}
fs.open()是根據(jù)指定路徑和參數(shù)打開一個文件,從而獲取一個文件描述符献幔,這是后續(xù)所有I/O操作的初始操作懂傀。
Javascript層面的代碼調(diào)用C++核心模塊進行下層操作。示意圖:
實際上調(diào)用了uv_fs_open()方法蜡感。在調(diào)用過程中創(chuàng)建了一個FSReqWrap請求對象蹬蚁。從Javascriptc層傳入的參數(shù)和當前方法都封裝在這個請求對象中,回調(diào)函數(shù)則被設(shè)置在對象的oncomplete_sym屬性上:
req_wrap->object_->Set(oncomplete_sym, callback);
對象包裝完畢郑兴,將FSReqWrap對象推入線程池中等待執(zhí)行犀斋。此時Javascript調(diào)用立即返回,Javascript線程可繼續(xù)執(zhí)行當前任務(wù)的后續(xù)操作情连,當前的I/O操作在線程池中等待執(zhí)行叽粹,不管是否是阻塞I/O,的不會影響Javascript線程的后續(xù)執(zhí)行。
請求對象是異步I/O過程的重要中間產(chǎn)物虫几,所有狀態(tài)都保存在這個對象中锤灿,包括送入線程池執(zhí)行以及I/O操作完畢后的回調(diào)處理。
3.4執(zhí)行回調(diào)
線程池中的I/O操作調(diào)用完畢后辆脸,將獲取結(jié)果儲存在req->result屬性上但校,然后通知IOCP(windows下)告知操作已完成,并歸還線程到線程池啡氢。
在每次Tick的執(zhí)行中状囱,它會檢查線程池中是否有執(zhí)行完的請求,如果存在倘是,將請求對象加入I/O觀察者列隊中亭枷,然后將其當做事件處理。
I/O觀察者回調(diào)函數(shù)的行為就是取出請求對象的result屬性作為參數(shù)然后執(zhí)行回調(diào)辨绊,調(diào)用Javascript中傳入的回調(diào)函數(shù)奶栖,至此,這個I/O流程完全接受门坷,示意圖:
在Node中除了Javascript是單線程外,Node自身其他都是多線程的袍镀,除了用戶代碼無法并行執(zhí)行默蚌,所有I/O則是可以并行起來的。
4.非I/O的異步API
無關(guān)I/O的異步API
- setTimeout()
- setInterval()
- setImmediate()
- process.nextTick()
4.1定時器
setTimeout()與setInterval()與瀏覽器API一致苇羡,分別用于單次和多次定時執(zhí)行任務(wù)绸吸。調(diào)用它們時創(chuàng)建的定時器會被插入到定時器觀察者內(nèi)部的一個紅黑樹中,每次Tick執(zhí)行會到該紅黑樹中迭代取出定時器對象设江,檢測是否超時锦茁,如果超時則形成一個事件,它的回調(diào)函數(shù)立即執(zhí)行叉存。
定時器并非精確码俩,雖然循環(huán)非常快但是如果某一次計算占用循環(huán)事件特別多歼捏,那么下次循環(huán)稿存,它可能已經(jīng)超時很久了。
setTimeout()的行為圖:
4.2 process.nextTick()
如果需要一個立即異步執(zhí)行的任務(wù)瞳秽,可以這樣調(diào)用:
setTimeout(() =>{
//todo
}, 0);
process.nextTick(() => {
//todo
})
由于定時器需要調(diào)用紅黑樹所有比較浪費性能瓣履。process.nextTick()方法比較輕量。每次調(diào)用process.nextTick()方法练俐,只會把回調(diào)函數(shù)放入隊列中袖迎,在下一輪Tick時取出立即執(zhí)行。所有process.nextTick()更為高效。
4.3 setImmediate()
setImmediate()與process.nextTick()相似燕锥,都是將回調(diào)函數(shù)延遲執(zhí)行浴韭,process.nextTick()執(zhí)行回調(diào)優(yōu)先級高于setImmediate()。
process.nextTick(() => {
console.log('process.nextTick');
})
setImmediate(() => {
console.log('setImmediate');
})
console.log('正常執(zhí)行')
//執(zhí)行結(jié)果
正常執(zhí)行
process.nextTick
setImmediate
這是因為事件循環(huán)對觀察者的檢查是有先后順序的脯宿,process.nextTick()屬于idle觀察者念颈,setImmediate()屬于check觀察者。
process.nextTick()的回調(diào)函數(shù)保存在一個數(shù)組中连霉,setImmediate()的結(jié)果則是保存在鏈表中榴芳。process.nextTick()在每次循環(huán)中會將數(shù)組的回調(diào)函數(shù)全部執(zhí)行完畢,而setImmediate()每輪循環(huán)中執(zhí)行鏈表中的一個回調(diào)函數(shù) (當前運行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ū)動與高性能服務(wù)器
利用Node構(gòu)建web服務(wù)器流程圖:
服務(wù)器模型的經(jīng)典模型:
-
同步式
一次只能處理一個請求,其余請求出于等待狀態(tài) -
每進程/每請求
為每個請求創(chuàng)建一個進程歉井,可以同時處理多個請求柿祈,不具備高擴展性,系統(tǒng)資源有限哩至。 -
每線程/每請求
為每個請求啟動一個新線程躏嚎。比啟動新進程輕量,但是高并發(fā)的時候內(nèi)存將很快耗光菩貌。(Apache采用這種模式)卢佣,線程多了后上下文切換頻繁消耗資源。
Node采用事件驅(qū)動方式無需為每個請求創(chuàng)建新線程箭阶,可以省掉很多開銷(Nginx采用與Node相同的事件驅(qū)動)虚茶,即使在大并發(fā)的情況下也不受上下文切換開銷的影響。