一、javascript從誕生之日起就是一門單線程的非阻塞的腳本語言
提出 web worker技術, 開的多線程有著諸多限制,例如:所有新線程都受主線程的完全控制,不能獨立執行。這意味著這些“線程” 實際上應屬于主線程的子線程。另外,這些子線程并沒有執行I/O操作的權限,只能為主線程分擔一些諸如計算等任務。所以嚴格來講這些線程并沒有完整的功能,也因此這項技術并非改變了javascript語言的單線程本質。
瀏覽器環境下js引擎的事件循環機制
1.執行棧與事件隊列
當javascript代碼執行的時候會將不同的變量存于內存中的不同位置:堆(heap)和棧(stack)中來加以區分。其中,堆里存放著一些對象。而棧中則存放著一些基礎類型變量以及對象的指針。 但是我們這里說的執行棧和上面這個棧的意義卻有些不同。
2.macro task與micro task
以上的事件循環過程是一個宏觀的表述,實際上因為異步任務之間并不相同,因此他們的執行優先級也有區別。不同的異步任務被分為兩類:微任務(micro task)和宏任務(macro task)。
以下事件屬于宏任務:
setInterval()
setTimeout()
以下事件屬于微任務
new Promise()
new MutaionObserver()
前面我們介紹過,在一個事件循環中,異步事件返回結果后會被放到一個任務隊列中。然而,根據這個異步事件的類型,這個事件實際上會被對應的宏任務隊列或者微任務隊列中去。并且在當前執行棧為空的時候,主線程會 查看微任務隊列是否有事件存在。如果不存在,那么再去宏任務隊列中取出一個事件并把對應的回到加入當前執行棧;如果存在,則會依次執行隊列中事件對應的回調,直到微任務隊列為空,然后去宏任務隊列中取出最前面的一個事件,把對應的回調加入當前執行棧...如此反復,進入循環。
我們只需記住當當前執行棧執行完畢時會立刻先處理所有微任務隊列中的事件,然后再去宏任務隊列中取出一個事件。同一次事件循環中,微任務永遠在宏任務之前執行。
這樣就能解釋下面這段代碼的結果:
setTimeout(function () {
console.log(1);
});
new Promise(function(resolve,reject){
console.log(2)
resolve(3)
}).then(function(val){
console.log(val);
})
結果為:
2
3
1
node環境下的事件循環機制
1.與瀏覽器環境有何不同?
在node中,事件循環表現出的狀態與瀏覽器中大致相同。不同的是node中有一套自己的模型。node中事件循環的實現是依靠的libuv引擎。我們知道node選擇chrome v8引擎作為js解釋器,v8引擎將js代碼分析后去調用對應的node api,而這些api最后則由libuv引擎驅動,執行對應的任務,并把不同的事件放在不同的隊列中等待主線程執行。 因此實際上node中的事件循環存在于libuv引擎中。
2.事件循環模型
下面是一個libuv引擎中的事件循環的模型:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<──connections─── │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
注:模型中的每一個方塊代表事件循環的一個階段
3.事件循環各階段詳解
從上面這個模型中,我們可以大致分析出node中的事件循環的順序:
外部輸入數據-->輪詢階段(poll)-->檢查階段(check)-->關閉事件回調階段(close callback)-->定時器檢測階段(timer)-->I/O事件回調階段(I/O callbacks)-->閑置階段(idle, prepare)-->輪詢階段...
以上各階段的名稱是根據我個人理解的翻譯,為了避免錯誤和歧義,下面解釋的時候會用英文來表示這些階段。
這些階段大致的功能如下:
timers: 這個階段執行定時器隊列中的回調如 setTimeout() 和 setInterval()。
I/O callbacks: 這個階段執行幾乎所有的回調。但是不包括close事件,定時器和setImmediate()的回調。
idle, prepare: 這個階段僅在內部使用,可以不必理會。
poll: 等待新的I/O事件,node在一些特殊情況下會阻塞在這里。
check: setImmediate()的回調會在這個階段執行。
close callbacks: 例如socket.on('close', ...)這種close事件的回調。
下面我們來按照代碼第一次進入libuv引擎后的順序來詳細解說這些階段:
poll階段
當個v8引擎將js代碼解析后傳入libuv引擎后,循環首先進入poll階段。poll階段的執行邏輯如下: 先查看poll queue中是否有事件,有任務就按先進先出的順序依次執行回調。 當queue為空時,會檢查是否有setImmediate()的callback,如果有就進入check階段執行這些callback。但同時也會檢查是否有到期的timer,如果有,就把這些到期的timer的callback按照調用順序放到timer queue中,之后循環會進入timer階段執行queue中的 callback。 這兩者的順序是不固定的,收到代碼運行的環境的影響。如果兩者的queue都是空的,那么loop會在poll階段停留,直到有一個i/o事件返回,循環會進入i/o callback階段并立即執行這個事件的callback。
值得注意的是,poll階段在執行poll queue中的回調時實際上不會無限的執行下去。有兩種情況poll階段會終止執行poll queue中的下一個回調:1.所有回調執行完畢。2.執行數超過了node的限制。
check階段
check階段專門用來執行setImmediate()方法的回調,當poll階段進入空閑狀態,并且setImmediate queue中有callback時,事件循環進入這個階段。
close階段
當一個socket連接或者一個handle被突然關閉時(例如調用了socket.destroy()方法),close事件會被發送到這個階段執行回調。否則事件會用process.nextTick()方法發送出去。
timer階段
這個階段以先進先出的方式執行所有到期的timer加入timer隊列里的callback,一個timer callback指得是一個通過setTimeout或者setInterval函數設置的回調函數。
I/O callback階段
如上文所言,這個階段主要執行大部分I/O事件的回調,包括一些為操作系統執行的回調。例如一個TCP連接生錯誤時,系統需要執行回調來獲得這個錯誤的報告。
4.process.nextTick,setTimeout與setImmediate的區別與使用場景
在node中有三個常用的用來推遲任務執行的方法:process.nextTick,setTimeout(setInterval與之相同)與setImmediate
這三者間存在著一些非常不同的區別:
process.nextTick()
盡管沒有提及,但是實際上node中存在著一個特殊的隊列,即nextTick queue。這個隊列中的回調執行雖然沒有被表示為一個階段,當時這些事件卻會在每一個階段執行完畢準備進入下一個階段時優先執行。當事件循環準備進入下一個階段之前,會先檢查nextTick queue中是否有任務,如果有,那么會先清空這個隊列。與執行poll queue中的任務不同的是,這個操作在隊列清空前是不會停止的。這也就意味著,錯誤的使用process.nextTick()方法會導致node進入一個死循環。。直到內存泄漏。
那么合適使用這個方法比較合適呢?下面有一個例子:
const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {});
這個例子中當,當listen方法被調用時,除非端口被占用,否則會立刻綁定在對應的端口上。這意味著此時這個端口可以立刻觸發listening事件并執行其回調。然而,這時候on('listening)還沒有將callback設置好,自然沒有callback可以執行。為了避免出現這種情況,node會在listen事件中使用process.nextTick()方法,確保事件在回調函數綁定后被觸發。
setTimeout()和setImmediate()
在三個方法中,這兩個方法最容易被弄混。實際上,某些情況下這兩個方法的表現也非常相似。然而實際上,這兩個方法的意義卻大為不同。
setTimeout()方法是定義一個回調,并且希望這個回調在我們所指定的時間間隔后第一時間去執行。注意這個“第一時間執行”,這意味著,受到操作系統和當前執行任務的諸多影響,該回調并不會在我們預期的時間間隔后精準的執行。執行的時間存在一定的延遲和誤差,這是不可避免的。node會在可以執行timer回調的第一時間去執行你所設定的任務。
setImmediate()方法從意義上將是立刻執行的意思,但是實際上它卻是在一個固定的階段才會執行回調,即poll階段之后。有趣的是,這個名字的意義和之前提到過的process.nextTick()方法才是最匹配的。node的開發者們也清楚這兩個方法的命名上存在一定的混淆,他們表示不會把這兩個方法的名字調換過來---因為有大量的node程序使用著這兩個方法,調換命名所帶來的好處與它的影響相比不值一提。
setTimeout()和不設置時間間隔的setImmediate()表現上及其相似。猜猜下面這段代碼的結果是什么?
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
實際上,答案是不一定。沒錯,就連node的開發者都無法準確的判斷這兩者的順序誰前誰后。這取決于這段代碼的運行環境。運行環境中的各種復雜的情況會導致在同步隊列里兩個方法的順序隨機決定。但是,在一種情況下可以準確判斷兩個方法回調的執行順序,那就是在一個I/O事件的回調中。下面這段代碼的順序永遠是固定的:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
答案永遠是:
immediate
timeout
因為在I/O事件的回調中,setImmediate方法的回調永遠在timer的回調前執行。
JS 微任務和宏任務
宏任務:script(全局任務), setTimeout, setInterval, setImmediate, I/O, UI rendering.
微任務:process.nextTick (node.js中進程相關的對象), Promise, Object.observer, MutationObserver。
宏任務(task):就是JS 內部(任務隊列里)的任務,嚴格按照時間順序壓棧和執行。如 setTimeOut、setInverter、setImmediate 、 MessageChannel等
微任務(Microtask ):通常來說就是需要在當前 任務 執行結束后立即執行的任務,例如需要對一系列的任務做出回應,或者是需要異步的執行任務而又不需要分配一個新的 任務 ,這樣便可以減小一點性能的開銷。
在掛起任務時,JS 引擎會將所有任務按照類別分到這兩個隊列中,首先在 macrotask 的隊列(這個隊列也被叫做 task queue)中取出第一個任務,執行完畢后取出 microtask 隊列中的所有任務順序執行;之后再取 macrotask 任務,周而復始,直至兩個隊列的任務都取完。
運行機制:
在執行棧中執行一個宏任務。
執行過程中遇到微任務,將微任務添加到微任務隊列中。
當前宏任務執行完畢,立即執行微任務隊列中的任務。
當前微任務隊列中的任務執行完畢,檢查渲染,GUI線程接管渲染。
渲染完畢后,js線程接管,開啟下一次事件循環,執行下一次宏任務(事件隊列中取)。
看一段代碼:
首先瀏覽器執行js進入第一個宏任務進入主線程, 直接打印console.log(‘1’)
遇到 setTimeout 分發到宏任務Event Queue中
遇到 process.nextTick 丟到微任務Event Queue中
遇到 Promise, new Promise 直接執行 輸出 console.log(‘7’);
執行then 被分發到微任務Event Queue中``
第一輪宏任務執行結束,開始執行微任務 打印 6,8
第一輪微任務執行完畢,執行第二輪宏事件,執行setTimeout
先執行主線程宏任務,在執行微任務,打印’2,4,3,5’
在執行第二個setTimeout,同理打印 ‘9,11,10,12’
整段代碼,共進行了三次事件循環,完整的輸出為1,7,6,8,2,4,3,5,9,11,10,12。
注意事項:
先是宏任務–>微任務–>宏任務–>微任務一直循環下去;
script代碼為第一層宏任務,如果有setTimeout,setInterval,則他們的回調函數會成為第二層的宏任務,
promise.then()和process.nextTick()是微任務,在執行完該一層的宏任務后執行,且process.nextTick()優先于promise.then();
小結
macrotask(按優先級順序排列): script(你的全部JS代碼,“同步代碼”), setTimeout, setInterval, setImmediate, I/O,UI rendering
microtask(按優先級順序排列):process.nextTick,Promises(這里指瀏覽器原生實現的 Promise), Object.observe, MutationObserver
JS引擎首先從macrotask queue中取出第一個任務,執行完畢后,將microtask queue中的所有任務取出,按順序全部執行;
然后再從macrotask queue(宏任務隊列)中取下一個,執行完畢后,再次將microtask queue(微任務隊列)中的全部取出;
循環往復,直到兩個queue中的任務都取完。
提別強調:
隊列的優先級執行順序為: 先執行同步和立即執行任務>microtask>macrotask
相關原文鏈接:
https://blog.csdn.net/qq_44624386/article/details/107344664
https://zhuanlan.zhihu.com/p/33058983