「詭異」是我對 JavaScript 異步機制的第一印象,這裏的詭異打了雙引號,並無任何貶義。javascript
跟不少前端不同,我是一名 Java 的開發者,當我剛開始接觸 JavaScript 的時候,我有一種既熟悉又陌生的感受,剛上手以爲很熟悉,越瞭解,越陌生,他們兩個的關係就真的如:雷鋒和雷峯塔的區別同樣。前端
最讓我陌生的莫過因而,就是 JavaScript 的異步機制了。java
我帶着原來 Java 併發和多線程的經驗,嘗試去理解的時候,讓我摸不着頭腦的是:單線程異步,我滿腦子的問號,由於個人經驗告訴我,單線程怎麼可能能夠異步嘛!面試
我閱讀了不少關於 JavaScript 異步的文章深刻理解後,我腦子裏的問號變成了感嘆號,實在太巧妙了。但更讓我以爲很是意外的是,你們好像對 JavaScript 的這種異步機制都習覺得常,好像你們都是「這不是理所固然的嗎?」,我內心就想,難道只有我才以爲詭異嗎?segmentfault
我曾經也想把這種疑惑寫下來,但好像寫着寫着,最後都變成了科普式 what,why,how 的方式來寫,我很難形容出這種詭異的感受,後來發現也有很多人有我這樣的迷惑,他們大多都來自別的多線程併發的語言體系。瀏覽器
我意識到或許應該先以初學者的試錯的方式來展開這個討論。bash
因而我決定,把我以前碰到的那種詭異的感受,以及我當時懷疑,並接着探索和發現真相的這種過程,記錄下來,這纔是我學習的故事嘛。session
因此這篇文章,並非要說明誰優誰劣,而是想讓你們瞭解一下,別的語言體系的人是怎麼理解這一過程的。數據結構
首先讓我對 JavaScript 的異步產生好奇的第一印象就是 setTimeout了,你們都知道它是用來延時一段時間才執行的做用。多線程
但在相似 Java 這種語言體系裏,咱們用的是 sleep,就是把當前程序的線程主動的休眠(阻塞),以達到等待一段時間的做用。
例如,先打印Start
,隔1秒再打印First
,最後在程序結束打印End
。
先打印 Start
隔一秒打印 First
而後立馬打印 End
複製代碼
在 Java 裏面是這麼實現的。
public class SleepDemo {
public static void main(String[] args) {
System.out.println("Start");
try {
Thread.sleep(1000); // 整個程序會阻塞在這個位置
System.out.println("First"); // 接下來打印 First
} catch (Exception e) {
System.out.println(e);
}
System.out.println("End"); // 緊接着就會打印 End
}
}
複製代碼
然而我剛開始使用 JavaScript 的時候,當我想相似的功能時,我發現竟然沒有 sleep,它只有一個叫 setTimeout()
的相似東西。
因而我寫下這一段。
console.log('Start');
setTimeout(() => console.log('First'), 1000);
console.log('End');
複製代碼
我就遇到了不少人剛開始學 JavaScript 遇到的經典問題了。
Start
End
First
複製代碼
根據個人經驗,我很快就改正過來了:
console.log('Start');
setTimeout(() => {
console.log('First');
console.log('End');
}, 1000);
複製代碼
個人第一直覺是,setTimeout
不就是 Java 裏面的 Timer
嘛。
Java 的話,大概就是這個感受:
import java.util.Timer;
import java.util.TimerTask;
public class TimerDemo {
public static void main(String[] args) {
System.out.println("Start");
Timer timer = new Timer();
timer.schedule(new TimerTask(){ // Timer 實際是開啓了一個線程
@Override
public void run() {
System.out.println("First");
System.out.println("End");
}
}, 1000);
}
}
複製代碼
好了,基於我對Timer
的理解和對setTimeout
的猜測,我對 JavaScript 的異步機制的初步假設是:
JavaScript 的單線程異步:實際上就是把多線程的實現隱藏起來了,它的異步仍是仍是經過多線程實現的
就如 Java 雖然說無指針,但仍是到處用到了指針一個道理同樣。
按照這個假設,由於setTimeout
是在另一個線程裏面執行的,那麼,若是主線程被阻塞了,應該也不會影響其餘線程的代碼。
爲了證實這個假設,我開始作一些小實驗。
因爲啓動一個線程,確定會耗費必定時間,因此我須要在啓動一個新線程以後,在主線程上增長一些阻塞,這樣才能夠把 First
出如今 End
前面,爲了更直觀的看到前後順序,我增長了時間間隔。
例如在 Java,就是這樣實現的:
import java.util.Date;
import java.util.Timer;
import java.util.TimerTask;
public class BlockDemo {
public static void main(String[] args) {
long startTime = new Date().getTime();
System.out.println("Start");
Timer timer = new Timer();
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println("First: " + (new Date().getTime() - startTime));
}
}, 0);
for (long i = 0; i < 10e8; i++) {}
System.out.println("End: " + (new Date().getTime() - startTime));
}
}
複製代碼
程序運行結果:
Start
First: 1
End: 1267
複製代碼
Java 代碼的運行結果是符合我預期的,因而我也嘗試瞭如下的 JavaScript 代碼:
const startTime = new Date().getTime();
console.log('Start');
setTimeout(() => {
console.log('First: ' + (new Date().getTime() - startTime));
}, 0); // 我這裏設成了0,爲的就是讓 First 出如今 End 前面
for (let i = 0; i < 10e8; i++) {} // 這裏只會小小的阻塞一下主線程
console.log('End: ' + (new Date().getTime() - startTime));
複製代碼
若是按照個人假設,主線程被阻塞一小段時間,而setTimeout
並不會被影響到。
但結果竟然是這樣的:
Start
End: 827
First: 829
複製代碼
我一開始以爲,多是多線程並行不肯定性引發的,可是,我嘗試了不少次,並且把阻塞運算量再調大不少倍,除了後面的時間數值變化,打印的順序卻絲毫沒有改變,我懵逼了。
經過上述實驗,我有理由能夠相信:End
必定會出如今 First
以後。
個人假設錯了,所以我有必要深挖一下 JavaScript 異步機制的祕密到底是什麼?
網上有很是多的 JavaScript 的運行原理的文章,我是經過 SessionStack 整個系列加上本身的總結來理解的。
例如:他們有一張圖基本囊括了瀏覽器環境的 JavaScript Runtime
setTimeout
的機制竟然是運行在 JavaScript 引擎之外的東西,它是屬於整個
JavaScript Runtime
整套體系下面,不只僅是 JavaScript 引擎中。
整個瀏覽器的 JavaScript Runtime
分爲:
DOM
的操做,AJAX
,Timeout
等實際上調用的都是這裏提供的Web APIs
裏面的回調函數,實際上都是放在這裏排隊的Call Stack
是JavaScript Engine
中最重要的一個環節,貫穿着整個函數調用的過程,但Call Stack
自己不是什麼新鮮東西,它原本就是一種很常見的數據結構,也是程序運行基本原理的重要組成部分。我這裏就再也不贅述它的基本概念了,你們能夠看 MDN 裏面的更詳細的解釋 Call Stack。
我這裏關注的是,JavaScript 之因此稱之爲單線程,最重要的就是它只有一個Call Stack
,是的,像 Java 這類語言中,多線程就會有多個Call Stack
。
整個 JavaScript 都是圍繞單個Call Stack
來展開。
讓我很是意外的是,原來setTimeout
等之類的全部異步的操做其實都歸屬於Web APIs
裏面。
JavaScript 的異步機制之因此巧妙,就是JavaScript Engine
是單線程的,可是JavaScript Runtime
就是瀏覽器並非單線程的,全部異步的操做的確是經過瀏覽器在別的線程完成,可是Callback
自己仍是在Call Stack
中完成的。
簡單理解,Callback Queue
就是全部 Web APIs
在別的線程裏面執行完以後,放到這裏排隊的一個隊列。
例如,setTimeout
的時間計算是在別的線程完成的,等到時間到了以後,就把這個callback
放在隊列裏面排隊。
終於要來到大名鼎鼎的EventLoop
,在前端的 EventLoop
的地位已經差很少和 Java中GC
同樣成爲各種面試必考知識點。
EventLoop
的做用就是,經過一個循環,不斷的把Callback Queue
裏面的callback
壓進了JavaScript Engine
中的Call Stack
中執行。
因爲 JavaScript Engine
只有單線程,因此一旦裏面的函數發生了阻塞,運算量過大,就會堵塞,後面的全部操做都會等待。
怎麼理解這張圖呢?我用了geekartt
做者給出的一個案例(我做了一些優化):
這個銀行,只有一個櫃員,而這個櫃員就是 JavaScript引擎
,用戶要辦理的事項就是程序代碼,櫃員她會把這些事項分解爲一個個任務小紙條(函數
),插入一個插紙針(Call Stack
)上,而後她先把最上面的那個取出來處理,若是這個任務須要外部協做,她會立馬發送給外部處理(Web APIs
),外部處理完後,會把要反饋的任務,自動投進一個排隊的箱子(Callback Queue
)裏面,而後,這個箱子會不斷的檢測櫃員的插紙針(EventLoop
),一旦發現插紙針是空的,它就立馬把箱子裏的任務小紙條,插到櫃員的插紙針上。
插紙針(Call Stack
),長這個樣,最早插入的紙條最後才處理,典型的先進後出:
這裏我用了SessionStack
裏面setTimeout
中的例子:
console.log('Hi');
setTimeout(function cb1() {
console.log('cb1');
}, 5000);
console.log('Bye');
複製代碼
我如今能夠這麼理解了:
console.log('Hi')
壓入棧console.log('Hi)
拿出來執行Hi
,彈出棧setTimeout
壓入棧setTimeout
拿出來執行,這裏的執行只是,調用了Web APIs
,並把回調cb1
和時間參數5000
傳遞過去,而後彈出棧Web APIs
立馬開啓了一個定時器,並開始計時console.log('Bye')
壓入棧內console.log('Bye')
拿出來執行Bye
,彈出棧Web APIs
把cb1
放到隊列裏面排隊EventLoop
在下一次循環立馬就把cb1
壓入了棧內cb1
分解,把裏面的console.log('cb1')
也壓入棧內console.log('cb1')
cb1
,彈出棧cb1
,彈出棧下面有個更直觀的圖:
Web APIs
中的操做都是別的瀏覽器線程完成的,爲了證實這一點,我從新作一次驗證。
我仍是回到剛剛的例子裏面,增長了時間輸出,證實:定時器的運算是否是與for
循環是同一時間執行的
const startTime = new Date().getTime();
console.log('Start');
setTimeout(() => {
console.log('First: ' + (new Date().getTime() - startTime));
}, 1000); // 能夠在這裏嘗試0和1000或者其餘時間的區別
for (let i = 0; i < 10e8; i++) {}
console.log('End: ' + (new Date().getTime() - startTime));
複製代碼
若是setTimeout
的時間參數爲0,在個人PC下是這樣的,因此我預計在個人PC下,for
循環的運算時間大概是800毫秒,因此下面的時間要大於800毫秒便可。
Start
End: 827
First: 829
複製代碼
若是setTimeout
的時間參數爲1000,大概是這樣的:
Start
End: 821
First: 1008
複製代碼
程序結果是符合預期的。
這個時候再來回到Why
,就是爲何 JavaScript 要選用這麼一種方法來實現異步?
GUI
的開發領域(例如:Android,GTK,QT),單線程 + 消息隊列是很是常見的作法,而 JavaScript 就是爲了GUI
開發而誕生的,只是它運行在瀏覽器上worker
能夠操做多線程,可是本質並無改變Call Stack
,這些都是有成本的,在計算資源匱乏的那個年代,JavaScript 的單線程或許能更好的節省計算機的資源的確最開始的 JavaScript 是處理一些很簡單的表單,可是如今 JavaScript 已經誕生了二十多年了,如今依然還有很是強勁的生命力,證實 JavaScript 的機制在必定程度上它是很是優秀的。
回到了剛剛第一個錯誤的假設:實際上 JavaScript 的單線程異步機制,就是把多線程的實現隱藏起來了,它的異步仍是仍是經過多線程實現的
如今看起來,這個結論有一部分是正確的,由於的確,Web APIs
是在別的線程中完成的。
但事情已經很不同了,我是按照之前 Java 多線程併發的經驗去理解的,而如今我真正的理解了 JavaScript 異步機制以後,對個人衝擊仍是挺大的。
這種感受就像是:曾經你覺得全世界的豆花都是甜的,也理應是甜的,然而忽然有一天,有人告訴你,豆花也有鹹的。可是衝擊個人不是,甜仍是鹹,而是我原來對豆花的概念從新認識。
我曾經是把異步和多線程劃上了等號,這是我原來的固有認識。
How JavaScript works: an overview of the engine, the runtime, and the call stack