簡介
我們都知道 JS 是一門單線程執(zhí)行語言,單線程意味著每次只能處理一件事隘梨,意味著阻塞程癌。JS 提供了很多異步代碼,Event Loop(事件環(huán)轴猎,又稱事件輪詢)就是 JS 處理異步的一種機(jī)制∏独颍現(xiàn)在我們用一種比較形象的方式去解釋這種機(jī)制
先備知識
在了解 Event Loop 之前,我們先要了解一些知識:
- 什么是(JS)異步捻脖?
在表現(xiàn)上來說锐峭,異步表現(xiàn)為代碼的執(zhí)行順序和你書寫的順序不一樣了。具體來說可婶,是與同步相對沿癞,異步處理不用阻塞當(dāng)前線程來等待處理完成,而是允許后續(xù)操作矛渴,直至其它線程將處理完成椎扬,并回調(diào)通知此線程。
- 什么是回調(diào)函數(shù)具温?
異步編程往往需要回調(diào)函數(shù)輔助(但有回調(diào)函數(shù)并不一定是異步編程)蚕涤。結(jié)合上面提到的異步,舉個(gè)例子铣猩,你去商店買東西揖铜,店員說沒貨,于是你登記自己的電話號碼要求店員有貨的時(shí)候打電話剂习,然后你就可以繼續(xù)做自己的事蛮位,隨后進(jìn)貨后店員打電話通知你取貨较沪。在這個(gè)例子里鳞绕,你的電話號碼就叫回調(diào)函數(shù)失仁,你把電話留給店員就叫登記回調(diào)函數(shù),店里后來有貨了叫做觸發(fā)了回調(diào)關(guān)聯(lián)的事件们何,店員給你打電話叫做調(diào)用回調(diào)函數(shù)萄焦,你到店里去取貨叫做響應(yīng)回調(diào)事件
// 下面這個(gè)并非異步
function call(cb) {
cb();
}
call(() => {});
// 異步
fetch('http://example.com/movies.json')
.then(function(response) {
return response.json();
})
setTimeout(param => {
console.log(param)
}, 1000, 'param')
- 進(jìn)程和線程?
這兩個(gè)概念都會在下面用到
進(jìn)程: 進(jìn)程(Process)是計(jì)算機(jī)中的程序關(guān)于某數(shù)據(jù)集合上的一次運(yùn)行活動(dòng)冤竹,是系統(tǒng)進(jìn)行資源分配和調(diào)度的基本單位拂封,是操作系統(tǒng)結(jié)構(gòu)的基礎(chǔ),在現(xiàn)代系統(tǒng)中鹦蠕,進(jìn)程基本都是線程的容器冒签。(百度百科)
線程: 是操作系統(tǒng)能夠進(jìn)行運(yùn)算調(diào)度的最小單位,它被包含在進(jìn)程之中钟病,是進(jìn)程中的實(shí)際運(yùn)作單位萧恕,一條線程指的是進(jìn)程中一個(gè)單一順序的控制流(由此可見線程同一時(shí)間基本只能做一件事情)。(百度百科)
說白了肠阱,如果把我們的身體比喻成一個(gè)進(jìn)程票唆,那么線程就是我們身體的各個(gè)部件,因?yàn)榫€程同一時(shí)間基本只能做一件事情屹徘,所以我們腳走路的時(shí)候不能踢毽子走趋,踢毽子的時(shí)候不能走路。
瀏覽器每打開一個(gè) Tab 頁噪伊,即為一個(gè)進(jìn)程簿煌,其中又包括許多線程,比如渲染線程鉴吹、JS 引擎線程啦吧、HTTP 請求線程、計(jì)時(shí)器等等拙寡。我們可以在 Chrome > 更多工具 > 任務(wù)管理器
中看到我們?yōu)g覽器中的所有進(jìn)程授滓。
- 棧和隊(duì)列?
棧(stack)和隊(duì)列(queue)都是一個(gè)運(yùn)算受限的線向表肆糕。
棧遵循的是“后進(jìn)先出”原則般堆,棧的模式就像我們擠地鐵,最后進(jìn)去的人(最外面的人诚啃,即棧頂)出去之后淮摔,先進(jìn)去的人(最里面的人,即棧底)才能出來始赎。
隊(duì)列遵循的是“先進(jìn)先出”原則和橙,隊(duì)列很語義化仔燕,和我們排隊(duì)一樣,前面的人先辦業(yè)務(wù)魔招,后面的人才能繼續(xù)晰搀。
瀏覽器中的 Event Loop
首先我們引入執(zhí)行棧的概念,JS 代碼在執(zhí)行時(shí)办斑,每次遇到一個(gè)函數(shù)外恕,便會在執(zhí)行棧中壓入這個(gè)函數(shù),函數(shù)執(zhí)行完會被移出執(zhí)行棧乡翅,直到執(zhí)行棧為空鳞疲。
當(dāng)遇到異步代碼時(shí)(HTTP 請求),回調(diào)函數(shù)(會被掛起蠕蚜,由其他線程處理(比如網(wǎng)絡(luò)線程)尚洽,當(dāng)處理完畢會把回調(diào)函數(shù)加入到 Task 隊(duì)列中。一旦執(zhí)行棧為空靶累,Event Loop 就會從隊(duì)列中取出一個(gè)函數(shù)放入執(zhí)行棧中執(zhí)行腺毫。所以本質(zhì)上來說 JS 中的異步還是同步行為。
JS 中的 Task 隊(duì)列分別兩種尺铣,不同的任務(wù)源會被分配到不同的隊(duì)列中拴曲。任務(wù)源可以分為微任務(wù)(microtask)和宏任務(wù)(macrotask)。在 ES6 規(guī)范中凛忿,microtask 稱為 jobs澈灼,macrotask 稱為 task〉暌纾看下圖:
- 微任務(wù)包括 process.nextTick 叁熔,promise ,MutationObserver床牧,其中 process.nextTick 為 Node 獨(dú)有荣回。
- 宏任務(wù)包括 script , setTimeout 戈咳,setInterval 心软,setImmediate ,I/O 著蛙,UI rendering删铃。
[圖片上傳失敗...(image-1e0115-1555772297164)]
Event Loop 的執(zhí)行順序如下:
- 執(zhí)行最舊的 macrotask(一次);
- 檢查是否存在 microtask踏堡,然后不停執(zhí)行猎唁,直到清空隊(duì)列(多次);
- 執(zhí)行render顷蟆;
- 然后開始下一輪 Event Loop诫隅;
這里有一個(gè)關(guān)鍵點(diǎn)腐魂,對于 macrotask,每次只會拉出一個(gè)來執(zhí)行逐纬;對于 microtask蛔屹,是把整體隊(duì)列拉出來執(zhí)行空。從圖中我們也可以看到這一點(diǎn)
我們來看一下例子
console.log(1)
setTimeout(() => {
console.log(2)
new Promise(resolve => {
console.log(4)
resolve()
}).then(() => {
console.log(5)
}).then(() => {
console.log(5.1)
})
})
new Promise(resolve => {
console.log(7)
resolve()
}).then(() => {
console.log(8)
}).then(() => {
console.log(8.1)
})
setTimeout(() => {
console.log(9)
new Promise(resolve => {
console.log(11)
resolve()
}).then(() => {
console.log(12)
}).then(() => {
console.log(12.1)
})
})
- 執(zhí)行同步代碼(macrotask)风题,打印出 1 7判导;
- 執(zhí)行完所有微任務(wù)嫉父,打印出 8 8.1卖鲤;
- 執(zhí)行一次宏任務(wù)第晰,打印出 2 4;
- 執(zhí)行完所有微任務(wù),打印出 5 5.1不傅;
- 執(zhí)行一次宏任務(wù),打印出 9 11咸作;
- 執(zhí)行完所有微任務(wù)宪迟,打印出 12 12.1;
Node Event Loop
Node 中的 Event Loop 與瀏覽器大有不同树碱,這是因?yàn)?Node 中的異步操作更多更復(fù)雜肯适,除了網(wǎng)絡(luò),定時(shí)器外成榜,還有文件讀寫框舔,數(shù)據(jù)庫操作等等... 所以 Node 中 Event Loop 分為 6 個(gè)階段,每個(gè)階段都有一個(gè)回調(diào)隊(duì)列等待執(zhí)行赎婚,它們會按照順序反復(fù)運(yùn)行刘绣。每當(dāng)進(jìn)入某一個(gè)階段的時(shí)候,都會從對應(yīng)的回調(diào)隊(duì)列中取出函數(shù)去執(zhí)行挣输。當(dāng)隊(duì)列為空或者執(zhí)行的回調(diào)函數(shù)數(shù)量到達(dá)系統(tǒng)設(shè)定的閾值纬凤,就會進(jìn)入下一階段。
6個(gè)階段
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
Node 會盡可能地保障定時(shí)器任務(wù)的準(zhǔn)時(shí)可靠撩嚼,所以有好幾個(gè)階段都是在處理定時(shí)器停士。我們分別來解釋一下這 6 個(gè)階段:
1. timers
這個(gè)階段執(zhí)行 setTimeout 和 setInterval 設(shè)定的回調(diào)。我們的定時(shí)器都會設(shè)定一個(gè)時(shí)間閾值完丽,但這個(gè)值并不意味著回調(diào)會精確在這個(gè)時(shí)刻執(zhí)行恋技,而是時(shí)間超過閾值之后,回調(diào)會被盡早的執(zhí)行舰涌。因?yàn)椴僮飨到y(tǒng)對其他回調(diào)的調(diào)度可能會延遲他們的執(zhí)行猖任。
2. pending callbacks
處理一些上一輪循環(huán)中的少數(shù)未執(zhí)行的 I/O 回調(diào)。
3. idle瓷耙,prepare
只在內(nèi)部使用朱躺。這個(gè)階段為一些系統(tǒng)操作執(zhí)行回調(diào)刁赖,例如 TCP 錯(cuò)誤。例如长搀,如果一個(gè) TCP socket 在嘗試連接時(shí)收到 ECONNREFUSED宇弛,一些 *nix 系統(tǒng)會等待要報(bào)告這個(gè)錯(cuò)誤。這會被放入 pending callbacks 階段的隊(duì)列中源请。
4. poll
poll 是一個(gè)至關(guān)重要的階段枪芒,這個(gè)階段會判斷定時(shí)器的存在而更改 Event Loop 流程。
poll 階段有兩個(gè)主要功能:
- 計(jì)算需要阻塞多久然后輪詢 I/O谁尸,然后
- 處理 poll 隊(duì)列中的事件
當(dāng)事件循環(huán)進(jìn)入 poll 階段舅踪,并且沒有需要執(zhí)行的定時(shí)器,會進(jìn)入下面兩種狀況之一:
- 如果 poll 隊(duì)列不是空的良蛮,事件循環(huán)會在隊(duì)列上同步迭代直到完成全部隊(duì)列的回調(diào)抽碌,或者系統(tǒng)依賴的限制被達(dá)到。
- 如果 poll 隊(duì)列是空的决瞳,那么會進(jìn)入下面兩種狀況之一:
- 如果有代碼被 setImmediate() 設(shè)定货徙,事件循環(huán)會結(jié)束輪詢階段,并且進(jìn)入 check 階段去執(zhí)行設(shè)定的回調(diào)皮胡。
- 如果代碼沒有被 setImmediate() 設(shè)定痴颊,事件循環(huán)會等待回調(diào)被加入隊(duì)列,然后立即執(zhí)行它們屡贺。
一旦 poll 隊(duì)列是空的蠢棱,事件循環(huán)會檢查超過閾值的定時(shí)器。如果一個(gè)或者多個(gè)定時(shí)器已經(jīng)準(zhǔn)備好烹笔,事件循環(huán)會繞回定時(shí)器階段去執(zhí)行定時(shí)器回調(diào)裳扯。
注意:為了防止 poll 階段耗盡事件循環(huán),libuv(實(shí)現(xiàn)了 Node.js 事件循環(huán)和這個(gè)平臺全部異步行為的 C 語言庫)也有對輪詢事件有一個(gè)最大限制谤职。
5. check
這個(gè)階段允許用戶在 poll 階段完成之后立即執(zhí)行回調(diào)饰豺。如果 poll 階段變的空閑并且代碼被 setImmediate() 放入隊(duì)列,事件循環(huán)就可能進(jìn)入 check 階段而不是繼續(xù)等待允蜈。
事實(shí)上 setImmediate() 是在事件循環(huán)一個(gè)單獨(dú)的階段運(yùn)行的特殊計(jì)時(shí)器冤吨。它使用 libuv 的 API 讓設(shè)定的回調(diào)在 poll 階段完成之后進(jìn)行。
通常饶套,隨著代碼被執(zhí)行漩蟆,事件循環(huán)會最終達(dá)到 poll 階段,在 poll 階段會等待可能到來的連接妓蛮、請求等等怠李。但是,如果一個(gè)回調(diào)被 setImmediate() 設(shè)定并且 poll 階段是空閑的,那么 poll 階段就會結(jié)束捺癞,然后進(jìn)入 check 階段而不是繼續(xù)等待輪詢事件夷蚊。
6. close callbacks
如果一個(gè) socket 或者句柄被突然關(guān)閉(例如 socket.destroy()
),close
事件會在這個(gè)階段被發(fā)送髓介,否則它就會被通過 process.nextTick() 發(fā)送惕鼓。
細(xì)節(jié)詳解
setImmediate() vs setTimeout()
setImmediate() 和 setTimeout() 很相似,但是他們的行為卻根據(jù)被調(diào)用的時(shí)機(jī)有差別唐础。
- setImmediate() 是在當(dāng)前 poll 階段結(jié)束時(shí)立即執(zhí)行箱歧。
- setTimeout() 是一段代碼在時(shí)間閾值被超過之后盡早執(zhí)行。
定時(shí)器被執(zhí)行的順序根據(jù)他們被調(diào)用的上下文會有區(qū)別一膨。如果它們都在主模塊被調(diào)用呀邢,時(shí)機(jī)會和進(jìn)程的性能有關(guān)(可能會被機(jī)器的其他進(jìn)程影響)。
例如汞幢,如果我們運(yùn)行并不在一個(gè)I/O循環(huán)中的腳本(比如在主模塊中)驼鹅,這兩個(gè)定時(shí)器執(zhí)行的順序是不確定的微谓,因?yàn)樗贿M(jìn)程的性能限制森篷。
- 首先 setTimeout(fn, 0) === setTimeout(fn, 1),這是由源碼決定的
- 進(jìn)入事件循環(huán)也是需要成本的豺型,如果在準(zhǔn)備時(shí)候花費(fèi)了大于 1ms 的時(shí)間仲智,那么在 timer 階段就會直接執(zhí)行 setTimeout 回調(diào)
- 那么如果準(zhǔn)備時(shí)間花費(fèi)小于 1ms,那么就是 setImmediate 回調(diào)先執(zhí)行了
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
但是姻氨,如果你把兩個(gè)回調(diào)放入一個(gè) I/O 循環(huán)中钓辆,那么 setImmediate() 的回調(diào)總是會被最先執(zhí)行。因?yàn)閮蓚€(gè)代碼寫在 IO 回調(diào)中肴焊,IO 回調(diào)是在 poll 階段執(zhí)行前联,當(dāng)回調(diào)執(zhí)行完畢后隊(duì)列為空,發(fā)現(xiàn)存在 setImmediate 回調(diào)娶眷,所以就直接跳轉(zhuǎn)到 check 階段去執(zhí)行回調(diào)了似嗤。
// timeout_vs_immediate.js
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
相比于使用 setTimeout(),使用 setImmediate() 的主要優(yōu)勢是届宠,如果在一個(gè) I/O 循環(huán)中烁落,setImmediate 總會比其他定時(shí)器先執(zhí)行,無論其他定時(shí)器有多少豌注。
microtask
上面介紹的都是 macrotask 的執(zhí)行情況伤塌,對于 microtask 來說,它會在以上每個(gè)階段完成前清空 microtask 隊(duì)列轧铁,這里和瀏覽器類似每聪,microtask 都會在在 macrotask 前面處理
process.nextTick()
你可能注意到 process.nextTick() 在示意圖中沒有展示,盡管它是異步 API 的一部分。這是因?yàn)?process.nextTick() 嚴(yán)格來說并不屬于事件循環(huán)的一部分药薯。實(shí)際上他爸,nextTickQueue 會在每一個(gè)正在進(jìn)行的操作完成后之后執(zhí)行。它有一個(gè)自己的隊(duì)列果善,當(dāng)每個(gè)階段完成后诊笤,如果存在 nextTickQueue,就會清空隊(duì)列中的所有回調(diào)函數(shù)巾陕,并且優(yōu)先于其他 microtask 執(zhí)行讨跟。