單線程的JavaScript如何經過事件循環實現異步

前言

當談及Javascript時, 咱們經常聽到 單線程、異步非阻塞、事件循環這樣的關鍵詞,然而它們是什麼? 爲何單線程還能夠實現異步?怎麼實現?相信這些問題都曾今或正在困擾着許多前端愛好者。經過這篇文章咱們將對它們一一梳理。文章將講解:javascript

  • 什麼是同步和異步
  • 單線程的JS如何經過事件循環實現異步
  • 這些機制是如何影響 web 應用的性能的
  • setTimeout 與 setInverval 爲何不許時

若是你對它們感興趣,就請繼續往下讀吧。前端

什麼是同步和異步

籠統抽象地說:java

  • 同步 (Synchronous):同一時間只作一件事,作完一件事纔開始下一件。
  • 異步 (Asynchronous):同時能夠作N件事,不必定要作完一件才能開始下一件。

回到編程的世界裏具體地說:web

  • 同步:代碼是一行一行執行的,一行完成了纔會跳到下一行。
  • 異步:某一行代碼還沒完成,咱們可讓他執行着先,但咱們能夠開始下一行。也就實現了咱們所謂的並行。

用圖表達:編程

因爲步驟2 的執行時間較長,在同步執行過程當中他會阻塞步驟3的執行一段時間,反之在異步的機制中,若是咱們標明瞭步驟2是異步的, 那麼在完成步驟1以後咱們只會開始執行步驟二並讓他在另外一個世界裏執行,而後馬上開始執行步驟3.
imagepromise

用JavaScript來模擬上面的過程:瀏覽器

const step1 = () => console.log(1);
const step3 = () => console.log(3);
const step4 = () => console.log(4);

// 用 Promise 簡單模擬一個執行時間爲1秒,並在結束時候打印2的函數
// 若是你不瞭解Promise,不要緊,蓋住函數的內容,只要記得它執行時間很長,會在結束時打印2,並會告訴你」我執行完了「
const step2 = () =>
  new Promise((resolve, _) => {
    setTimeout(() => {
      console.log(2);
      resolve();
    }, 1000);
  });

// 異步機制執行
// JS引擎支持返回Promise的函數異步執行,因此咱們不須要作額外的包裝,直接執行step2,它即是異步的)
const asyncExecute = () => {
  step1();
  step2();
  step3();
  step4();
};

// 同步機制執行
// 若是你不瞭解async、await沒關係,只要知道 await 就是要等到它後面的語句執行完了纔會進行下一步
const syncExecute = async () => {
  step1();
  await step2();
  step3();
  step4();
};

asyncExecute(); // 打印 1 3 4 2
syncExecute(); // 打印 1 2 3 4

同步與異步各有優勢,同步能夠保證執行的順序,異步能夠保證程序的非阻塞。 在一個web應用中,若是咱們把向服務端請求一個資源當作一個時間很長的步驟,那麼處理這個請求返回信息就須要咱們去同步執行。然而在若是咱們在請求資源的同時還想讓用戶能夠繼續使用咱們的應用,那這就須要異步地去實現。 Javascript 和他的引擎給咱們提供豐富的資源去實現這兩種機制。數據結構

講到這裏,也許還有些抽象,但相信在下面的章節裏,這一切會變得愈來愈清晰。異步

單線程的JS如何經過事件循環實現異步

如何理解 "JavaScript 是單線程的"

首先咱們來理解幾個概念:async

  • JS運行時環境 (JS runtime environment): 一個JS代碼運行的環境。瀏覽器和Node裏都有。
  • JS引擎 (JS engine):一個能夠編譯並執行JS的程序,執行中的它是運行時環境的一部分。
  • 線程 (Thread):操做系統執行和調度的最小單元,它也是一個程序進行自我分割而後並行執行的最小單元。

在一個JS運行時環境中,JS 代碼只在一個線程中執行, 因此咱們說 JS 是單線程的。然而運行時環境自己(好比在瀏覽器中)並非單線程的,他包含了JS引擎的運行、一系列的web API 調用、以及咱們後面要講到的事件循環機制的運行等。

若是說線程是程序自我分割、並行執行的最小單元,那在單個線程裏執行的JS代碼又怎麼可能實現並行,也就是異步呢?

事件循環實現異步

假設咱們在Chrome瀏覽器中,事件循環的機制能夠用這樣一張圖來解釋。
image

在這裏咱們須要記住五個模塊:

  • 堆 (Heap / Memory Heap)
  • 棧 (Stack / Call stack)
  • Web API
  • 回調函數隊列(Callback queue / Message queue)
  • 事件循環(Event loop)

接下來讓咱們一一解釋

堆 (Heap / Memory Heap)

當 JS 引擎解析JS代碼的過程當中遇到一些變量或者函數申明的時候,它會將它們存儲到裏面。

棧 (Stack / Call stack)

  • 咱們也叫它 Call stack,是一個LIFO-last in first out (後進先出) 的數據結構。
  • 每當 JS 引擎要調用一個函數是就會把這個調用放到 stack 的頂端,而後開始解析執行函數裏面的內容。當函數返回一個值了或者全部內容都執行完了(若是沒有返回值就會默認返回 undefined ),引擎就會把這個調用從stack頂端刪除, 而後繼續執行它下面的函數。

舉個例子:

const func2 = ()=> {
  console.log("我是 func2 ")
}
const func1 = () => {
  console.log("func1 開始了")
  func2();
  console.log("func1 結束了")
}
func1();
// 打印:
// func1 開始了
// 我是 func2 
// func1 結束了

執行這段代碼時,引擎就會先調用 func1, 將它的調用放到stack裏,而後執行func1 中第一行打印。而後執行第二行 調用 func2。這時引擎會把 func2調用放到stack的頂端(如大圖中所示),而後執行 func2 的內容也就是打印。結束以後,由於func2 中沒有更多的內容,引擎會刪除stack頂端的func2 的調用,而後繼續執行func1 第三行,當第三行結束完畢,引擎刪除stack中func1 的調用。最後咱們會看到這段程序的打印如代碼最後的註釋中所示。

Web API

  • Web API 在這裏是有瀏覽器提供的一些API (若是你在Node 環境中,那就會是由Node提供的一些API),它們經常是異步調用的。
  • 它們會接受一個參數叫「回調函數」(Callback), 這個函數會在API觸發某個事件時執行。
  • 當Stack頂端的函數調用API時,API 的內容就會在另外的線程中執行(原來所謂的並行仍是用到了多個線程)。API在監聽到某些事件時會把對應的回調函數放到一個回調函數隊列裏。這個事件能夠是一個http請求的結束,一個 Timer倒計時結束,或者一個鼠標點擊事件。

回調函數隊列 (Callback queue)

  • 回調函數隊列是一個 FIFO-first in first out(先進先出)的數據結構。它按放入順序存儲了須要執行的回調函數。
  • 當 Stack 空了的時候,隊列中第一個回調函數就會被放到 Stack 裏去調用。

事件循環(Event loop)

  • 事件循環是一種機制,它被運行在在 JS 運行時環境中
  • 它不斷地去檢查 Stack 和 回調函數隊列, 當 Stack 空了,它就會馬上通知回調函數隊列把第一個函數發送過去。
  • 有時候Stack和回調函數隊列可能都會空着一段時間,但這個檢查是不會所以而停下來的。

用代碼舉例

callback1 = () => console.log("我是 callback1");
callback2 = () => console.log("我是 callback2");

const func2 = () => {
  console.log("func2 開始");
  // setTimeout 就是一個異步調用的 Timer API, 他會讓 Timer 計時必定的時間,好比這裏是1秒,而後觸發計時結束,隨後callback將會被放入 callback queue
  setTimeout(callback2, 1000);
  console.log("func2 結束");
};

const func1 = () => {
  console.log("func1 開始");
  setTimeout(callback1, 0);
  func2();
  console.log("func1 結束");
};

func1();

// 打印
// func1 開始
// func2 開始
// func2 結束
// func1 結束
// 我是 callback1
// 我是 callback2

咱們來講說這段代碼是怎麼在在剛纔解釋的機制下執行的(超長!若是你已經理解了能夠跳過這段 ^^):

  1. func1被放入stack,並開始執行裏面的內容
  2. 執行funct1 第一行:打印 "func1 開始"
  3. 執行func1 第二行:setTimeout的調用被放入stack
  4. 在別的線程裏, 一個計時API 被調用 (在0秒後將callback1放入回調函數隊列) 因此callback1馬上會被放入回調函數隊列
  5. 將setTimeout從stack中刪除。

    因爲4和5是在兩個線程裏執行的,因此咱們能夠把它們當作幾乎是同時執行的。

  6. 執行func1 第三行:func2被放入stack
  7. 執行func2 第一行:打印 "func2 開始"
  8. 執行func2 第二行:setTimeout的調用被放入stack
  9. 在別的線程裏, 一個計時API 被調用 (在1秒後將callback2放入回調函數隊列)
  10. 將setTimeout從stack中刪除。

    因爲9和10是在兩個線程裏執行的,因此咱們能夠把它們當作幾乎是同時執行的

  11. 執行func2 第三行:打印"func2 結束"
  12. 將func2 的調用從stack中刪除
  13. 執行func1 第三行:打印"func1 結束"
  14. 將func1 的調用從stack中刪除
  15. 事件循環機制發現stack空了,發消息給回調函數隊列
  16. callback1被取出並放入stack執行
  17. 執行callback1第一行:打印 "我是 callback1"
  18. 將callback1的調用從stack中刪除
  19. 事件循環機制發現stack空了,發消息給回調函數隊列,但隊列中什麼也沒有,因此什麼也不作
  20. 第9步中的計時1秒時間到,callback2被放入回調函數隊列
  21. 事件循環機制發現stack空了,發消息給回調函數隊列
  22. Callback2被取出並放入stack執行
  23. 執行callback2第一行:打印 "我是 callback2"
  24. 將callback1的調用從stack中刪除

文字表現比較侷限,咱們能夠按步驟動手畫一畫,就很是清晰了。

總結

  • 全部 JS 代碼都運行在一個線程裏,它們在一個stack裏同步(synchronous)執行。
  • 瀏覽器JS 運行時環境中包含 JS引擎(內有heap 、stack)、WebAPI、回調函數隊列、事件循環機制,它們一同協做實現了JS 的異步。
  • 一些咱們認爲時間長或須要並行執行的內容,實際上是由運行時環境提供的WebAPI來完成的,它們是在其餘的線程裏完成的。
  • 回調函數和異步調用WebAPI的機制讓 JS 能夠不阻塞地運行下去,到必定的時候再執行回調函數。

這些機制是如何影響 web 應用的性能的

Web應用的性能是個很大的話題,在這裏咱們只討論性能中與 JS 的單線程和異步相關的部分。

首先提幾個概念做爲準備:

  • 大部分狀況下瀏覽器是16.66ms一幀, 也就是每16.66ms刷新(或者說render)一次你屏幕上的內容。
  • render (其中包括一些計算工做) 也是在 JS 的線程裏進行的。
  • render 相似一個咱們上一節提到的callback, 在 Stack 沒有被清空的狀況下是無法被放入執行的。

那若是Stack中有一個function執行時間超過16.66ms 會怎麼樣?答案是它會致使下一個render的推遲執行。在這個function結束前,頁面是停在一個靜止的狀態的,用戶在頁面上點擊也不會有什麼反應。這就是咱們有時會感覺到的 「頁面有點卡」。因此爲了防止這種性能差的表現,咱們不建議將耗時的function放到 JS 的主線程裏執行

其實因爲render自己的執行也須要消耗時間,因此咱們還要給它留出空間。根據谷歌的官方文檔,咱們最好是將本身的邏輯保證在10ms如下,甚至是3-4ms。

然而因爲業務的須要,在開發中一些耗時的邏輯是沒法避免的,例如排序、搜索等。在這樣的狀況下咱們能夠將邏輯分紅小塊,而後使用requestAnimationFrame,或者將耗時的邏輯放到service worker中進行。 具體如何使用在這裏不作細說,咱們能夠參照谷歌的這篇文檔 Optimize JavaScript Execution,上面有詳細的解說。

setTimeout 與 setInterval 爲何不許時

長話短說:

setTimeout 只是在給定的時間以後將它的 callback function 放入 callback queue 但並不能保證function 的準時執行。setInterval 也是,只是每隔固定的時間放入一次callback function. 因此它們是否能準時執行都取決於當時stack 和 callback queue 的狀態。但咱們仍是能夠粗略地認爲它們是準時的,由於大部分狀況下這些不許時只是毫秒級的,但也須要理解它們其中的原理來處理和解釋那些小部分的狀況。

詳細解釋:

看了 MDNw3schoolssetTimeout 的解釋,咱們容易簡單地認爲它的做用是在必定的時間後執行一個callback function。然而這並不徹底正確。根據咱們在第二節中解釋的 stack 和 callback queue 的概念, setTimeout 只能保證將它的callback function在必定時間以後放入callback queue 而不是執行。 若是此時callback queue 中只有這個function 且stack是空的,固然它就會被準時執行。但若是此時stack中還有還沒有執行完的內容,或者在callback queue 中還有好幾個callback在排隊,顯然咱們的function會被推後執行,這個推後的時間取決與stack中的內容 和 callback queue 中排在前面的 callback 要執行多久。

但咱們仍是能夠粗略地認爲它是準時的。 只要咱們不在JS的線程裏放入一個十分耗時的function, 或者在callback queue裏瞬間塞入一大堆callback, 那麼stack是時常會被空出來執行咱們 setTimeout 扥 callback 的。在這樣的狀況下不許時的誤差也就只是毫秒級的。

細心的你也許發現了在第二節的例子中咱們使用了 setTimeout(callback,0), 也就是在 0 毫秒後將callback 放入 callback queue。 因爲queue 中的 callback 會在stack 空了以後在執行,那麼這個用法其實能夠做爲一種控制執行順序的工具。 讓咱們來看一個簡單地例子:

setTimeout(() => console.log("我想後執行"), 0);
console.log("我想先執行");
// 打印
// 我想先執行
// 我想後執行

setIntervalsetTimeout 的實現原理是類似的。咱們粗略地理解它爲每隔必定的時間執行一次callback。實際上是每隔必定的時間在 callback queue 中加入一次 callback, 因此它先後兩次執行callback 的間隔時間也是不能保證的, 它們可長可短, 取決於stack和callback queue 的狀態。

結束語

謝謝你一直讀到如今。這是個人第一篇博文,它記錄了我對前端知識學習和思考的過程。但願你在閱讀過程當中有所收穫。我會繼續堅持下去分享我在學習工做中的心得和體會。

最後,感謝這些幫助我學習文章相關內容的資料:

Asynchronous JavaScript: Promises, Callbacks, Async Await

The Javascript Runtime Environment

How JavaScript Timers Work

What the heck is the event loop anyway? | Philip Roberts | JSConf EU

Optimize JavaScript Execution

相關文章
相關標籤/搜索