引言
最近面試被問到,JS
既然是單線程的,為什么可以執行異步操作?
當時腦子蒙了,思維一直被困在 單線程
這個問題上,一直在思考單線程為什么可以額外運行任務,其實在我很早以前寫的博客里面有寫相關的內容,只不過時間太長給忘了,所以要經常溫習啊:(淺談 Generator 和 Promise 的原理及實現)
- JS 是單線程的,只有一個主線程
- 函數內的代碼從上到下順序執行,遇到被調用的函數先進入被調用函數執行,待完成后繼續執行
- 遇到異步事件,瀏覽器另開一個線程,主線程繼續執行,待結果返回后,執行回調函數
其實 JS
這個語言是運行在宿主環境中,比如 瀏覽器環境
,nodeJs環境
- 在瀏覽器中,瀏覽器負責提供這個額外的線程
- 在
Node
中,Node.js
借助libuv
來作為抽象封裝層, 從而屏蔽不同操作系統的差異,Node
可以借助libuv
來實現多線程。
而這個異步線程又分為 微任務
和 宏任務
,本篇文章就來探究一下 JS
的異步原理以及其事件循環機制
為什么 JavaScript
是單線程的
JavaScript
語言的一大特點就是單線程,也就是說,同一個時間只能做一件事。這樣設計的方案主要源於其語言特性,因為 JavaScript
是瀏覽器腳本語言,它可以操縱 DOM
,可以渲染動畫,可以與用戶進行互動,如果是多線程的話,執行順序無法預知,而且操作以哪個線程為准也是個難題。
所以,為了避免復雜性,從一誕生,JavaScript就是單線程,這已經成了這門語言的核心特征,將來也不會改變。
在 HTML5
時代,瀏覽器為了充分發揮 CPU
性能優勢,允許 JavaScript
創建多個線程,但是即使能額外創建線程,這些子線程仍然是受到主線程控制,而且不得操作 DOM
,類似於開辟一個線程來運算復雜性任務,運算好了通知主線程運算完畢,結果給你,這類似於異步的處理方式,所以本質上並沒有改變 JavaScript
單線程的本質。
函數調用棧與任務隊列
函數調用棧
JavaScript
只有一個主線程和一個調用棧(call stack
),那什么是調用棧呢?
這類似於一個乒乓球桶,第一個放進去的乒乓球會最后一個拿出來。
舉個栗子:
function a() {
console.log("I'm a!");
};
function b() {
a();
console.log("I'm b!");
};
b();
執行過程如下所示:
-
第一步,執行這個文件,此文件會被壓入調用棧(例如此文件名為
main.js
)call stack main.js
-
第二步,遇到
b()
語法,調用b()
方法,此時調用棧會壓入此方法進行調用:call stack b()
main.js
-
第三步:調用
b()
函數時,內部調用的a()
,此時a()
將壓入調用棧:call stack a()
b()
main.js
-
第四步:
a()
調用完畢輸出I'm a!
,調用棧將a()
彈出,就變成如下:call stack b()
main.js
-
第五步:
b()
調用完畢輸出I'm b!
,調用棧將b()
彈出,變成如下:call stack main.js
-
第六步:
main.js
這個文件執行完畢,調用棧將b()
彈出,變成一個空棧,等待下一個任務執行:call stack
這就是一個簡單的調用棧,在調用棧中,前一個函數在執行的時候,下面的函數全部需要等待前一個任務執行完畢,才能執行。
但是,有很多任務需要很長時間才能完成,如果一直都在等待的話,調用棧的效率極其低下,這時,JavaScript
語言設計者意識到,這些任務主線程根本不需要等待,只要將這些任務掛起,先運算后面的任務,等到執行完畢了,再回頭將此任務進行下去,於是就有了 任務隊列
的概念。
任務隊列
所有任務可以分成兩種,一種是 同步任務(synchronous)
,另一種是 異步任務(asynchronous)
。
同步任務指的是,在主線程上排隊執行的任務,只有前一個任務執行完畢,才能執行后一個任務。
異步任務指的是,不進入主線程、而進入"任務隊列"(task queue)
的任務,只有 "任務隊列"
通知主線程,某個異步任務可以執行了,該任務才會進入主線程執行。
所以,當在執行過程中遇到一些類似於 setTimeout
等異步操作的時候,會交給瀏覽器的其他模塊進行處理,當到達 setTimeout
指定的延時執行的時間之后,回調函數會放入到任務隊列之中。
當然,一般不同的異步任務的回調函數會放入不同的任務隊列之中。等到調用棧中所有任務執行完畢之后,接着去執行任務隊列之中的回調函數。
用一張圖來表示就是:
上圖中,調用棧先進行順序調用,一旦發現異步操作的時候就會交給瀏覽器內核的其他模塊進行處理,對於 Chrome
瀏覽器來說,這個模塊就是 webcore
模塊,上面提到的異步API,webcore
分別提供了 DOM Binding
、 network
、timer
模塊進行處理。等到這些模塊處理完這些操作的時候將回調函數放入任務隊列中,之后等棧中的任務執行完之后再去執行任務隊列之中的回調函數。
我們先來看一個有意思的現象,我運行一段代碼,大家覺得輸出的順序是什么:
setTimeout(() => {
console.log('setTimeout')
}, 22)
for (let i = 0; i++ < 2;) {
i === 1 && console.log('1')
}
setTimeout(() => {
console.log('set2')
}, 20)
for (let i = 0; i++ < 100000000;) {
i === 99999999 && console.log('2')
}
沒錯!結果很量子化:
那么這實際上是一個什么過程呢?那我就拿上面的一個過程解析一下:
-
首先,文件入棧
-
開始執行文件,讀取到第一行代碼,當遇到
setTimeout
的時候,執行引擎將其添加到棧中。(由於字體太細我調粗了一點。。。) -
調用棧發現
setTimeout
是Webapis
中的API
,因此將其交給瀏覽器的timer
模塊進行處理,同時處理下一個任務。
-
第二個
setTimeout
入棧 -
同上所示,異步請求被放入
異步API
進行處理,同時進行下一個入棧操作: -
在進行異步的同時,
app.js
文件調用完畢,彈出調用棧,異步執行完畢后,會將回調函數放入任務隊列: -
任務隊列通知調用棧,我這邊有任務還沒有執行,調用棧則會執行任務隊列里的任務:
上面的流程解釋了瀏覽器遇到 setTimeout
之后究竟如何執行的,其實總結下來就是以下幾點:
- 調用棧順序調用任務
- 當調用棧發現異步任務時,將異步任務交給其他模塊處理,自己繼續進行下面的調用
- 異步執行完畢,異步模塊將任務推入任務隊列,並通知調用棧
- 調用棧在執行完當前任務后,將執行任務隊列里的任務
- 調用棧執行完任務隊列里的任務之后,繼續執行其他任務
這一整個流程就叫做 事件循環(Event Loop)
。
那么,了解了這么多,小伙伴們能從事件循環上面來解析下面代碼的輸出嗎?
for (var i = 0; i < 10; i++) {
setTimeout(() => {
console.log(i)
}, 1000)
}
console.log(i)
解析:
- 首先由於
var
的變量提升,i
在全局作用域都有效 - 再次,代碼遇到
setTimeout
之后,將該函數交給其他模塊處理,自己繼續執行console.log(i)
,由於變量提升,i
已經循環10次,此時i
的值為10
,即,輸出10
- 之后,異步模塊處理好函數之后,將回調推入任務隊列,並通知調用棧
- 1秒之后,調用棧順序執行回調函數,由於此時
i
已經變成10
,即輸出10次10
用下圖示意:
現在小伙伴們是否已經恍然大悟,從底層了解了為什么這個代碼會輸出這個內容吧:
那么問題又來了,我們看下面的代碼:
setTimeout(() => {
console.log(4)
}, 0);
new Promise((resolve) =>{
console.log(1);
for (var i = 0; i < 10000000; i++) {
i === 9999999 && resolve();
}
console.log(2);
}).then(() => {
console.log(5);
});
console.log(3);
大家覺得這個輸出是多少呢?
有小伙伴就開始分析了,promise
也是異步,先執行里面函數的內容,輸出 1
和 2
,然后執行下面的函數,輸出 3
,但 Promise
里面需要循環999萬次,setTimeout
卻是0毫秒執行,setTimeout
應該立即推入執行棧, Promise
后推入執行棧,結果應該是下圖:
實際上答案是 1,2,3,5,4
噢,這是為什么呢?這就涉及到任務隊列的內部,宏任務和微任務。
宏任務和微任務
什么是宏任務和微任務
任務隊列又分為 macro-task(宏任務)
與 micro-task(微任務)
,在最新標准中,它們被分別稱為 task
與 jobs
。
macro-task(宏任務)
大概包括:script(整體代碼)
,setTimeout
,setInterval
,setImmediate(NodeJs)
,I/O
,UI rendering
。micro-task(微任務)
大概包括:process.nextTick(NodeJs)
,Promise
,Object.observe(已廢棄)
,MutationObserver(html5新特性)
- 來自不同任務源的任務會進入到不同的任務隊列。其中
setTimeout
與setInterval
是同源的。
事實上,事件循環決定了代碼的執行順序,從全局上下文進入函數調用棧開始,直到調用棧清空,然后執行所有的micro-task(微任務)
,當所有的micro-task(微任務)
執行完畢之后,再執行macro-task(宏任務)
,其中一個macro-task(宏任務)
的任務隊列執行完畢(例如setTimeout
隊列),再次執行所有的micro-task(微任務)
,一直循環直至執行完畢。
解析
現在我就開始解析上面的代碼。
-
第一步,整體代碼
script
入棧,並執行setTimeout
后,執行Promise
: -
第二步,執行時遇到
Promise
實例,Promise
構造函數中的第一個參數,是在new
的時候執行,因此不會進入任何其他的隊列,而是直接在當前任務直接執行了,而后續的.then
則會被分發到micro-task
的Promise
隊列中去。 -
第三步,調用棧繼續執行宏任務
app.js
,輸出3
並彈出調用棧,app.js
執行完畢彈出調用棧: -
第四步,這時,
macro-task(宏任務)
中的script
隊列執行完畢,事件循環開始執行所有的micro-task(微任務)
: -
第五步,調用棧發現所有的
micro-task(微任務)
都已經執行完畢,又跑去macro-task(宏任務)
調用setTimeout
隊列: -
第六步,
macro-task(宏任務)
setTimeout
隊列執行完畢,調用棧又跑去微任務進行查找是否有未執行的微任務,發現沒有就跑去宏任務執行下一個隊列,發現宏任務也沒有隊列執行,此次調用結束,輸出內容1,2,3,5,4
。
那么上面這個例子的輸出結果就顯而易見。大家可以自行嘗試體會。
總結
- 不同的任務會放進不同的任務隊列之中。
- 先執行
macro-task
,等到函數調用棧清空之后再執行所有在隊列之中的micro-task
。 - 等到所有
micro-task
執行完之后再從macro-task
中的一個任務隊列開始執行,就這樣一直循環。 - 宏任務和微任務的隊列執行順序排列如下:
macro-task(宏任務)
:script(整體代碼)
,setTimeout
,setInterval
,setImmediate(NodeJs)
,I/O
,UI rendering
。micro-task(微任務)
:process.nextTick(NodeJs)
,Promise
,Object.observe(已廢棄)
,MutationObserver(html5新特性)
進階舉例
那么,我再來一些有意思一點的代碼:
<script>
setTimeout(() => {
console.log(4)
}, 0);
new Promise((resolve) => {
console.log(1);
for (var i = 0; i < 10000000; i++) {
i === 9999999 && resolve();
}
console.log(2);
}).then(() => {
console.log(5);
});
console.log(3);
</script>
<script>
console.log(6)
new Promise((resolve) => {
resolve()
}).then(() => {
console.log(7);
});
</script>
這一段代碼輸出的順序是什么呢?
其實,看明白上面流程的同學應該知道整個流程,為了防止一些同學不明白,我再簡單分析一下:
-
首先,
script1
進入任務隊列(為了方便起見,我把兩塊script
命名為script1
,script2
): -
第二步,
script1
進行調用並彈出調用棧: -
第三步,
script1
執行完畢,調用棧清空后,直接調取所有微任務: -
第四步,所有微任務執行完畢之后,調用棧會繼續調用宏任務隊列:
-
第五步,執行
script2
,並彈出: -
第六步,調用棧開始執行微任務:
-
第七步,調用棧調用完所有微任務,又跑去執行宏任務:
至此,所有任務執行完畢,輸出 1,2,3,5,6,7,4
了解了上面的內容,我覺得再復雜一點異步調用關系你也能搞定:
setImmediate(() => {
console.log(1);
},0);
setTimeout(() => {
console.log(2);
},0);
new Promise((resolve) => {
console.log(3);
resolve();
console.log(4);
}).then(() => {
console.log(5);
});
console.log(6);
process.nextTick(()=> {
console.log(7);
});
console.log(8);
//輸出結果是3 4 6 8 7 5 2 1
終極測試
setTimeout(() => {
console.log('to1');
process.nextTick(() => {
console.log('to1_nT');
})
new Promise((resolve) => {
console.log('to1_p');
setTimeout(() => {
console.log('to1_p_to')
})
resolve();
}).then(() => {
console.log('to1_then')
})
})
setImmediate(() => {
console.log('imm1');
process.nextTick(() => {
console.log('imm1_nT');
})
new Promise((resolve) => {
console.log('imm1_p');
resolve();
}).then(() => {
console.log('imm1_then')
})
})
process.nextTick(() => {
console.log('nT1');
})
new Promise((resolve) => {
console.log('p1');
resolve();
}).then(() => {
console.log('then1')
})
setTimeout(() => {
console.log('to2');
process.nextTick(() => {
console.log('to2_nT');
})
new Promise((resolve) => {
console.log('to2_p');
resolve();
}).then(() => {
console.log('to2_then')
})
})
process.nextTick(() => {
console.log('nT2');
})
new Promise((resolve) => {
console.log('p2');
resolve();
}).then(() => {
console.log('then2')
})
setImmediate(() => {
console.log('imm2');
process.nextTick(() => {
console.log('imm2_nT');
})
new Promise((resolve) => {
console.log('imm2_p');
resolve();
}).then(() => {
console.log('imm2_then')
})
})
// 輸出結果是:?
大家可以在評論里留言結果喲~