Event Loop是什麼
因為js設計之初,多線程的執行模式還不流行,所以一直以為,js都是單線程執行的。但是js擁有異步執行的能力,這依賴於事件循環(Event Loop)的執行模式。我們將通過js在瀏覽器中的執行來研究一下該模式。
其中涉及到一些概念,我們先簡單研究一下,以便後續更好地瞭解。
進程和線程
參考阮一峰的解釋,將整個CPU比喻為一座工廠,進程就是其中的車間,車間中的需要完成的工序就是線程。一個工廠可以有多個車間,每個車間有一個或者多個工序,但是必須 按照順序執行,這就是單線程的概念。也是瀏覽器事件執行的基礎。
瀏覽器渲染過程
瀏覽器是一個多進程應用,每一個窗口就是一個進程,其中包含以下線程:
- GUI渲染線程
負責渲染頁面,佈局和繪製
頁面需要重繪和迴流時,該線程就會執行
與js引擎線程互斥,防止渲染結果不可預期
- JS引擎線程
負責處理解析和執行javascript腳本程序
只有一個JS引擎線程(單線程)
與GUI渲染線程互斥,防止渲染結果不可預期
- 事件觸發線程
用來控制事件循環(鼠標點擊、setTimeout、ajax等)
當事件滿足觸發條件時,將事件放入到JS引擎所在的執行隊列中
- 定時觸發器線程
setInterval與setTimeout所在的線程
定時任務並不是由JS引擎計時的,是由定時觸發線程來計時的
計時完畢後,通知事件觸發線程
- 異步http請求線程
瀏覽器有一個單獨的線程用於處理AJAX請求
當請求完成時,若有回調函數,通知事件觸發線程
各個進程之間的關係
- 同步任務都在js引擎線程上完成,當前的任務都存儲在執行棧中;
- js引擎線程執行到setTimeout/setInterval的時候,通知定時觸發器線程,間隔一定時間,觸發回調函數;
- 定時觸發器線程在接收到這個消息後,會在等待的時間後,將回調事件放入到由事件觸發線程所管理的事件隊列(事件隊列分為宏任務隊列和微任務隊列)中;
- js引擎線程執行到XHR/fetch時,通知 異步http請求線程,發送一個網絡請求;
- 異步http請求線程在請求成功後,將回調事件放入到由事件觸發線程的事件隊列中;
- 如果JS引擎線程中的執行棧沒有任務了,JS引擎線程會詢問事件觸發線程,在 事件隊列中是否有待執行的回調函數,如果有就會加入到執行棧中交給JS引擎線程執行;
- JS引擎線程空閒之後,GUI渲染線程開始工作
總結:
- JS 是可以操作 DOM 的, 因此瀏覽器設定 GUI渲染線程和 JS引擎線程為互斥關係;
- setTimeout/setInterval和 XHR/fetch代碼執行時, 本身是同步任務,而其中的回調函數才是異步任務
- JS引擎線程只執行執行棧中的事件
- 執行棧中的代碼執行完畢,就會讀取事件隊列中的事件
- 事件隊列中的回調事件,是由各自線程插入到事件隊列中的
- 如此循環
js如何異步執行
瞭解了瀏覽器多線程之間的關聯之後,我們開始探究,js是如何依賴Event Loop,進行異步操作的。
執行棧和事件隊列
在分析多線程之間的關係時,我們提到了兩個概念,執行棧和執行隊列
執行棧
棧,是一種數據結構,具有先進後出的原則。JS 中的執行棧就具有這樣的結構,當引擎第一次遇到 JS 代碼時,會產生一個全局執行上下文並壓入執行棧,每遇到一個函數調用,就會往棧中壓入一個新的上下文。引擎執行棧頂的函數,執行完畢,彈出當前執行上下文
事件隊列
事件隊列是一個存儲著 異步任務 的隊列,按照先進先出的原則執行。事件隊列每次僅執行一個任務。當執行棧為空時,JS 引擎便檢查事件隊列,如果事件隊列不為空的話,事件隊列便將第一個任務壓入執行棧中運行。
宏任務和微任務
異步任務又分為宏任務跟微任務、他們之間的區別主要是執行順序的不同。
宏任務(macrotask)
也叫tasks,一些異步任務的回調會依次進入macro task queue,等待後續被調用,這些異步任務包括:
- 包括整體代碼script
- setTimeout
- setInterval
- requestAnimationFrame
微任務(microtask)
也叫jobs,另一些異步任務的回調會依次進入micro task queue,等待後續被調用,這些異步任務包括:
- Promise
- MutationObserver
理解Event Loop
示例
下面上一道很經典的題目:
console.log('1');
setTimeout(()=>{
console.log('2');
},100);
setTimeout(()=>{
console.log('3');
},0);
console.log('4');
沒有研究event loop之前,答案很可能以為是1 3 4 2,但是實際答案是1 4 3 2。其中的原理下面來分析一下。
異步任務執行的時候,有這樣一個順序:
- 執行全局Script代碼,如果碰到異步任務,將該任務放入微任務隊列中
- 全局Script執行完,執行棧清空
- 從微隊列microtask queue中取出位於隊首的回調任務,放入調用棧Stack中執行
- 一個微任務執行完畢之後,再從微任務隊列中取出一個任務放入執行棧執行,若微任務中還有微任務,則放入當前微任務隊列末尾
- 微任務隊列為空,執行棧也為空,此時從宏任務隊列取出一個任務執行,如果其中有微任務,放入微任務隊列
- 重複執行3-5步驟...
- 重複執行3-5步驟...
*注:
- 宏任務隊列一次只從隊列中取一個任務執行,執行完後就去執行微任務隊列中的任務;
- 微任務隊列中所有的任務都會被依次取出來執行,直到隊列為空;
- GUI渲染線程在微任務執行完,執行棧為空,下一個宏任務執行之前執行,。
以上就是瀏覽器事件循環——event loop。
理解了異步任務的執行順序之後,再來回顧上面這道題:
console.log('1');
setTimeout(()=>{
console.log('2');
},100);
setTimeout(()=>{
console.log('3');
},0);
console.log('4');
- 執行整個script,console.log('1')是同步任務,setTimeout是宏任務,js引擎線程通知事件觸發線程 ,在定時n秒後存入宏任務隊列中,所以先存入console.log('3'),後存入console.log('2');;
- 執行下一個同步任務console.log('4');;
- 執行棧為空,查詢微任務隊列也為空,查詢宏任務隊列
- 根據先進先出原則,執行console.log('3');,後執行console.log('2');
- 輸出1 4 3 2;
實踐
再來2道題鞏固一下:
一.
setTimeout(() => {
console.log('A');
}, 0);
var obj = {
func: function() {
setTimeout(function() {
console.log('B');
}, 0);
return new Promise(function(resolve) {
console.log('C');
resolve();
});
},
};
obj.func().then(function() {
console.log('D');
});
console.log('E');
- 第一個setTimeout放到宏任務隊列,此時宏任務隊列為['A'];
- 接著執行obj的func方法,將setTimeout放到宏任務隊列,此時宏任務隊列為['A', 'B']
- 函數返回一個Promise,因為這是一個同步操作,所以先打印出'C';
- 接著將then放到微任務隊列,此時微任務隊列為 ['D'];
- 接著執行同步任務console.log('E');,打印出 'E';
- 因為微任務優先執行,所以先輸出 'D';
- 最後依次輸出 ['A', 'B'];
- 輸出結果:C E D A B
二.
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
async1();
new Promise(function(resolve) {
console.log('promise1');
resolve();
}).then(function() {
console.log('promise2');
});
console.log('script end');
await前面的代碼是同步的,調用此函數時會直接執行;而await a(); 這句可以被轉換成 Promise.resolve(a()); await 後面的代碼 則會被放到 Promise.then() 方法裡。因此上面的代碼可以被轉換成如下形式:
function async1() {
console.log('async1 start'); // 2
Promise.resolve(async2()).then(() => {
console.log('async1 end'); // 6
});
}
function async2() {
console.log('async2'); // 3
}
console.log('script start'); // 1
setTimeout(function() {
console.log('settimeout'); // 8
}, 0);
async1();
new Promise(function(resolve) {
console.log('promise1'); // 4
resolve();
}).then(function() {
console.log('promise2'); // 7
});
- 首先打印出script start
- 接著將settimeout添加到宏任務隊列,此時宏任務隊列為['settimeout']
- 然後執行函數async1,先打印出async1 start,又因為Promise.resolve(async2()) 是同步任務,所以打印出async2,接著將async1 end 添加到微任務隊列,此時微任務隊列為['async1 end']
- 接著打印出promise1,將promise2 添加到微任務隊列,此時微任務隊列為['async1 end', promise2]
- 打印出script end
- 因為微任務優先級高於宏任務,所以先依次打印出 async1 end和promise2
- 最後打印出宏任務settimeout
我自己是一名從事了多年的前端老程序員,小編為大家準備了新出的前端編程學習資料,免費分享給大家!
如果你也想學習前端,那麼幫忙轉發一下然後再關注小編後私信【1】可以得到我整理的這些前端資料了(私信方法:點擊我頭像進我主頁有個上面有個私信按鈕)
閱讀更多 前端明澈 的文章