JS為什麼是單執行緒的?
最初設計JS是用來在瀏覽器驗證表單操控DOM元素的是一門指令碼語言,如果js是多執行緒的那麼兩個執行緒同時對一個DOM元素進行了相互衝突的操作,那麼瀏覽器的解析器是無法執行的。
JS為什麼需要非同步?
如果JS中不存在非同步,只能自上而下執行,如果上一行解析時間很長,那麼下面的程式碼就會被阻塞。對於使用者而言,阻塞就意味著"卡死",這樣就導致了很差的使用者體驗。比如在進行ajax請求的時候如果沒有返回資料後面的程式碼就沒辦法執行。
JS單執行緒又是如何實現非同步的呢?
js中的非同步以及多執行緒都可以理解成為一種“假象”,就拿h5的WebWorker來說,子執行緒有諸多限制,不能控制DOM元素、不能修改全域性物件 等等,通常只用來做計算做資料處理。這些限制並沒有違揹我們之前的觀點,所以說是“假象”。JS非同步的執行機制其實就是事件迴圈(eventloop),理解了eventloop機制,就理解了JS非同步的執行機制。
JS的事件迴圈(eventloop)是怎麼運作的?
事件迴圈、eventloop、執行機制 這三個術語其實說的是同一個東西,在寫這篇文章之前我一直以為事件迴圈簡單的很,就是先執行同步操作,然後把非同步操作排在事件佇列裡,等同步操作都執行完了(執行棧空閒),按順序執行事件佇列裡的內容。但是遠不止這麼膚淺,我們接下來一步一步的深入來了解。
“先執行同步操作非同步操作排在事件佇列裡”這樣的理解其實也沒有任何問題但如果深入的話會引出來很多其他概念,比如event table和event queue,我們來看執行過程:
- 首先判斷JS是同步還是非同步,同步就進入主執行緒執行,非同步就進入event table。
- 非同步任務在event table中註冊事件,當滿足觸發條件後(觸發條件可能是延時也可能是ajax回撥),被推入event queue。
- 同步任務進入主執行緒後一直執行,直到主執行緒空閒時,才會去event queue中檢視是否有可執行的非同步任務,如果有就推入主執行緒中。
setTimeout(() => {
console.log('2秒到了')
}, 2000)
複製程式碼
我們用上面的第二條來分析一下這段指令碼,setTimeout是非同步操作首先進入event table,註冊的事件就是他的回撥,觸發條件就是2秒之後,當滿足條件回撥被推入event queue,當主執行緒空閒時會去event queue裡檢視是否有可執行的任務。
console.log(1) // 同步任務進入主執行緒
setTimeout(fun(),0) // 非同步任務,被放入event table, 0秒之後被推入event queue裡
console.log(3) // 同步任務進入主執行緒
複製程式碼
1、3是同步任務馬上會被執行,執行完成之後主執行緒空閒去event queue(事件佇列)裡檢視是否有任務在等待執行,這就是為什麼setTimeout的延遲時間是0毫秒卻在最後執行的原因。
關於setTimeout有一點要注意延時的時間有時候並不是那麼準確。
setTimeout(() => {
console.log('2秒到了')
}, 2000)
wait(9999999999)
複製程式碼
分析執行過程:
- console進入Event Table並註冊,計時開始。
- 執行sleep函式,sleep方法雖然是同步任務但sleep方法進行了大量的邏輯運算,耗時超過了2秒。
- 2秒到了,計時事件timeout完成,console進入Event Queue,但是sleep還沒執行完,主執行緒還被佔用,只能等著。
- sleep終於執行完了,console終於從Event Queue進入了主執行緒執行,這個時候已經遠遠超過了2秒。
其實延遲2秒只是表示2秒後,setTimeout裡的函式被會推入event queue,而event queue(事件佇列)裡的任務,只有在主執行緒空閒時才會執行。上述的流程走完,我們知道setTimeout這個函式,是經過指定時間後,把要執行的任務(本例中為console)加入到Event Queue中,又因為是單執行緒任務要一個一個執行,如果前面的任務需要的時間太久,那麼只能等著,導致真正的延遲時間遠遠大於2秒。 我們還經常遇到setTimeout(fn,0)這樣的程式碼,它的含義是,指定某個任務在主執行緒最早的空閒時間執行,意思就是不用再等多少秒了,只要主執行緒執行棧內的同步任務全部執行完成,棧為空就馬上執行。但是即便主執行緒為空,0毫秒實際上也是達不到的。根據HTML的標準,最低是4毫秒。
關於setInterval: 以setInterval(fn,ms)為例,setInterval是迴圈執行的,setInterval會每隔指定的時間將註冊的函式置入Event Queue,不是每過ms秒會執行一次fn,而是每過ms秒,會有fn進入Event Queue。需要注意的一點是,一旦setInterval的回撥函式fn執行時間超過了延遲時間ms,那麼就完全看不出來有時間間隔了。
上面的概念很基礎也很容易理解但不幸的訊息是上面講的一切都不是絕對的正確,因為涉及到Promise、async/await、process.nextTick(node)所以要對任務有更精細的定義:
巨集任務(macro-task):包括整體程式碼script,setTimeout,setInterval。 微任務(micro-task):Promise,process.nextTick。
在劃分巨集任務、微任務的時候並沒有提到async/await因為async/await的本質就是Promise。
事件迴圈機制到底是怎麼樣的? 不同型別的任務會進入對應的Event Queue,比如setTimeout和setInterval會進入相同(巨集任務)的Event Queue。而Promise和process.nextTick會進入相同(微任務)的Event Queue。
- 「巨集任務」、「微任務」都是佇列,一段程式碼執行時,會先執行巨集任務中的同步程式碼。
- 進行第一輪事件迴圈的時候會把全部的js指令碼當成一個巨集任務來執行。
- 如果執行中遇到setTimeout之類巨集任務,那麼就把這個setTimeout內部的函式推入「巨集任務的佇列」中,下一輪巨集任務執行時呼叫。
- 如果執行中遇到 promise.then() 之類的微任務,就會推入到「當前巨集任務的微任務佇列」中,在本輪巨集任務的同步程式碼都執行完成後,依次執行所有的微任務。
- 第一輪事件迴圈中當執行完全部的同步指令碼以及微任務佇列中的事件,這一輪事件迴圈就結束了,開始第二輪事件迴圈。
- 第二輪事件迴圈同理先執行同步指令碼,遇到其他巨集任務程式碼塊繼續追加到「巨集任務的佇列」中,遇到微任務,就會推入到「當前巨集任務的微任務佇列」中,在本輪巨集任務的同步程式碼執行都完成後,依次執行當前所有的微任務。
- 開始第三輪,迴圈往復...
下面用程式碼來深入理解上面的機制:
setTimeout(function() {
console.log('4')
})
new Promise(function(resolve) {
console.log('1') // 同步任務
resolve()
}).then(function() {
console.log('3')
})
console.log('2')
複製程式碼
- 這段程式碼作為巨集任務,進入主執行緒。
- 先遇到setTimeout,那麼將其回撥函式註冊後分發到巨集任務Event Queue。
- 接下來遇到了Promise,new Promise立即執行,then函式分發到微任務Event Queue。
- 遇到console.log(),立即執行。
- 整體程式碼script作為第一個巨集任務執行結束。檢視當前有沒有可執行的微任務,執行then的回撥。 (第一輪事件迴圈結束了,我們開始第二輪迴圈。)
- 從巨集任務Event Queue開始。我們發現了巨集任務Event Queue中setTimeout對應的回撥函式,立即執行。
執行結果:
1 - 2 - 3 - 4
console.log('1')
setTimeout(function() {
console.log('2')
process.nextTick(function() {
console.log('3')
})
new Promise(function(resolve) {
console.log('4')
resolve()
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6')
})
new Promise(function(resolve) {
console.log('7')
resolve()
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9')
process.nextTick(function() {
console.log('10')
})
new Promise(function(resolve) {
console.log('11')
resolve()
}).then(function() {
console.log('12')
})
})
複製程式碼
- 整體script作為第一個巨集任務進入主執行緒,遇到console.log(1)輸出1。
- 遇到setTimeout,其回撥函式被分發到巨集任務Event Queue中。我們暫且記為setTimeout1。
- 遇到process.nextTick(),其回撥函式被分發到微任務Event Queue中。我們記為process1。
- 遇到Promise,new Promise直接執行,輸出7。then被分發到微任務Event Queue中。我們記為then1。
- 又遇到了setTimeout,其回撥函式被分發到巨集任務Event Queue中,我們記為setTimeout2。
- 現在開始執行微任務,我們發現了process1和then1兩個微任務,執行process1,輸出6。執行then1,輸出8。 第一輪事件迴圈正式結束,這一輪的結果是輸出1,7,6,8。那麼第二輪事件迴圈從setTimeout1巨集任務開始:
- 首先輸出2。接下來遇到了process.nextTick(),同樣將其分發到微任務Event Queue中,記為process2。
- new Promise立即執行輸出4,then也分發到微任務Event Queue中,記為then2。
- 現在開始執行微任務,我們發現有process2和then2兩個微任務可以執行輸出3,5。 第二輪事件迴圈結束,第二輪輸出2,4,3,5。第三輪事件迴圈從setTimeout2巨集任務開始:
- 直接輸出9,將process.nextTick()分發到微任務Event Queue中。記為process3。
- 直接執行new Promise,輸出11。將then分發到微任務Event Queue中,記為then3。
- 執行兩個微任務process3和then3。輸出10。輸出12。 第三輪事件迴圈結束,第三輪輸出9,11,10,12。 整段程式碼,共進行了三次事件迴圈,完整的輸出為1,7,6,8,2,4,3,5,9,11,10,12。 (請注意,node環境下的事件監聽依賴libuv與前端環境不完全相同,輸出順序可能會有誤差)
new Promise(function (resolve) {
console.log('1')// 巨集任務一
resolve()
}).then(function () {
console.log('3') // 巨集任務一的微任務
})
setTimeout(function () { // 巨集任務二
console.log('4')
setTimeout(function () { // 巨集任務五
console.log('7')
new Promise(function (resolve) {
console.log('8')
resolve()
}).then(function () {
console.log('10')
setTimeout(function () { // 巨集任務七
console.log('12')
})
})
console.log('9')
})
})
setTimeout(function () { // 巨集任務三
console.log('5')
})
setTimeout(function () { // 巨集任務四
console.log('6')
setTimeout(function () { // 巨集任務六
console.log('11')
})
})
console.log('2') // 巨集任務一
複製程式碼
- 全部的程式碼作為第一個巨集任務進入主執行緒執行。
- 首先輸出1,是同步程式碼。then回撥作為微任務進入到巨集任務一的微任務佇列。
- 下面最外層的三個setTimeout分別是巨集任務二、巨集任務三、巨集任務四按序排入巨集任務佇列。
- 輸出2,現在巨集任務一的同步程式碼都執行完成了接下來執行巨集任務一的微任務輸出3。 第一輪事件迴圈完成了
- 現在執行巨集任務二輸出4,後面的setTimeout作為巨集任務五排入巨集任務佇列。 第二輪事件迴圈完成了
- 執行巨集任務三輸出5,執行巨集任務四輸出6,巨集任務四里面的setTimeout作為巨集任務六。
- 執行巨集任務五輸出7,8。then回撥作為巨集任務五的微任務排入巨集任務五的微任務佇列。
- 輸出同步程式碼9,巨集任務五的同步程式碼執行完了,現在執行巨集任務五的微任務。
- 輸出10,後面的setTimeout作為巨集任務七排入巨集任務的佇列。 巨集任務五執行完成了,當前已經是第五輪事件迴圈了。
- 執行巨集任務六輸出11,執行巨集任務七輸出12。
-^-,這個案例是有點噁心,目的是讓大家明白各巨集任務之間執行的順序以及巨集任務和微任務的執行關係。
初步總結: 巨集任務是一個棧按先入先執行的原則,微任務也是一個棧也是先入先執行。 但是每個巨集任務都對應會有一個微任務棧,巨集任務在執行過程中會先執行同步程式碼再執行微任務棧。
上面的案例只是用setTimeout和Promise模擬了一些場景來幫助理解,並沒有用到async/await下面我們從什麼是async/await開始講起。
async/await是什麼?
我們建立了 promise 但不能同步等待它執行完成。我們只能通過 then 傳一個回撥函式這樣很容易再次陷入 promise 的回撥地獄。實際上,async/await 在底層轉換成了 promise 和 then 回撥函式。也就是說,這是 promise 的語法糖。每次我們使用 await, 直譯器都建立一個 promise 物件,然後把剩下的 async 函式中的操作放到 then 回撥函式中。async/await 的實現,離不開 Promise。從字面意思來理解,async 是“非同步”的簡寫,而 await 是 async wait 的簡寫可以認為是等待非同步方法執行完成。
async/await用來幹什麼?
用來優化 promise 的回撥問題,被稱作是非同步的終極解決方案。
async/await內部做了什麼?
async 函式會返回一個 Promise 物件,如果在函式中 return 一個直接量(普通變數),async 會把這個直接量通過 Promise.resolve() 封裝成 Promise 物件。如果你返回了promise那就以你返回的promise為準。 await 是在等待,等待執行的結果也就是返回值。await後面通常是一個非同步操作(promise),但是這不代表 await 後面只能跟非同步操作 await 後面實際是可以接普通函式呼叫或者直接量的。
await的等待機制?
如果 await 後面跟的不是一個 Promise,那 await 後面表示式的運算結果就是它等到的東西;如果 await 後面跟的是一個 Promise 物件,await 它會“阻塞”後面的程式碼,等著 Promise 物件 resolve,然後得到 resolve 的值作為 await 表示式的運算結果。但是此“阻塞”非彼“阻塞”這就是 await 必須用在 async 函式中的原因。async 函式呼叫不會造成“阻塞”,它內部所有的“阻塞”都被封裝在一個 Promise 物件中非同步執行。(這裡的阻塞理解成非同步等待更合理)
async/await在使用過程中有什麼規定?
每個 async 方法都返回一個 promise 物件。await 只能出現在 async 函式中。
async/await 在什麼場景使用?
單一的 Promise 鏈並不能發現 async/await 的優勢,但是如果需要處理由多個 Promise 組成的 then 鏈的時候,優勢就能體現出來了(Promise 通過 then 鏈來解決多層回撥的問題,現在又用 async/await 來進一步優化它)。
async/await如何使用?
假設一個業務,分多個步驟完成,每個步驟都是非同步的且依賴於上一個步驟的結果。
function myPromise(n) {
return new Promise(resolve => {
console.log(n)
setTimeout(() => resolve(n+1), n)
})
}
function step1(n) {
return myPromise(n)
}
function step2(n) {
return myPromise(n)
}
function step3(n) {
return myPromise(n)
}
如果用 Promise 實現
step1(1000)
.then(a => step2(a))
.then(b => step3(b))
.then(result => {
console.log(result)
})
如果用 async/await 來實現呢
async function myResult() {
const a = await step1(1000)
const b = await step2(a)
const result = await step3(b)
return result
}
myResult().then(result => {
console.log(result)
}).catch(err => {
// 如果myResult內部有語法錯誤會觸發catch方法
})
複製程式碼
看的出來async/await的寫法更加優雅一些要比Promise的鏈式呼叫更加直觀也易於維護。
我們來看在任務佇列中async/await的執行機制,先給出大概方向再通過案例來證明:
- async定義的是一個Promise函式和普通函式一樣只要不呼叫就不會進入事件佇列。
- async內部如果沒有主動return Promise,那麼async會把函式的返回值用Promise包裝。
- await關鍵字必須出現在async函式中,await後面不是必須要跟一個非同步操作,也可以是一個普通表示式。
- 遇到await關鍵字,await右邊的語句會被立即執行然後await下面的程式碼進入等待狀態,等待await得到結果。 await後面如果不是 promise 物件, await會阻塞後面的程式碼,先執行async外面的同步程式碼,同步程式碼執行完,再回到async內部,把這個非promise的東西,作為 await表示式的結果。 await後面如果是 promise 物件,await 也會暫停async後面的程式碼,先執行async外面的同步程式碼,等著 Promise 物件 fulfilled,然後把 resolve 的引數作為 await 表示式的運算結果。
setTimeout(function () {
console.log('6')
}, 0)
console.log('1')
async function async1() {
console.log('2')
await async2()
console.log('5')
}
async function async2() {
console.log('3')
}
async1()
console.log('4')
複製程式碼
- 6是巨集任務在下一輪事件迴圈執行
- 先同步輸出1,然後呼叫了async1(),輸出2。
- await async2() 會先執行async2(),5進入等待狀態。
- 輸出3,這個時候先執行async函式外的同步程式碼輸出4。
- 最後await拿到等待的結果繼續往下執行輸出5。
- 進入第二輪事件迴圈輸出6。
console.log('1')
async function async1() {
console.log('2')
await 'await的結果'
console.log('5')
}
async1()
console.log('3')
new Promise(function (resolve) {
console.log('4')
resolve()
}).then(function () {
console.log('6')
})
複製程式碼
- 首先輸出1,然後進入async1()函式,輸出2。
- await後面雖然是一個直接量,但是還是會先執行async函式外的同步程式碼。
- 輸出3,進入Promise輸出4,then回撥進入微任務佇列。
- 現在同步程式碼執行完了,回到async函式繼續執行輸出5。
- 最後執行微任務輸出6。
async function async1() {
console.log('2')
await async2()
console.log('7')
}
async function async2() {
console.log('3')
}
setTimeout(function () {
console.log('8')
}, 0)
console.log('1')
async1()
new Promise(function (resolve) {
console.log('4')
resolve()
}).then(function () {
console.log('6')
})
console.log('5')
複製程式碼
- 首先輸出同步程式碼1,然後進入async1方法輸出2。
- 因為遇到await所以先進入async2方法,後面的7處於等待狀態。
- 在async2中輸出3,現在跳出async函式先執行外面的同步程式碼。
- 輸出4,5。then回撥進入微任務棧。
- 現在巨集任務執行完了,執行微任務輸出6。
- 然後回到async1函式接著往下執行輸出7。
setTimeout(function () {
console.log('9')
}, 0)
console.log('1')
async function async1() {
console.log('2')
await async2()
console.log('8')
}
async function async2() {
return new Promise(function (resolve) {
console.log('3')
resolve()
}).then(function () {
console.log('6')
})
}
async1()
new Promise(function (resolve) {
console.log('4')
resolve()
}).then(function () {
console.log('7')
})
console.log('5')
複製程式碼
- 先輸出1,2,3。3後面的then進入微任務佇列。
- 執行外面的同步程式碼,輸出4,5。4後面的then進入微任務佇列。
- 接下來執行微任務,因為3後面的then先進入,所以按序輸出6,7。
- 下面回到async1函式,await關鍵字等到了結果繼續往下執行。
- 輸出8,進行下一輪事件迴圈也就是巨集任務二,輸出9。
async function async1() {
console.log('2')
const data = await async2()
console.log(data)
console.log('8')
}
async function async2() {
return new Promise(function (resolve) {
console.log('3')
resolve('await的結果')
}).then(function (data) {
console.log('6')
return data
})
}
console.log('1')
setTimeout(function () {
console.log('9')
}, 0)
async1()
new Promise(function (resolve) {
console.log('4')
resolve()
}).then(function () {
console.log('7')
})
console.log('5')
複製程式碼
- 函式async1和async2只是定義先不去管他,首先輸出1。
- setTimeout作為巨集任務進入巨集任務佇列等待下一輪事件迴圈。
- 進入async1()函式輸出2,await下面的程式碼進入等待狀態。
- 進入async2()輸出3,then回撥進入微任務佇列。
- 現在執行外面的同步程式碼,輸出4,5,then回撥進入微任務佇列。
- 按序執行微任務,輸出6,7。現在回到async1函式。
- 輸出data,也就是await關鍵字等到的內容,接著輸出8。
- 進行下一輪時間迴圈輸出9。
執行結果:
1 - 2 - 3 - 4 - 5 - 6 - 7 - await的結果 - 8 - 9
setTimeout(function () {
console.log('8')
}, 0)
async function async1() {
console.log('1')
const data = await async2()
console.log('6')
return data
}
async function async2() {
return new Promise(resolve => {
console.log('2')
resolve('async2的結果')
}).then(data => {
console.log('4')
return data
})
}
async1().then(data => {
console.log('7')
console.log(data)
})
new Promise(function (resolve) {
console.log('3')
resolve()
}).then(function () {
console.log('5')
})
複製程式碼
- setTimeout作為巨集任務進入巨集任務佇列等待下一輪事件迴圈。
- 先執行async1函式,輸出1,6進入等待狀態,現在執行async2。
- 輸出2,then回撥進入微任務佇列。
- 接下來執行外面的同步程式碼輸出3,then回撥進入微任務佇列。
- 按序執行微任務,輸出4,5。下面回到async1函式。
- 輸出了4之後執行了return data,await拿到了內容。
- 繼續執行輸出6,執行了後面的 return data 才觸發了async1()的then回撥輸出7以及data。
- 進行第二輪事件迴圈輸出8。
執行結果:
1 - 2 - 3 -4 - 5 - 6 - 7 - async2的結果 - 8
案例有點多主要為了以後回顧,如果大家覺得我的理解有偏差歡迎指正。
緩緩先...
-^-