最近在閱讀《你不知道的JavaScript中卷》,當我看到第二部分介紹異步和回調函數的一些知識時,由於該書在第二部分1、2章對線程、事件循環的概念介紹的並非詳細,因此引發了我的一系列思考。於是寫下這篇小文章,記錄自己對該知識點的學習和思考。
javascript單線程
由於JavaScript是單線程語言,因此,在一個進程上,只能運行一個線程,而不能多個線程同時運行。也就是說JavaScript不允許多個線程共享內存空間。因此,如果有多個線程想同時運行,則需采取排隊的方式,即只有當前一個任務執行完畢,后一個任務才開始執行。
任務隊列
在JavaScript中,所有任務可以分為兩種,一種是同步任務,一種是異步任務。同步任務指的是,在主線程上排隊執行的任務,只有前一個任務執行完畢,后一個任務才會執行;異步任務指的是不進入主線程、而進入任務隊列的任務,只有當主線程上的所有同步任務執行完畢之后,主線程才會讀取任務隊列,開始執行異步任務。
單線程就意味着,所有任務需要排隊,前一個任務結束,才會執行后一個任務。如果前一個任務耗時很長,后一個任務不得不一直等待。那么這樣的JavaScript的執行不是很慢嗎?特別是對於長時間任務執行的時候,那么其他的任務就得不到執行。考慮這種原因,JS中將這些耗時的I/O等操作封裝成了異步的方法,也就是通過回調函數的方式。等到同步任務執行完畢,主線程才會讀取任務隊列上的異步任務(回調函數的方式)。
任務隊列是一個事件的隊列(也可以理解成消息的隊列),IO設備完成一項任務,就在"任務隊列"中添加一個事件,表示相關的異步任務可以進入"執行棧"了。主線程讀取"任務隊列",就是讀取里面有哪些事件。
“任務隊列”中的事件,除了IO設備(ajax獲取服務器數據)的事件以外,還包括一些用戶產生的事件(mouseover、click、scroll、keyup等)和定時器等。只要在事件中指定了事件處理程序(回調函數),這些事件發生時就會進入“任務隊列”,等待主線程讀取。而主線程讀取任務隊列中的異步任務,主要就是讀取回調函數。
當主線程的所有同步任務執行(排隊執行)完畢之后,就會讀取任務隊列中的異步任務,將異步任務推入執行棧中執行。任務隊列是一個先進先出的數據結構,即排在前面的事件,優先被主線程讀取。如果存在定時器,時間越短的越先進入執行棧。
因此,可以做一個簡單的總結:
- JS將任務分為兩種,同步任務和異步任務。
- 當主線程開始執行同步任務時,會創建一個“執行棧”,每一個同步任務排隊執行,只有前一個任務執行完畢,才會執行下一個任務。
- 當主線程上的所有同步任務執行完畢之后,也就是當“執行棧”為空時,主線程會去讀取任務隊列上的異步任務(回調函數),並將異步任務推入執行棧中開始執行。
- 主線程不斷重復第二、第三個步驟。
Event Loop(事件循環)
主線程中的所有同步任務執行完畢,再讀取任務隊列中的異步任務,這個過程是循環不斷的。所以,整個的這種運行機制稱為Event Loop(事件循環)。
在異步任務中,又可以分為兩種,macrotask(宏任務)和micro(微任務)。在掛起任務時,JS 引擎會將所有任務按照類別分到這兩個隊列中,在當前執行棧為空的時候,主線程會查看微任務隊列是否有事件存在。如果不存在,那么再去宏任務隊列中取出一個事件並把對應的回到加入當前執行棧;如果存在,則會依次執行隊列中事件對應的回調,直到微任務隊列為空,然后去宏任務隊列中取出最前面的一個事件,把對應的回調加入當前執行棧...如此反復,進入循環。
我們只需記住當當前執行棧執行完畢時會立刻先處理所有微任務隊列中的事件,然后再去宏任務隊列中取出一個事件。同一次事件循環中,微任務永遠在宏任務之前執行。
兩個類別的具體分類如下:
- macrotask: script(整體代碼), setTimeout, setInterval, setImmediate, I/O, UI rendering
- microtask: process.nextTick, Promise, MutationObserver
上圖中,主線程運行的時候,產生堆(heap)和棧(stack),棧中的代碼調用各種webAPIs,它們在任務隊列中加入各種事件(click,load,keyup等)。只要棧中的代碼執行完畢,主線程就會去讀取任務隊列,依次執行那些事件所對應的回調函數。
對於setTimeout定時器而言,setTimeout會在指定時間內任務隊列添加一個回調函數,如果任務隊列中沒有其他任務,則這條任務會立即執行;否則,這個任務會不得不等到其他異步任務執行完畢之后,才會得到執行。因此,setTimeout指定的執行時間,只是一個最早可能發生的時間,並不能保證一定會在那個時間發生。
setTimeout
明白了主線程執行相關任務的思路后,來看看定時器。上面介紹到,定時器是屬於任務隊列中的異步任務。因此會等待“執行棧”上的所有同步任務執行完畢之后,主線程計算定時器的執行時間,再將事件推入“執行棧”。看一個簡單的例子。
function foo() {
setTimeout(function() {
console.log(1);
}, 0)
console.log(2);
}
function bar() {
setTimeout(function() {
console.log(3);
}, 0);
console.log(4);
}
foo();
bar();
這段函數的輸出結果為2, 4, 1, 3。做一個簡單的分析。
foo、bar函數的內部有相同的結構,都有一個定時器和console.log()函數。當foo、bar函數調用時,會形成一個“執行棧”,主線程會先執行“執行棧”中的同步任務,即console.log(2), console.log(4)
,而兩個定時器會被推入任務隊列中,等待執行。當主線程上的同步任務執行完畢之后,結束定時器的等待,將任務隊列中的兩個異步任務推入“執行棧”中執行,因此輸出的順序為2, 4, 1, 3。
定時器的第一個參數是一個函數,第二個參數是推遲執行的毫秒數。從函數的定義上看,如果將時間設定為0,此時應該是立即執行定時器才對,為什么輸出順序會不同呢?
需要注意的是,setTimeout()只是將回調函數插入到“任務隊列”中,因此必須等到主線程上的同步任務全部執行完畢,並且任務隊列中不存在其他異步任務時,才會開始執行。setTimeout的第二個參數只能確保任務在指定的時間之后執行,而不能保證一定就在該時間之后立即執行,是否能夠立即執行,取決於任務隊列中是否存在其他異步任務。
看一段代碼。
function foo() {
setTimeout(function() {
console.log(1);
}, 2000)
console.log(2);
}
function bar() {
setTimeout(function() {
console.log(3);
}, 1000);
console.log(4);
}
function baz() {
setTimeout(function() {
console.log(5);
}, 0)
console.log(6);
}
foo();
bar();
baz();
//結果: 2, 4, 6, 5, 3, 1;
主線程上的同步任務按照執行棧排隊執行,任務隊列上的定時器按照時間長短排隊執行。時間越短,越早進入“執行棧”,越早被主線程執行。也就是說,先進入任務隊列的任務先執行。
如果換一種函數的調用位置
baz();
foo();
bar();
//此時的結果: 6, 2, 4, 5, 3, 1
從上面的兩種運行結果可以看出
同步任務取決於函數的調用位置,不同的調用位置,進入執行棧的位置就不同,主線程執行的順序就不同
異步任務的執行與函數的調用位置無關,只取決於執行棧的任務數量,當同步任務執行完畢之后,才會開始執行異步任務,並且遵循先進入任務隊列的事件先執行的原則。