為什么要異步 I/O
用戶體驗(yàn)
只有后端能夠快速響應(yīng)資源或听,才能讓前端的體驗(yàn)變好
資源分配
利用單線程妥粟,遠(yuǎn)離多線程死鎖、狀態(tài)同步等問(wèn)題窝趣;利用異步 I/O疯暑,讓單線程遠(yuǎn)離阻塞,以更好地使用 CPU
異步 I/O 實(shí)現(xiàn)現(xiàn)狀
異步 I/O 與非阻塞 I/O
輪詢技術(shù)滿足了非阻塞 I/O 確保獲取完整數(shù)據(jù)的需求哑舒,但是對(duì)于應(yīng)用程序而言妇拯,它仍然只能算是一種同步,因?yàn)閼?yīng)用程序仍然需要等待 I/O 完全返回洗鸵,依舊花費(fèi)了很多時(shí)間等待越锈。等待期間,CPU 要么用于遍歷文件描述符的狀態(tài)预麸,要么用于休眠等待時(shí)間發(fā)生
理想的非阻塞異步 I/O
我們期望的完美的異步 I/O 應(yīng)該是應(yīng)用程序發(fā)起非阻塞調(diào)用瞪浸,無(wú)須通過(guò)遍歷或者事件喚醒等方式輪詢,可以直接處理下一個(gè)任務(wù)吏祸,只需在 I/O 完成后通過(guò)信號(hào)或回調(diào)將數(shù)據(jù)傳遞給應(yīng)用程序即可
現(xiàn)實(shí)的異步 I/O
通過(guò)讓部分現(xiàn)成進(jìn)行阻塞 I/O 或者非阻塞 I/O 加輪詢技術(shù)來(lái)完成數(shù)據(jù)獲取对蒲,讓一個(gè)線程進(jìn)行計(jì)算處理,通過(guò)線程之間的通信將 I/O 得到的數(shù)據(jù)進(jìn)行傳遞贡翘,這就輕松實(shí)現(xiàn)了異步 I/O(盡管它是模擬的)
我們時(shí)常提到 Node 是單線程的蹈矮,這里的單線程僅僅只是 JavaScript 執(zhí)行在單線程中罷了。在 Node 中鸣驱,無(wú)論是 *nix 還是 Windows 平臺(tái)泛鸟,內(nèi)部完成 I/O 任務(wù)的另有線程池
Node 的異步 I/O
事件循環(huán)——Node 自身的執(zhí)行模型
在進(jìn)程啟動(dòng)時(shí),Node 便會(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í)行它們坚冀。然后進(jìn)入下個(gè)循環(huán)济赎,如果不再有事件處理,就退出進(jìn)程。
觀察者——在每個(gè) Tick 的過(guò)程中司训,判斷是否有事件需要處理
每個(gè)事件循環(huán)中有一個(gè)或多個(gè)觀察者构捡,而判斷是否有事件要處理的過(guò)程就是向這些觀察者詢問(wèn)是否有要處理的事件
事件可能來(lái)自用戶的點(diǎn)擊或者加載某些文件時(shí)產(chǎn)生,而產(chǎn)生的事件都有對(duì)應(yīng)的觀察者壳猜。在 Node 中勾徽,事件主要來(lái)源于網(wǎng)絡(luò)請(qǐng)求、文件 I/O 等蓖谢,這些事件對(duì)應(yīng)的觀察者有文件 I/O 觀察者捂蕴、網(wǎng)絡(luò) I/O 觀察者等譬涡。觀察者將事件進(jìn)行了分類闪幽。
事件循環(huán)是一個(gè)典型的生產(chǎn)者/消費(fèi)者模型。異步 I/O涡匀、網(wǎng)絡(luò)請(qǐng)求等則是事件的生產(chǎn)者盯腌,源源不斷為 Node 提供不同類型的事件,這些事件被傳遞到對(duì)應(yīng)的觀察者那里陨瘩,事件循環(huán)則從觀察者那里取出事件并處理腕够。
在 Windows 下,這個(gè)循環(huán)基于 IOCP 創(chuàng)建舌劳,而在 *nix 下則基于多線程創(chuàng)建
請(qǐng)求對(duì)象——從 JavaScript 發(fā)起調(diào)用到內(nèi)核執(zhí)行完 I/O 操作的過(guò)渡過(guò)程中的中間產(chǎn)物
從 JavaScript 調(diào)用 Node 的核心模塊帚湘,核心模塊調(diào)用 C++ 內(nèi)建模塊,內(nèi)建模塊通過(guò) libuv 進(jìn)行系統(tǒng)調(diào)用甚淡,這里的 libuv 作為封裝層大诸,有兩個(gè)平臺(tái)的實(shí)現(xiàn),實(shí)質(zhì)上是調(diào)用了 uv_fs_open() 方法贯卦。在 uv_fs_open() 的調(diào)用過(guò)程中资柔,我們創(chuàng)建了一個(gè) FSReqWrap 請(qǐng)求對(duì)象。從 JavaScript 層傳入的參數(shù)和當(dāng)前方法都被封裝在這個(gè)請(qǐng)求對(duì)象中撵割,其中我們最為關(guān)注的回調(diào)函數(shù)則被設(shè)置在這個(gè)對(duì)象的 oncomplete_sym 屬性上:req_wrap->object->Set(oncomplete_sym, callback);
對(duì)象包裝完畢后贿堰,在 Windows 下,則調(diào)用 QueueUserWorkItem() 方法將這個(gè) FSReqWrap 對(duì)象推入線程池中等待執(zhí)行啡彬。
至此羹与,JavaScript 調(diào)用立即返回, 由 JavaScript 層面發(fā)出的異步調(diào)用的第一階段就此結(jié)束庶灿。JavaScript 線程可以繼續(xù)執(zhí)行當(dāng)前任務(wù)的后續(xù)操作纵搁。當(dāng)前的 I/O 操作在線程池中等待執(zhí)行。不管它是否阻塞 I/O跳仿,都不會(huì)影響到 JavaScript 線程的后續(xù)執(zhí)行诡渴,如此就達(dá)到了異步的目的。
請(qǐng)求對(duì)象是異步 I/O 過(guò)程中的重要中間產(chǎn)物,所有的狀態(tài)都保存在這個(gè)對(duì)象中妄辩,包括送入線程池等待執(zhí)行以及 I/O 操作完畢后的回調(diào)處理
執(zhí)行回調(diào)
線程池中的 I/O 操作調(diào)用完畢之后惑灵,會(huì)將獲取的結(jié)果儲(chǔ)存在 req->result 屬性上,然后調(diào)用 PostQueuedCompletionStatus() 通知 IOCP眼耀,告知當(dāng)前對(duì)象操作已經(jīng)完畢:PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped))
PostQueuedCompletionStatus() 方法的作用是向 IOCP 提交執(zhí)行狀態(tài)英支,并將線程歸還線程池。
在這個(gè)過(guò)程中哮伟,我們其實(shí)還動(dòng)用了事件循環(huán)的 I/O 觀察者干花。在每次 Tick 的執(zhí)行中,它會(huì)調(diào)用 IOCP 相關(guān)的 FetQueuedCompletionStatus() 方法檢查線程池中是否有執(zhí)行完的請(qǐng)求楞黄,如果存在池凄,會(huì)將請(qǐng)求對(duì)象加入到 I/O 觀察者的隊(duì)列中,然后將其當(dāng)做事件處理鬼廓。
I/O 觀察者回調(diào)函數(shù)的行為就是取出請(qǐng)求對(duì)象的 result 屬性作為參數(shù)肿仑,取出 oncomplete_sym 屬性作為方法,然后調(diào)用執(zhí)行碎税。以此達(dá)到調(diào)用 JavaScript 中傳入的回調(diào)函數(shù)的目的尤慰。
事件循環(huán)、觀察者雷蹂、請(qǐng)求對(duì)象伟端、I/O 線程池這四者共同構(gòu)成了 Node 異步 I/O 模型的基本要素
在 Node 中,除了 JavaScript 是單線程外匪煌,Node 自身其實(shí)是多線程的责蝠,只是 I/O 線程使用的 CPU 較少。另一個(gè)需要重視的觀點(diǎn)則是虐杯,除了用戶代碼無(wú)法并行執(zhí)行外玛歌,所有的 I/O(磁盤(pán) I/O 和網(wǎng)絡(luò) I/O 等)則是可以并行起來(lái)的。
非 I/O 的異步 API
定時(shí)器
setTimeout() 和 setInterval() 與瀏覽器中的 API 是一致的擎椰,分別用于單次和多次定時(shí)執(zhí)行任務(wù)支子。它們的實(shí)現(xiàn)原理和異步 I/O 比較類似,只是不需要 I/O 線程池的參與达舒。定時(shí)器的問(wèn)題在于值朋,它并非精確的(容忍范圍內(nèi))。盡管事件循環(huán)十分快巩搏,但是如果某一次循環(huán)占用的事件較長(zhǎng)昨登,那么下次循環(huán)時(shí),它也許已經(jīng)超時(shí)很久了贯底。
process.nextTick()
采用定時(shí)器需要?jiǎng)佑眉t黑樹(shù)丰辣,創(chuàng)建定時(shí)器對(duì)象和迭代等操作,而 setTimeout(fn, 0)
的方式較為浪費(fèi)性能。實(shí)際上 process.nextTick()
的方法的操作相對(duì)較為輕量笙什。
每次調(diào)用 process.nextTick()
方法飘哨,只會(huì)將回調(diào)函數(shù)放入隊(duì)列中,在下一輪 Tick 時(shí)取出執(zhí)行琐凭。定時(shí)器中采用紅黑樹(shù)的操作時(shí)間復(fù)雜度為 O(lg(n))芽隆,nextTick() 的時(shí)間復(fù)雜度為 O(1)。相較之下统屈, process.nextTick()
更高效胚吁。
setImmediate()
setImmediate() 方法與 process.nextTick() 方法十分類似,都是將回調(diào)函數(shù)延遲執(zhí)行
區(qū)別是愁憔,process.nextTick() 中的回調(diào)函數(shù)執(zhí)行的優(yōu)先級(jí)要高于 setImmediate()腕扶。這里的原因在于事件循環(huán)對(duì)觀察者的檢查是有先后順序的,process.nextTick() 屬于 idle 觀察者惩淳,setImmediate() 屬于 check 觀察者蕉毯。在每一次輪循環(huán)檢查中,idle 觀察者先于 I/O 觀察者思犁,I/O 觀察者先于 check 觀察者
在具體實(shí)現(xiàn)上,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ù)学辱。
// 加入兩個(gè)nextTick()de 回調(diào)函數(shù)
process.nextTick(function () {
console.log('nextTick延遲執(zhí)行1');
});
process.nextTick(function () {
console.log('nextTick延遲執(zhí)行2');
});
// 加入兩個(gè)setImmediate()的回調(diào)函數(shù)
setImmediate(function () {
console.log('setImmediate延遲執(zhí)行1');
// 進(jìn)入下次循環(huán)
process.nextTick(function () {
console.log('強(qiáng)勢(shì)插入');
});
});
setImmediate(function () {
console.log('setImmediate延遲執(zhí)行2');
});
console.log('正常執(zhí)行');
// 其執(zhí)行結(jié)果如下:
//// 正常執(zhí)行
//// nextTick延遲執(zhí)行1
//// nextTick延遲執(zhí)行2
//// setImmediate延遲執(zhí)行1
//// 強(qiáng)勢(shì)插入
//// setImmediate延遲執(zhí)行2
從執(zhí)行結(jié)果上可以看出,當(dāng)?shù)谝粋€(gè) setImmediate() 的回調(diào)函數(shù)執(zhí)行后环形,并沒(méi)有立即執(zhí)行第二個(gè)策泣,而是進(jìn)入了下一輪循環(huán),再次按 process.nextTick() 優(yōu)先抬吟、setImmediate() 次后的順序執(zhí)行萨咕。之所以這樣設(shè)計(jì),是為了保證每輪循環(huán)能夠較快地執(zhí)行結(jié)束火本,防止 CPU 占用過(guò)多而阻塞后續(xù) I/O 調(diào)用的情況危队。
事件驅(qū)動(dòng)與高性能服務(wù)器
Node 通過(guò)事件驅(qū)動(dòng)的方式處理請(qǐng)求,無(wú)須為每一個(gè)請(qǐng)求創(chuàng)建額外的對(duì)應(yīng)線程钙畔,可以省掉創(chuàng)建線程和銷毀線程的開(kāi)銷茫陆,同時(shí)操作系統(tǒng)在調(diào)度任務(wù)時(shí)因?yàn)榫€程較少,上下文切換的代價(jià)很低擎析。這使得服務(wù)器有條不紊地處理請(qǐng)求簿盅,即使在大量連接的情況下,也不受線程上下文切換開(kāi)銷的影響,這是 Node 高性能的一個(gè)原因桨醋。