在我看來理解好JS引擎的執行機制對於理解JS引擎至關重要,今天將要好好梳理下JS引擎的執行機制。
首先解釋下題目中的名詞:(閱讀本文后你會對這些概念掌握了解)
Event Loop:事件循環
Micro Task:微任務
Macro Task:宏任務
閱讀本文前,我們要知道兩個重點。
(1) JS是單線程語言
(2) JS的Event Loop是JS的執行機制。深入了解JS的執行,就等於深入了解JS里的event loop
一、JS為什么是單線程語言。
javascript是一門單線程語言,在最新的HTML5中提出了Web-Worker,但javascript是單線程這一核心仍未改變。與它的用途有關。作為瀏覽器腳本語言,JavaScript的主要用途是與用戶互動,以及操作DOM。這決定了它只能是單線程,否則會帶來很復雜的同步問題。比如,假定JavaScript同時有兩個線程,一個線程在某個DOM節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程為准?
所以,為了避免復雜性,從一誕生,JavaScript就是單線程,這已經成了這門語言的核心特征,將來也不會改變。
二、什么是任務隊列?
2.1 同步任務與異步任務
單線程就意味着,所有任務需要排隊。所有任務可以分成兩種,一種是同步任務(synchronous),另一種是異步任務(asynchronous)。同步任務指的是,在主線程上排隊執行的任務,只有前一個任務執行完畢,才能執行后一個任務;異步任務指的是,不進入主線程、而進入"任務隊列"(task queue)的任務,只有"任務隊列"通知主線程,某個異步任務可以執行了,該任務才會進入主線程執行。異步執行的運行機制如下:
(1)所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)。
(2)主線程之外,還存在一個"任務隊列"(task queue)。只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件。
(3)一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務隊列",看看里面有哪些事件。那些對應的異步任務,於是結束等待狀態,進入執行棧,開始執行。
(4)主線程不斷重復上面的第三步。
2.2 JS引擎執行模型
從宏觀角度講, js 的執行是單線程的. 所有的異步結果都是通過 “任務隊列(Task Queue)” 來調度被調度. 消息隊列中存放的是一個個的任務(Task). 規范中規定, Task 分為兩大類, 分別是 Macro Task(宏任務) 和 Micro Task(微任務), 並且每個 Macro Task 結束后, 都要清空所有的 Micro Task.
宏觀上講, Macrotask 會進入 Macro Task Queue, Microtask 會進入 Micro Task Queue。而 Micro Task 被分到了兩個隊列中. ‘Micro Task Queue’ 存放 Promise
等 Microtask. 而 ‘Tick Task Queue’ 專門用於存放 process.nextTick
的任務.現在先來看看規范怎么做的分類.
- Macrotask 包括:
- setTimeout
- setInterval
- setImmediate
- I/O
- UI rendering
- Microtask 包括:
process.nextTick
Promise
Object.observe
MutaionObserver
所說的, ‘每個 MacroTask 結束后, 都要清空所有的 Micro Task‘. 引擎會遍歷 Macro Task Queue, 對於每個 MacroTask 執行完畢后都要遍歷執行 Tick Task Queue 的所有任務, 緊接着再遍歷 MicroTask Queue 的所有任務. (nextTick
會優於 Promise
執行)
三、Event Loop
主線程從"任務隊列"中讀取事件,這個過程是循環不斷的,所以整個的這種運行機制又稱為Event Loop(事件循環)。
修改:下面的圖有錯誤,process.nextTick是添加到當前"執行棧"的尾部,事實上並不存在所謂的TickTask Queue,只有MacroTask Queue和Micro Task Queue,圖里的TickTask Queue僅僅是為了更好理解,或者我們理解其為當前"執行棧"的尾部。
三種任務隊列中的代碼執行流程圖如下:
來個例子檢驗一下吧:
console.log('main1'); process.nextTick(function() { console.log('process.nextTick1'); }); setTimeout(function() { console.log('setTimeout'); process.nextTick(function() { console.log('process.nextTick2'); }); }, 0); new Promise(function(resolve, reject) { console.log('promise'); resolve(); }).then(function() { console.log('promise then'); }); console.log('main2');
執行順序
分析如下
-
開始執行代碼,輸出 main1,process.nextTick 放入TickTask Queue,setTimeout放入 MacroTask Queue, new Promise 執行 輸出 promise,then 方法 放入 MicroTask Queue , 接着 最后一行代碼 console.log 輸出 main2
-
當前的 宏任務執行完畢,開始清空微任務,先清空TickTask Queue ,執行 console.log('process.nextTick1'); 輸出'process.nextTick1;再清空MicroTask Queue執行 console.log('promise then'); 輸出promise then;微任務全部清空。
-
開始下次 eventLoop; 執行 setTimeout; 第一行 console.log('setTimeout'); 輸出setTimeout; process.nextTick 將任務放入了TickTask Queue;當前宏任務執行完畢;開始清空MicroTask Queue,清空TickTaskQueue ,執行 console.log('process.nextTick2');輸出process.nextTick2;
四、Node.js中的Event Loop
Node.js也是單線程的Event Loop,但是它的運行機制不同於瀏覽器環境。
根據上圖,Node.js的運行機制如下。
(1)V8引擎解析JavaScript腳本。
(2)解析后的代碼,調用Node API。
(3)libuv庫負責Node API的執行。它將不同的任務分配給不同的線程,形成一個Event Loop(事件循環),以異步的方式將任務的執行結果返回給V8引擎。
(4)V8引擎再將結果返回給用戶。
Node.js還提供了另外兩個與"任務隊列"有關的方法:process.nextTick和setImmediate。它們可以幫助我們加深對"任務隊列"的理解。
process.nextTick方法可以在當前"執行棧"的尾部----下一次Event Loop(主線程讀取"任務隊列")之前----觸發回調函數。也就是說,它指定的任務總是發生在所有異步任務之前。setImmediate方法則是在當前"任務隊列"的尾部添加事件,也就是說,它指定的任務總是在下一次Event Loop時執行,這與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方法指定的回調函數,總是在當前"執行棧"的尾部觸發,所以不僅函數A比setTimeout指定的回調函數timeout先執行,而且函數B也比timeout先執行。這說明,如果有多個process.nextTick語句(不管它們是否嵌套),將全部在當前"執行棧"執行。
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前面觸發。至於2排在TIMEOUT FIRED的后面(即函數B在timeout后面觸發),是因為setImmediate總是將事件注冊到下一輪Event Loop,所以函數A和timeout是在同一輪Loop執行,而函數B在下一輪Loop執行。
我們由此得到了process.nextTick和setImmediate的一個重要區別:多個process.nextTick語句總是在當前"執行棧"一次執行完,多個setImmediate可能則需要多次loop才能執行完。
事實上,這正是Node.js 10.0版添加setImmediate方法的原因,否則像下面這樣的遞歸調用process.nextTick,將會沒完沒了,主線程根本不會去讀取"事件隊列"!
process.nextTick(function foo() { process.nextTick(foo); });
事實上,現在要是你寫出遞歸的process.nextTick,Node.js會拋出一個警告,要求你改成setImmediate。
另外,由於process.nextTick指定的回調函數是在本次"事件循環"觸發,而setImmediate指定的是在下次"事件循環"觸發,所以很顯然,前者總是比后者發生得早,而且執行效率也高(因為不用檢查"任務隊列")。
Node中event loop的6個階段(phase)
nodejs的event loop分為6個階段,每個階段的作用如下(process.nextTick()
在6個階段結束的時候都會執行,文章后半部分會詳細分析process.nextTick()
的回調是怎么引進event loop,僅僅從uv_run()
是找不到process.nextTick()
是如何牽涉進來):
- timers:執行
setTimeout()
和setInterval()
中到期的callback。 - I/O callbacks:上一輪循環中有少數的I/Ocallback會被延遲到這一輪的這一階段執行
- idle, prepare:僅內部使用,process.nextTick就屬於這一類
- poll:最為重要的階段,執行I/O callback,在適當的條件下會阻塞在這個階段
- check:執行setImmediate()的callback
- close callbacks:執行close事件的callback,例如
socket.on("close",func)
從上面可以得知,idle觀察者先於I/O觀察者,I/O觀察者先於check觀察者。——《深入淺出Node.js》
┌───────────────────────┐ ┌─>│ timers │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ I/O callbacks │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ │ │ idle, prepare │ │ └──────────┬────────────┘ ┌───────────────┐ │ ┌──────────┴────────────┐ │ incoming: │ │ │ poll │<─────┤ connections, │ │ └──────────┬────────────┘ │ data, etc. │ │ ┌──────────┴────────────┐ └───────────────┘ │ │ check │ │ └──────────┬────────────┘ │ ┌──────────┴────────────┐ └──┤ close callbacks │ └───────────────────────┘
event loop的每一次循環都需要依次經過上述的階段。 每個階段都有自己的callback隊列,每當進入某個階段,都會從所屬的隊列中取出callback來執行,當隊列為空或者被執行callback的數量達到系統的最大數量時,進入下一階段。這六個階段都執行完畢稱為一輪循環。
具體細節可以看這篇文章:https://cnodejs.org/topic/5a9108d78d6e16e56bb80882#5a98d9a2ce1c90bc44c445af