引言
首先需要知道的是JavaScript是門的語言。之所以如此設計,是因為JavaScript主要應用于瀏覽器的互動,即操作DOM。所以一次只能完成一件任務。如果有多個任務,就必須排隊,前面一個任務完成,再執行后面一個任務,以此類推。我們假設JavaScript是多線程的,那么多個線程同時進行,兩個線程同時操作一個DOM,那么以誰的操作為準呢?
是當代碼需要進行一項異步任務(無法立刻返回結果,需要花一定時間才能返回的任務,如I/O事件)的時候,主線程會掛起(pending)這個任務,然后在異步任務返回結果的時候再根據一定規則去執行相應的回調。
單線程雖然實現起來比較簡單,執行環境相對單純;但是只要有一個任務耗時很長,后面的任務都必須排隊等著,會拖延整個程序的執行。因此為了解決這個問題Javascript語言將任務的執行模式分成兩種:同步(Synchronous)和異步(Asynchronous)。
同步模式
就是后一個任務等待前一個任務結束,然后再執行,程序的執行順序與任務的排列順序是一致的、同步的。
異步模式
每一個任務有一個或多個回調函數(callback),前一個任務結束后,不是執行隊列上的后一個任務,而是執行回調函數;后一個任務則是不等前一個任務的回調函數的執行而執行,所以程序的執行順序與任務的排列順序是不一致的、異步的。
"異步模式"非常重要。在瀏覽器端,耗時很長的操作都應該異步執行,避免瀏覽器失去響應,最好的例子就是Ajax操作。在服務器端,"異步模式"甚至是唯一的模式,因為執行環境是單線程的,如果允許同步執行所有http請求,服務器性能會急劇下降,很快就會失去響應。
JavaScript為何能執行異步任務
Javascript是單線程的,但是卻能執行異步任務,這主要是因為 JS 中存在(Event Loop)和
(Task Queue)。
示例
setTimeout(function(){
console.log(2);
},0);
new Promise(function(resolve){
console.log(3);
resolve();
console.log(4);
}).then(function(){
console.log(5);
});
console.log(6);
setTimeout(function(){
console.log(7);
},0);
事件循環(Event Loop)
JS 會創建一個類似于 while (true) 的循環,每執行一次循環體的過程稱之為Tick。每次Tick的過程就是查看是否有待處理事件,如果有則取出相關事件及回調函數放入執行棧中由主線程執行。待處理的事件會存儲在一個任務隊列中,也就是每次Tick會查看任務隊列中是否有需要執行的任務。
示例
while (queue.waitForMessage()) {
queue.processNextMessage();
}
事件循環會按照上圖所示的模式進行操作,queue.waitForMessage() 會同步地等待消息到達(如果當前沒有任何消息等待被處理)。
任務隊列
和事件循環聯系在一起的是任務隊列,
-所有同步任務都在主線程上執行,形成一個執行棧(execution context stack)。
-主線程之外,還存在一個”任務隊列”(task queue)。只要異步任務有了運行結果,就在”任務隊列”之中放置一個事件。
-一旦”執行棧”中的所有同步任務執行完畢,系統就會讀取”任務隊列”,看看里面有哪些事件。那些對應的異步任務,于是結束等待狀態,進入執行棧,開始執行。
-主線程不斷重復上面的第三步。
異步任務
異步操作會將相關回調添加到任務隊列中。而不同的異步操作添加到任務隊列的時機也不同,如onclick, setTimeout,ajax 處理的方式都不同,這些異步操作是由瀏覽器內核的webcore來執行的,webcore包含下圖中的3種 webAPI,分別是DOM Binding、network、timer模塊。
-DOM Binding 模塊處理一些DOM綁定事件,如onclick事件觸發時,回調函數會立即被-webcore添加到任務隊列中。
-network 模塊處理Ajax請求,在網絡請求返回時,才會將對應的回調函數添加到任務隊列中。
-timer 模塊會對setTimeout等計時器進行延時處理,當時間到達的時候,才會將回調函數添加到任務隊列中。
事件循環和任務隊列之間的關系
規范中中提到,一個瀏覽器環境,只能有一個事件循環,而一個事件循環可以多個任務隊列,每個任務都有一個任務源(Task source)。相同任務源的任務,只能放到一個任務隊列中。
不同任務源的任務,可以放到不同任務隊列中。
簡單來說:一個事件循環可以有多個任務隊列,隊列之間可有不同的優先級,同一隊列中的任務按先進先出的順序執行,但是不保證多個任務隊列中的任務優先級,具體實現可能會交叉執行。
不同任務隊列的優先級
在異步代碼測試結果的圖中看到,代碼的執行順序并不是按著,代碼書寫順序依次執行的,這是因為不同的異步任務之間也有優先級的區別。異步任務分為兩類,macrotask(宏任務)和 microtask(微任務)
宏任務(macro task)
script(你的全部JS代碼,“同步代碼”), setTimeout, setInterval, setImmediate, I/O,UI rendering
微任務(micro task)
process.nextTick,Promises(這里指瀏覽器原生實現的 Promise), Object.observe, MutationObserver
執行順序
瀏覽器為了能夠使得JS內部task與DOM任務能夠有序的執行,會在一個task執行結束后,在下一個 task 執行開始前,對頁面進行重新渲染 (task->渲染->task->...),鼠標點擊會觸發一個事件回調,需要執行一個宏任務,然后解析HTMl。微任務通常來說就是需要在當前 task 執行結束后立即執行的任務,比如對一系列動作做出反饋,或或者是需要異步的執行任務而又不需要分配一個新的 task,這樣便可以減小一點性能的開銷。所有微任務總會在下一個宏任務之前全部執行完畢。
所以,瀏覽器環境中,js執行任務的流程是這樣的:
1.第一個事件循環,先執行script中的所有同步代碼(即 macrotask 中的第一項任務)
2.再取出 microtask 中的全部任務執行(先清空process.nextTick隊列,再清空promise.then隊列)
3.下一個事件循環,再回到 macrotask 取其中的下一項任務
4.再重復2
5.反復執行事件循環…
一些常見的異步任務
setTimeout()
將事件插入到了事件隊列,必須等到當前代碼(執行棧)執行完,主線程才會去執行它指定的回調函數。
當主線程時間執行過長,無法保證回調會在事件指定的時間執行。
瀏覽器端每次setTimeout會有4ms的延遲,當連續執行多個setTimeout,有可能會阻塞進程,造成性能問題。
setImmediate()
事件插入到事件隊列尾部,主線程和事件隊列的函數執行完成之后立即執行。和setTimeout(fn,0)的效果差不多。
服務端node提供的方法。瀏覽器端最新的api也有類似實現:window.setImmediate,但支持的瀏覽器很少。
process.nextTick()
插入到事件隊列尾部,但在下次事件隊列之前會執行。也就是說,它指定的任務總是發生在所有異步任務之前,當前主線程的末尾。
大致流程:當前”執行棧”的尾部–>下一次Event Loop(主線程讀取”任務隊列”)之前–>觸發process指定的回調函數。
服務器端node提供的辦法。用此方法可以用于處于異步延遲的問題。
可以理解為:此次不行,預約下次優先執行。
Promise
Promise本身是同步的立即執行函數, 當在 executor 中執行 resolve 或者 reject 的時候, 此時是異步操作, 會先執行 then/catch 等,當主棧完成后,才會去調用 resolve/reject 中存放的方法執行,打印 p 的時候,是打印的返回結果,一個 Promise 實例。
async await
Async/Await就是一個自執行的generate函數。利用generate函數的特性把異步的代碼寫成“同步”的形式。
async 函數返回一個 Promise 對象,當函數執行的時候,一旦遇到 await 就會先返回,等到觸發的異步操作完成,再執行函數體內后面的語句。可以理解為,是讓出了線程,跳出了 async 函數體。
參考資料
https://blog.csdn.net/happyqyt/article/details/90644667
https://blog.csdn.net/github_35549695/article/details/82390345
https://www.cnblogs.com/nayek/p/11729923.html
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/EventLoop#%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF