Event Loop

一、為什么JavaScript是單線程?js引擎線程

與它的用途有關。作為瀏覽器腳本語言,JavaScript的主要用途是與用戶互動,以及操作DOM。這決定了它只能是單線程,否則會帶來很復雜的同步問題。比如,假定JavaScript同時有兩個線程,一個線程在某個DOM節(jié)點上添加內容,另一個線程刪除了這個節(jié)點,這時瀏覽器應該以哪個線程為準?
為了利用多核CPU的計算能力,HTML5提出Web Worker標準,允許JavaScript腳本創(chuàng)建多個線程,但是子線程完全受主線程控制,且不得操作DOM。所以,這個新標準并沒有改變JavaScript單線程的本質

二、任務隊列

背景: 單線程就意味著,所有任務需要排隊,前一個任務結束,才會執(zhí)行后一個任務。如果排隊是因為計算量大,CPU忙不過來,倒也算了,但是很多時候CPU是閑著的,因為IO設備(輸入輸出設備)很慢(比如Ajax操作從網絡讀取數據),不得不等著結果出來,再往下執(zhí)行。JavaScript語言的設計者意識到,這時主線程完全可以不管IO設備,掛起處于等待中的任務,先運行排在后面的任務。等到IO設備返回了結果,再回過頭,把掛起的任務繼續(xù)執(zhí)行下去。
同步任務和異步任務:所有任務可以分成兩種,一種是同步任務(synchronous),另一種是異步任務(asynchronous)。同步任務指的是,在主線程上排隊執(zhí)行的任務,只有前一個任務執(zhí)行完畢,才能執(zhí)行后一個任務;異步任務指的是,不進入主線程、而進入"任務隊列"(task queue)的任務,只有"任務隊列"通知主線程,某個異步任務可以執(zhí)行了,該任務才會進入主線程執(zhí)行。
異步運行機制:
所有同步任務都在主線程上執(zhí)行,形成一個執(zhí)行棧(execution context stack)。
主線程之外,還存在一個"任務隊列"(task queue)。只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件。
一旦"執(zhí)行棧"中的所有同步任務執(zhí)行完畢,系統就會讀取"任務隊列",看看里面有哪些事件。那些對應的異步任務,于是結束等待狀態(tài),進入執(zhí)行棧,開始執(zhí)行。
主線程不斷重復上面的第三步。

三、事件和回調函數

"任務隊列"是一個事件的隊列(也可以理解成消息的隊列),IO設備完成一項任務,就在"任務隊列"中添加一個事件,表示相關的異步任務可以進入"執(zhí)行棧"了。主線程讀取"任務隊列",就是讀取里面有哪些事件。
"任務隊列"中的事件,除了IO設備的事件以外,還包括一些用戶產生的事件(比如鼠標點擊、頁面滾動等等)。只要指定過回調函數,這些事件發(fā)生時就會進入"任務隊列",等待主線程讀取。
所謂"回調函數"(callback),就是那些會被主線程掛起來的代碼。異步任務必須指定回調函數,當主線程開始執(zhí)行異步任務,就是執(zhí)行對應的回調函數。
"任務隊列"是一個先進先出的數據結構,排在前面的事件,優(yōu)先被主線程讀取。主線程的讀取過程基本上是自動的,只要執(zhí)行棧一清空,"任務隊列"上第一位的事件就自動進入主線程。但是,由于存在后文提到的"定時器"功能,主線程首先要檢查一下執(zhí)行時間,某些事件只有到了規(guī)定的時間,才能返回主線程

四、Event Loop

定義:主線程從"任務隊列"中讀取事件,這個過程是循環(huán)不斷的,所以整個的這種運行機制又稱為Event Loop(事件循環(huán))

主線程運行的時候,產生堆(heap)和棧(stack),棧中的代碼調用各種外部API,它們在"任務隊列"中加入各種事件(click,load,done)。只要棧中的代碼執(zhí)行完畢,主線程就會去讀取"任務隊列",依次執(zhí)行那些事件所對應的回調函數。

五、定時器

除了放置異步任務的事件,"任務隊列"還可以放置定時事件,即指定某些代碼在多少時間之后執(zhí)行。這叫做"定時器"(timer)功能,也就是定時執(zhí)行的代碼。

總之,setTimeout(fn,0)的含義是,指定某個任務在主線程最早可得的空閑時間執(zhí)行,也就是說,盡可能早得執(zhí)行。它在"任務隊列"的尾部添加一個事件,因此要等到同步任務和"任務隊列"現有的事件都處理完,才會得到執(zhí)行。

六、Node.js的Event Loop

V8引擎解析JavaScript腳本。
解析后的代碼,調用Node API。
libuv庫負責Node API的執(zhí)行。它將不同的任務分配給不同的線程,形成一個Event Loop(事件循環(huán)),以異步的方式將任務的執(zhí)行結果返回給V8引擎。
V8引擎再將結果返回給用戶。

除了setTimeout和setInterval這兩個方法,Node.js還提供了另外兩個與"任務隊列"有關的方法:process.nextTick和setImmediate。它們可以幫助我們加深對"任務隊列"的理解。
process.nextTick方法可以在當前"執(zhí)行棧"的尾部----下一次Event Loop(主線程讀取"任務隊列")之前----觸發(fā)回調函數。也就是說,它指定的任務總是發(fā)生在所有異步任務之前。setImmediate方法則是在當前"任務隊列"的尾部添加事件,也就是說,它指定的任務總是在下一次Event Loop時執(zhí)行,這與setTimeout(fn, 0)很像。請看下面的例子(via StackOverflow)。

process.nextTick(function A() {
  console.log(1);
  process.nextTick(function B(){console.log(2);});
});

setTimeout(function timeout() {
  console.log('TIMEOUT FIRED');
}, 0)
// 1
// 2
// TIMEOUT FIRED

上面代碼中,由于process.nextTick方法指定的回調函數,總是在當前"執(zhí)行棧"的尾部觸發(fā),所以不僅函數A比setTimeout指定的回調函數timeout先執(zhí)行,而且函數B也比timeout先執(zhí)行。這說明,如果有多個process.nextTick語句(不管它們是否嵌套),將全部在當前"執(zhí)行棧"執(zhí)行。
現在,再看setImmediate。

setImmediate(function A() {
  console.log(1);
  setImmediate(function B(){console.log(2);});
});

setTimeout(function timeout() {
  console.log('TIMEOUT FIRED');
}, 0);

上面代碼中,setImmediate與setTimeout(fn,0)各自添加了一個回調函數A和timeout,都是在下一次Event Loop觸發(fā)。那么,哪個回調函數先執(zhí)行呢?答案是不確定。運行結果可能是1--TIMEOUT FIRED--2,也可能是TIMEOUT FIRED--1--2。
令人困惑的是,Node.js文檔中稱,setImmediate指定的回調函數,總是排在setTimeout前面。實際上,這種情況只發(fā)生在遞歸調用的時候。

setImmediate(function (){
  setImmediate(function A() {
    console.log(1);
    setImmediate(function B(){console.log(2);});
  });

  setTimeout(function timeout() {
    console.log('TIMEOUT FIRED');
  }, 0);
});
// 1
// TIMEOUT FIRED
// 2

上面代碼中,setImmediate和setTimeout被封裝在一個setImmediate里面,它的運行結果總是1--TIMEOUT FIRED--2,這時函數A一定在timeout前面觸發(fā)。至于2排在TIMEOUT FIRED的后面(即函數B在timeout后面觸發(fā)),是因為setImmediate總是將事件注冊到下一輪Event Loop,所以函數A和timeout是在同一輪Loop執(zhí)行,而函數B在下一輪Loop執(zhí)行。
我們由此得到了process.nextTick和setImmediate的一個重要區(qū)別:多個process.nextTick語句總是在當前"執(zhí)行棧"一次執(zhí)行完,多個setImmediate可能則需要多次loop才能執(zhí)行完。事實上,這正是Node.js 10.0版添加setImmediate方法的原因,否則像下面這樣的遞歸調用process.nextTick,將會沒完沒了,主線程根本不會去讀取"事件隊列"!

process.nextTick(function foo() {
  process.nextTick(foo);
});

事實上,現在要是你寫出遞歸的process.nextTick,Node.js會拋出一個警告,要求你改成setImmediate。
另外,由于process.nextTick指定的回調函數是在本次"事件循環(huán)"觸發(fā),而setImmediate指定的是在下次"事件循環(huán)"觸發(fā),所以很顯然,前者總是比后者發(fā)生得早,而且執(zhí)行效率也高(因為不用檢查"任務隊列")。

七、宏任務與微任務

宏任務(macrotask)
微任務(microtask)
誰發(fā)起的
宿主(Node、瀏覽器)
JS引擎
具體事件

  1. script (可以理解為外層同步代碼)
  2. setTimeout/setInterval
  3. UI rendering/UI事件
  4. postMessage,MessageChannel
  5. setImmediate,I/O(Node.js)
  6. Promise
  7. MutaionObserver
  8. Object.observe(已廢棄;Proxy 對象替代)
  9. process.nextTick(Node.js)
    誰先運行
    后運行
    先運行
    會觸發(fā)新一輪Tick嗎

    不會

拓展 1:async和await是如何處理異步任務的?

簡單說,async是通過Promise包裝異步任務。

比如有如下代碼:

async function async1() {
 await async2()
 console.log('async1 end')
}
async function async2() {
 console.log('async2 end')
}
async1()

改為ES5的寫法:

new Promise((resolve, reject) => {
 // console.log('async2 end')
 async2() 
 ...
}).then(() => {
// 執(zhí)行async1()函數await之后的語句
 console.log('async1 end')
})

當調用 async1 函數時,會馬上輸出 async2 end,并且函數返回一個 Promise,接下來在遇到 await的時候會就讓出線程開始執(zhí)行 async1 外的代碼(可以把 await 看成是讓出線程的標志)。
然后當同步代碼全部執(zhí)行完畢以后,就會去執(zhí)行所有的異步代碼,那么又會回到 await 的位置,去執(zhí)行 then 中的回調。

拓展 2:setTimeout,setImmediate誰先執(zhí)行?

setImmediate和process.nextTick為Node環(huán)境下常用的方法(IE11支持setImmediate),所以,后續(xù)的分析都基于Node宿主。

Node.js是運行在服務端的js,雖然用到也是V8引擎,但由于服務目的和環(huán)境不同,導致了它的API與原生JS有些區(qū)別,其Event Loop還要處理一些I/O,比如新的網絡連接等,所以與瀏覽器Event Loop不太一樣。

執(zhí)行順序如下:

timers: 執(zhí)行setTimeout和setInterval的回調
pending callbacks: 執(zhí)行延遲到下一個循環(huán)迭代的 I/O 回調
idle, prepare: 僅系統內部使用
poll: 檢索新的 I/O 事件;執(zhí)行與 I/O 相關的回調。事實上除了其他幾個階段處理的事情,其他幾乎所有的異步都在這個階段處理。
check: setImmediate在這里執(zhí)行
close callbacks: 一些關閉的回調函數,如:socket.on('close', ...)

一般來說,setImmediate會在setTimeout之前執(zhí)行,如下:

console.log('outer');
setTimeout(() => {
 setTimeout(() => {
   console.log('setTimeout');
 }, 0);
 setImmediate(() => {
   console.log('setImmediate');
 });
}, 0);

其執(zhí)行順序為:

外層是一個setTimeout,所以執(zhí)行它的回調的時候已經在timers階段了
處理里面的setTimeout,因為本次循環(huán)的timers正在執(zhí)行,所以其回調其實加到了下個timers階段
處理里面的setImmediate,將它的回調加入check階段的隊列
外層timers階段執(zhí)行完,進入pending callbacks,idle, prepare,poll,這幾個隊列都是空的,所以繼續(xù)往下
到了check階段,發(fā)現了setImmediate的回調,拿出來執(zhí)行
然后是close callbacks,隊列是空的,跳過
又是timers階段,執(zhí)行console.log('setTimeout')

但是,如果當前執(zhí)行環(huán)境不是timers階段,就不一定了。。。。順便科普一下Node里面對setTimeout的特殊處理:setTimeout(fn, 0)會被強制改為setTimeout(fn, 1)。

看看下面的例子:

setTimeout(() => {
 console.log('setTimeout');
}, 0);

setImmediate(() => {
 console.log('setImmediate');
});

其執(zhí)行順序為:

遇到setTimeout,雖然設置的是0毫秒觸發(fā),但是被node.js強制改為1毫秒,塞入times階段
遇到setImmediate塞入check階段
同步代碼執(zhí)行完畢,進入Event Loop
先進入times階段,檢查當前時間過去了1毫秒沒有,如果過了1毫秒,滿足setTimeout條件,執(zhí)行回調,如果沒過1毫秒,跳過
跳過空的階段,進入check階段,執(zhí)行setImmediate回調

可見,1毫秒是個關鍵點,所以在上面的例子中,setImmediate不一定在setTimeout之前執(zhí)行了。

拓展 3:Promise,process.nextTick誰先執(zhí)行?

因為process.nextTick為Node環(huán)境下的方法,所以后續(xù)的分析依舊基于Node。

process.nextTick() 是一個特殊的異步API,其不屬于任何的Event Loop階段。事實上Node在遇到這個API時,Event Loop根本就不會繼續(xù)進行,會馬上停下來執(zhí)行process.nextTick(),這個執(zhí)行完后才會繼續(xù)Event Loop。

所以,nextTick和Promise同時出現時,肯定是nextTick先執(zhí)行,原因是nextTick的隊列比Promise隊列優(yōu)先級更高。

拓展 4:應用場景 - Vue中的vm.$nextTick

vm.$nextTick 接受一個回調函數作為參數,用于將回調延遲到下次DOM更新周期之后執(zhí)行。

這個API就是基于事件循環(huán)實現的。
“下次DOM更新周期”的意思就是下次微任務執(zhí)行時更新DOM,而vm.$nextTick就是將回調函數添加到微任務中(在特殊情況下會降級為宏任務)。

因為微任務優(yōu)先級太高,Vue 2.4版本之后,提供了強制使用宏任務的方法。

vm.$nextTick優(yōu)先使用Promise,創(chuàng)建微任務。
如果不支持Promise或者強制開啟宏任務,那么,會按照如下順序發(fā)起宏任務:

優(yōu)先檢測是否支持原生 setImmediate(這是一個高版本 IE 和 Edge 才支持的特性)
如果不支持,再去檢測是否支持原生的MessageChannel
如果也不支持的話就會降級為 setTimeout。

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

相關閱讀更多精彩內容

友情鏈接更多精彩內容