怎麼理解JavaScript異步機制的"詭異"

前言

「詭異」是我對 JavaScript 異步機制的第一印象,這裏的詭異打了雙引號,並無任何貶義。javascript

跟不少前端不同,我是一名 Java 的開發者,當我剛開始接觸 JavaScript 的時候,我有一種既熟悉又陌生的感受,剛上手以爲很熟悉,越瞭解,越陌生,他們兩個的關係就真的如:雷鋒和雷峯塔的區別同樣。前端

最讓我陌生的莫過因而,就是 JavaScript 的異步機制了。java

我帶着原來 Java 併發和多線程的經驗,嘗試去理解的時候,讓我摸不着頭腦的是:單線程異步,我滿腦子的問號,由於個人經驗告訴我,單線程怎麼可能能夠異步嘛!面試

我閱讀了不少關於 JavaScript 異步的文章深刻理解後,我腦子裏的問號變成了感嘆號,實在太巧妙了。但更讓我以爲很是意外的是,你們好像對 JavaScript 的這種異步機制都習覺得常,好像你們都是「這不是理所固然的嗎?」,我內心就想,難道只有我才以爲詭異嗎?segmentfault

我曾經也想把這種疑惑寫下來,但好像寫着寫着,最後都變成了科普式 what,why,how 的方式來寫,我很難形容出這種詭異的感受,後來發現也有很多人有我這樣的迷惑,他們大多都來自別的多線程併發的語言體系。瀏覽器

我意識到或許應該先以初學者的試錯的方式來展開這個討論。bash

因而我決定,把我以前碰到的那種詭異的感受,以及我當時懷疑,並接着探索和發現真相的這種過程,記錄下來,這纔是我學習的故事嘛。session

因此這篇文章,並非要說明誰優誰劣,而是想讓你們瞭解一下,別的語言體系的人是怎麼理解這一過程的。數據結構

sleep 和 setTimeout

首先讓我對 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);
    }
}
複製代碼

假設:setTimeout 是在別的線程完成的

好了,基於我對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 Runtime

網上有很是多的 JavaScript 的運行原理的文章,我是經過 SessionStack 整個系列加上本身的總結來理解的。

例如:他們有一張圖基本囊括了瀏覽器環境的 JavaScript Runtime

當我看到這張圖時,說實話我有點意外。 setTimeout的機制竟然是運行在 JavaScript 引擎之外的東西,它是屬於整個 JavaScript Runtime 整套體系下面,不只僅是 JavaScript 引擎中。

整個瀏覽器的 JavaScript Runtime 分爲:

  • JavaScript Engine,Chrome 的引擎就是 V8
  • Web APIs,DOM 的操做,AJAXTimeout 等實際上調用的都是這裏提供的
  • Callback Queue,回調的隊列,也就是剛剛全部的 Web APIs 裏面的回調函數,實際上都是放在這裏排隊的
  • EventLoop,事件循環,能夠說它是整個 JavaScript 運行的核心

Call Stack 調用棧

Call StackJavaScript Engine中最重要的一個環節,貫穿着整個函數調用的過程,但Call Stack自己不是什麼新鮮東西,它原本就是一種很常見的數據結構,也是程序運行基本原理的重要組成部分。我這裏就再也不贅述它的基本概念了,你們能夠看 MDN 裏面的更詳細的解釋 Call Stack

我這裏關注的是,JavaScript 之因此稱之爲單線程,最重要的就是它只有一個Call Stack,是的,像 Java 這類語言中,多線程就會有多個Call Stack

整個 JavaScript 都是圍繞單個Call Stack來展開。

Web APIs

讓我很是意外的是,原來setTimeout等之類的全部異步的操做其實都歸屬於Web APIs裏面。

JavaScript 的異步機制之因此巧妙,就是JavaScript Engine是單線程的,可是JavaScript Runtime就是瀏覽器並非單線程的,全部異步的操做的確是經過瀏覽器在別的線程完成,可是Callback自己仍是在Call Stack中完成的。

Callback Queue 回調函數的隊列

簡單理解,Callback Queue 就是全部 Web APIs 在別的線程裏面執行完以後,放到這裏排隊的一個隊列。

例如,setTimeout的時間計算是在別的線程完成的,等到時間到了以後,就把這個callback放在隊列裏面排隊。

EventLoop 事件循環

終於要來到大名鼎鼎的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');
複製代碼

我如今能夠這麼理解了:

  1. 先把console.log('Hi')壓入棧
  2. 引擎把棧內的console.log('Hi)拿出來執行
  3. 打印Hi,彈出棧
  4. 接下來把setTimeout壓入棧
  5. 引擎把棧內的setTimeout拿出來執行,這裏的執行只是,調用了Web APIs,並把回調cb1和時間參數5000傳遞過去,而後彈出棧
  6. Web APIs立馬開啓了一個定時器,並開始計時
  7. 計時的同時,把console.log('Bye')壓入棧內
  8. 引擎把棧內的console.log('Bye')拿出來執行
  9. 打印Bye,彈出棧
  10. 定時器還沒結束,所以一直在等待,等到時間夠了,Web APIscb1放到隊列裏面排隊
  11. 因爲隊列裏面只有一個,並且棧內如今也是空的,EventLoop在下一次循環立馬就把cb1壓入了棧內
  12. 接下引擎把cb1分解,把裏面的console.log('cb1')也壓入棧內
  13. 引擎根據先進後出的順序,先處理console.log('cb1')
  14. 打印cb1,彈出棧
  15. 處理回調cb1,彈出棧

下面有個更直觀的圖:

假設:Web APIs 是併發的

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

這個時候再來回到Why,就是爲何 JavaScript 要選用這麼一種方法來實現異步?

  1. 實際上在GUI的開發領域(例如:Android,GTK,QT),單線程 + 消息隊列是很是常見的作法,而 JavaScript 就是爲了GUI開發而誕生的,只是它運行在瀏覽器上
  2. 爲了更簡單更易用,JavaScript 把單線程貫徹到這個語言解釋層面上,避免了不少由於多線程併發中產生的鎖問題,雖然如今有worker能夠操做多線程,可是本質並無改變
  3. 上面說過,線程是有獨立的Call Stack,這些都是有成本的,在計算資源匱乏的那個年代,JavaScript 的單線程或許能更好的節省計算機的資源

的確最開始的 JavaScript 是處理一些很簡單的表單,可是如今 JavaScript 已經誕生了二十多年了,如今依然還有很是強勁的生命力,證實 JavaScript 的機制在必定程度上它是很是優秀的。

總結

回到了剛剛第一個錯誤的假設:實際上 JavaScript 的單線程異步機制,就是把多線程的實現隱藏起來了,它的異步仍是仍是經過多線程實現的

如今看起來,這個結論有一部分是正確的,由於的確,Web APIs 是在別的線程中完成的。

但事情已經很不同了,我是按照之前 Java 多線程併發的經驗去理解的,而如今我真正的理解了 JavaScript 異步機制以後,對個人衝擊仍是挺大的。

這種感受就像是:曾經你覺得全世界的豆花都是甜的,也理應是甜的,然而忽然有一天,有人告訴你,豆花也有鹹的。可是衝擊個人不是,甜仍是鹹,而是我原來對豆花的概念從新認識。

我曾經是把異步和多線程劃上了等號,這是我原來的固有認識。

參考文獻:

探祕JS的異步單線程

GUI爲何不設計爲多線程

How JavaScript works: an overview of the engine, the runtime, and the call stack

How JavaScript works: Event loop and the rise of Async programming + 5 ways to better coding with async/await

相關文章
相關標籤/搜索