事件循環(huán)與任務隊列是JS中比較重要的兩個概念构韵。這兩個概念在ES5和ES6兩個標準中有不同的實現(xiàn)泉沾。尤其在ES6標準中,清楚的區(qū)分宏觀任務隊列和微觀任務隊列才能解釋Promise一些看似奇怪的表現(xiàn)。
JS引擎本身所做的只不過是在需要的時候垦页,在給定的任意時刻執(zhí)行程序中的單個代碼塊掌唾。JS引擎并不是獨立運行的放前,它運行在宿主環(huán)境:Web瀏覽器忿磅、Node.js、機器人等各種設備中凭语。所有這些環(huán)境都有一個共同點(thread葱她,線程),即它們都提供了一種機制來處理程序中多個塊的執(zhí)行似扔,且執(zhí)行每塊時調用JavaScript引擎吨些,這種機制被稱為事件循環(huán)。
所以說炒辉,JavaScript引擎本身并沒有時間的概念豪墅,只是一個按需執(zhí)行JavaScript任意代碼片段的環(huán)境∏埽“事件”(JavaScript代碼執(zhí)行)調度總是由包含它的環(huán)境進行偶器。
直到ES6,JavaScript才真正內(nèi)建有直接的異步概念缝裤,即ES6從本質上改變了在哪里管理事件循環(huán)屏轰,ES6精確指定了事件循環(huán)的工作細節(jié),這意味著在技術上將其納入JavaScript引擎的勢力范圍憋飞,而不是只由宿主環(huán)境來管理亭枷。
ES6中Promise的引入,這項技術要求對事件循環(huán)隊列的調度運行能夠直接進行精細控制搀崭。
事件循環(huán)
一旦有事件需要運行叨粘,事件循環(huán)就會運行,直到隊列清空瘤睹。事件循環(huán)的每一輪稱為一個tick升敲。用戶交互、IO轰传、定時器會向事件隊列中加入事件驴党。
var eventLoop = []; //用做隊列的數(shù)組,先進先出
var event;
while(true){ //永遠執(zhí)行
if(eventLoop.length > 0){ //一次tick
event = eventLoop.shift(); //拿到隊列中的下一個事件
try {
event();
} catch (err) {
reportError(err);
}
}
}
在瀏覽器的事件循環(huán)中获茬,首先大家要認清楚 3 個角色:函數(shù)調用棧港庄、宏任務(macro-task)隊列 和 微任務(micro-task)隊列。
- 函數(shù)調用棧:當引擎第一次遇到 JS 代碼時恕曲,會產(chǎn)生一個全局執(zhí)行上下文并壓入調用棧鹏氧。后面每遇到一個函數(shù)調用,就會往棧中壓入一個新的函數(shù)上下文佩谣。JS引擎會執(zhí)行棧頂?shù)暮瘮?shù)把还,執(zhí)行完畢后,彈出對應的上下文。
- 任務隊列:對于一些異步的任務吊履,不需要立刻被執(zhí)行安皱,屬于待執(zhí)行的任務,會按照一定規(guī)則排隊艇炎,等待被推入調用棧的時刻到來酌伊。這個隊列就是任務隊列。
Q:為什么需要 Event Loop缀踪?
javascript的一個特點就是單線程腺晾,但是很多時候我們?nèi)匀恍枰诓煌臅r間去執(zhí)行不同的任務,例如給元素添加點擊事件辜贵,設置一個定時器,或者發(fā)起Ajax請求归形。因此需要一個異步機制來達到這樣的目的托慨,事件循環(huán)機制也因此而來。
var a = 1;
var b = 2;
function foo(){
a++;
b = b * a;
a = b + 3;
}
function bar(){
b--;
a = 8 + b;
b = a * 2;
}
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);
由于JavaScript的單線程特性暇榴,foo()以及bar()中的代碼具有原子性厚棵。也就是說,一旦foo()開始運行蔼紧,它的所有代碼都會在bar()中的任意代碼運行之間完成婆硬,或者相反。這稱為完整運行特性奸例。
由于foo()不會被bar()中斷彬犯,bar()也不會被foo()中斷,所以這個程序只有兩個可能的輸出查吊,取決于這兩個函數(shù)哪個先運行谐区。如果存在多線程,且foo()和bar()中的語句可以交替運行的話(并行線程逻卖,共享內(nèi)存)宋列,可能輸出的情況會增加不少。
同一段代碼有兩個可能輸出意味著存在不確定性评也!但是炼杖,這種不確定性是在函數(shù)(事件)順序級別上,而不是多線程情況下的語句順序級別盗迟。因此坤邪,這一確定性要高于多線程情況。
在JavaScript的特性中罚缕,這種函數(shù)順序的不確定性就是通常所說的競態(tài)條件,foo()和bar()相互競爭罩扇,看誰先運行。具體來說,因為無法可靠預測a和b的最終結果喂饥,所以才是競態(tài)條件消约。
??:如果JavaScript中某個函數(shù)由于某種原因不具有完整運行特性,那么可能的結果就會多得多员帮,對吧或粮?實際上,ES6就引入了這么一個東西捞高!
- 異步:是關于現(xiàn)在和將來的時間間隙氯材;(事件循環(huán)把自身的工作分成一個個任務并順序執(zhí)行,不允許對共享內(nèi)存的并行訪問和修改)
JavaScript程序總是至少分為兩個塊:第一塊現(xiàn)在運行硝岗;下一塊將來運行氢哮,以響應某個事件。盡管程序是一塊一塊執(zhí)行的型檀,但是所有這些塊共享對程序作用域和狀態(tài)的訪問冗尤,所以對狀態(tài)的修改都是在之前累積的修改之上進行的。 - 并行:是關于能夠同時發(fā)生的事情胀溺;(并行計算的最常見工具就是進程和線程)并行線程
- 并發(fā):兩個或多個“進程”同時執(zhí)行就出現(xiàn)了并發(fā)裂七,不管組成它們的單個運行是否并行執(zhí)行(在獨立的處理器或處理器核心上同時運行)。并發(fā)可以看作是“進程”級(或任務級)的并行仓坞。
并發(fā)指兩個或多個事件鏈隨時間發(fā)展交替執(zhí)行背零,以至于從更高的層次來看,就像是同時在運行(盡管在任意時刻只處理一個事件)无埃。
單線程事件循環(huán)是并發(fā)的一種形式徙瓶。
任務隊列
在ES6中,有一個新的概念建立在事件循環(huán)隊列之上嫉称,叫作任務隊列倍啥。這個概念給大家?guī)淼淖畲笥绊懣赡苁荘romise的異步特性。
任務隊列澎埠,是掛在事件循環(huán)隊列的每個tick之后的一個隊列虽缕。在事件循環(huán)的每個tick中,可能出現(xiàn)的異步動作不會導致一個完整的新事件添加到事件循環(huán)隊列中蒲稳,而會在當前tick的任務隊列末尾添加一個項目(一個任務)氮趋。
- 事件循環(huán)隊列:玩過了一個游戲之后,需要重新到隊尾排隊才能再玩一次江耀;
- 任務隊列:玩過了游戲之后剩胁,插隊接著繼續(xù)玩;
JavaScript 代碼的執(zhí)行過程中祥国,除了依靠函數(shù)調用棧來搞定函數(shù)的執(zhí)行順序外昵观,還依靠任務隊列(task queue)來搞定另外一些代碼的執(zhí)行:
- 一個線程中晾腔,事件循環(huán)是唯一的,但任務隊列可以擁有多個啊犬;
- 任務隊列又分為 macro-task(宏任務)與 micro-task(微任務)灼擂,在最新標準中,它們被分別稱為 task 與 jobs 觉至;
- 來自不同源的任務會進入到不同的任務隊列剔应,其中 setTimeout 和 setInterval 是同源的,常見的任務隊列如下:
- 事件循環(huán)的順序语御,決定了JavaScript代碼的執(zhí)行順序峻贮。它從script(整體代碼)開始第一次循環(huán)。之后全局上下文進入函數(shù)調用棧应闯。直到調用棧清空(只剩全局)纤控,然后執(zhí)行所有的micro-task。當所有可執(zhí)行的micro-task執(zhí)行完畢之后碉纺。循環(huán)再次從macro-task開始船万,找到其中一個任務隊列執(zhí)行完畢,然后再執(zhí)行所有的micro-task惜辑,這樣一直循環(huán)下去;
- 其中每一個任務的執(zhí)行疫赎,無論是macro-task還是micro-task盛撑,都是借助函數(shù)調用棧來完成;
宏任務 macro-task(task):
- script(整體代碼)
- setTimeout
- setInterval
- setImmediate(僅存在于Node.js環(huán)境中)
- I/O操作
- request / AnimationFrame
- UI render
setTimeout 是宿主環(huán)境提供的API捧搞,其作為一個任務分發(fā)器抵卫,這個函數(shù)會立即執(zhí)行,而它所要分發(fā)但任務胎撇,也就是它的第一個參數(shù)介粘,才是延遲執(zhí)行。
setTimeout 函數(shù)的返回值是一個整數(shù)晚树,返回的是一個 ID號姻采,從1開始。clearTimeout(timeid) 會清除這個定時器爵憎,但不會改版id值慨亲。
當我們在執(zhí)行setTimeout任務中遇到setTimeout時,它仍然會將對應的任務分發(fā)到setTimeout隊列中去宝鼓,但是該任務就得等到下一輪事件循環(huán)執(zhí)行了刑棵。
微任務 micro-task(job):
- process.nextTick(僅存在于Node11之后 是微任務的一種)
- MutationObserver(html5新特性)
- Object.observe(已廢棄)
- Promise
- Async/Await(實際上就是promise)
nextTick 隊列會比 Promise 先執(zhí)行。nextTick中的可執(zhí)行任務執(zhí)行完畢之后愚铡,才會開始執(zhí)行Promise隊列中的任務蛉签。
Node.js VS 瀏覽器環(huán)境中的事件循環(huán)機制
- 瀏覽器中有事件循環(huán),Node.js 中也有。事件循環(huán)是Node處理非阻塞I/O操作的機制碍舍,Node.js 中事件循環(huán)的實現(xiàn)是依靠的libuv引擎柠座。由于 Node.js 11 之后,事件循環(huán)的一些原理發(fā)生了變化乒验,因此 Node11事件循環(huán)已與瀏覽器事件循環(huán)機制趨同愚隧。
- chrome瀏覽器中新標準中的事件循環(huán)機制與 Node.js 類似,都有宏任務和微任務之分锻全。但是有些API只有 Node.js 中有狂塘,而瀏覽器中沒有,比如
process.nextTick
及setImmediate
鳄厌。 - 瀏覽器中的微任務是在每個相應的宏任務中執(zhí)行的荞胡。而 Node.js 中的微任務是在不同階段之間執(zhí)行的。
- 瀏覽器的 Event-Loop 由各個瀏覽器自己實現(xiàn)了嚎;而 Node 的 Event-Loop 由 libuv 來實現(xiàn)泪漂。
- 在瀏覽器中,只有一個微任務隊列需要接受處理歪泳;在Node中萝勤,有兩類微任務隊列:next-tick隊列和其他隊列。 其中這個 next-tick 隊列呐伞,專門用來收斂 process.nextTick 派發(fā)的異步任務敌卓。在清空隊列時,優(yōu)先清空 next-tick 隊列中的任務伶氢,隨后才會清空其它微任務趟径。
- 在瀏覽器中,我們每次出隊并執(zhí)行一個宏任務癣防;而在 Node 中蜗巧,我們每次會嘗試清空當前階段對應宏任務隊列里的所有任務(除非達到了系統(tǒng)限制);
Node.js 技術架構
Node整體上由這三部分組成:
- 應用層:這一層就是大家最熟悉的 Node.js 代碼蕾盯,包括 Node 應用以及一些標準庫幕屹。
- 橋接層:Node 底層是用 C++ 來實現(xiàn)的。橋接層負責封裝底層依賴的 C++ 模塊的能力级遭,將其簡化為 API 向應用層提供服務香嗓。
- 底層依賴:這里就是最最底層的 C++ 庫了,支撐 Node 運行的最基本能力在此匯聚装畅。其中需要特別引起大家注意的就是 V8 和 libuv:
- V8 是 JS 的運行引擎靠娱,它負責把 JavaScript 代碼轉換成 C++,然后去跑這層 C++ 代碼掠兄。
- libuv:它對跨平臺的異步I/O能力進行封裝像云,同時也是我們本節(jié)的主角:Node 中的事件循環(huán)就是由 libuv 來初始化的锌雀。
瀏覽器的 Event-Loop 由各個瀏覽器自己實現(xiàn);而 Node 的 Event-Loop 由 libuv 來實現(xiàn)迅诬。
libuv 中 Event-Loop 實現(xiàn)
libuv 主導循環(huán)機制共有六個循環(huán)階段:
- timers階段:執(zhí)行 setTimeout 和 setInterval 中定義的回調腋逆;
- pending callbacks:直譯過來是“被掛起的回調”,如果網(wǎng)絡I/O或者文件I/O的過程中出現(xiàn)了錯誤侈贷,就會在這個階段處理錯誤的回調(比較少見惩歉,可以略過);
- idle, prepare:僅系統(tǒng)內(nèi)部使用俏蛮。這個階段我們開發(fā)者不需要操心撑蚌。(可以略過);
- poll (輪詢階段):重點階段搏屑,這個階段會執(zhí)行I/O回調争涌,同時還會檢查定時器是否到期;在 poll 階段處理的回調中辣恋,如果既派發(fā)了 setImmediate亮垫、又派發(fā)了 setTimeout,那么這個順序是板上釘釘?shù)摹欢ㄊ窍葓?zhí)行 setImmediate伟骨,再執(zhí)行 setTimeout饮潦。
- check(檢查階段):處理 setImmediate 中定義的回調;
- close callbacks:處理一些“關閉”的回調携狭,比如
socket.on('close', ...)
就會在這個階段被觸發(fā)继蜡。
Node11前后的變化
setTimeout(() => {
console.log('timeout1');
}, 0);
setTimeout(() => {
console.log('timeout2');
Promise.resolve().then(function() {
console.log('promise1');
});
}, 0);
setTimeout(() => {
console.log('timeout3')
}, 0)
Node11開始,timers 階段的setTimeout暑中、setInterval等函數(shù)派發(fā)的任務壹瘟、包括 setImmediate 派發(fā)的任務鲫剿,都被修改為:一旦執(zhí)行完當前階段的一個任務鳄逾,就立刻執(zhí)行微任務隊列。
上述代碼輸出結果:
- Node v9.3.0:
timeout1 timeout2 timeout3 promise1
在 timers 階段灵莲,依次執(zhí)行了所有的 setTimeout 回調雕凹、清空了隊列。 - Node v12.4.1:
timeout1 timeout2 promise1 timeout3
- 瀏覽器:
timeout1 timeout2 promise1 timeout3
測試一下
例1:
console.log('script start');
async function async1(){
await async2();
console.log('async1 end');
}
async function async2(){
console.log('async2 end');
}
async1();
setTimeout(function(){
console.log('setTimeout')
}, 0);
new Promise(resolve => {
console.log('Promise');
resolve();
}).then(function(){
console.log('promise1');
}).then(function(){
console.log('promise2');
})
console.log('script end');
// script start => async2 end => Promise => script end => async1 end => promise1 => promise2 => setTimeout
- 首先政冻,事件循環(huán)從宏任務隊列開始枚抵,此時宏任務隊列中只有一個script(整體代碼)任務。所以執(zhí)行代碼明场,輸出
script start
汽摹; - 執(zhí)行 async1(),會調用 async2()苦锨,輸出
async2 end
逼泣。此時會保留 async1 函數(shù)的上下文趴泌,將await后面的代碼注冊為一個微任務,然后跳出 async1 函數(shù)拉庶; - 遇到 setTimeout嗜憔,產(chǎn)生一個宏任務;
- 遇到Promise實例氏仗,Promise構造函數(shù)中的第一個參數(shù)吉捶,是在new的時候執(zhí)行,因此不會進入任何其他的隊列皆尔,而是直接在當前任務直接執(zhí)行了呐舔,輸出
Promise
,后續(xù)的 .then 產(chǎn)生第二個微任務床佳,會被分發(fā)到 micro-task 的Promise隊列中滋早; - 繼續(xù)執(zhí)行代碼,輸出
script end
砌们; - 代碼邏輯執(zhí)行完成(當前宏任務執(zhí)行完畢)杆麸,開始執(zhí)行當前宏任務產(chǎn)生的微任務隊列,輸出
async1 end
浪感,繼續(xù)執(zhí)行下一個微任務輸出promise1
昔头,該微任務遇到 then,產(chǎn)生一個新的微任務影兽; - 執(zhí)行產(chǎn)生的微任務揭斧,輸出
promise2
,當前微任務隊列執(zhí)行完畢峻堰; - 執(zhí)行下一個宏任務讹开,輸出
setTimeout
;
例2:
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
return Promise.resolve().then(()=>{
console.log('async2 end1')
})
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
// script start => async2 end => Promise => script end => async2 end1 => promise1 => promise2 => async1 end => setTimeout
此時執(zhí)行完awit并不先把await后面的代碼注冊到微任務隊列中去捐名,而是執(zhí)行完await之后旦万,直接跳出async1函數(shù),執(zhí)行其他代碼镶蹋。然后遇到promise的時候成艘,把promise.then注冊為微任務。