JavaScript非同步程式設計 | 掘金技術徵文

熊建剛發表於2017-04-26

還記得一年前寫過一篇關於JavaScript非同步程式設計簡述的文章,主要介紹了JavaScript的單執行緒特性與非同步程式設計實現方式:
回撥函式,釋出訂閱模式,Promise物件三種,關於Promise介紹的比較簡略,決定再詳細總結一下,既是對上一篇文章的補充,也能以更深刻的方式分享自己關於非同步程式設計的理解。

前言

如果你有志於成為一個優秀的前端工程師,或是想要深入學習JavaScript,非同步程式設計是必不可少的一個知識點,這也是區分初級,中級或高階前端的依據之一。如果你對非同步程式設計沒有太清晰的概念,那麼我建議你花點時間學習JavaScript非同步程式設計,如果你對非同步程式設計有自己的獨特理解,也歡迎閱讀本文,一起交流。

同步與非同步

介紹非同步之前,回顧一下,所謂同步程式設計,就是計算機一行一行按順序依次執行程式碼,當前程式碼任務耗時執行會阻塞後續程式碼的執行。

同步程式設計,即是一種典型的請求-響應模型,當請求呼叫一個函式或方法後,需等待其響應返回,然後執行後續程式碼。

一般情況下,同步程式設計,程式碼按序依次執行,能很好的保證程式的執行,但是在某些場景下,比如讀取檔案內容,或請求伺服器介面資料,需要根據返回的資料內容執行後續操作,讀取檔案和請求介面直到資料返回這一過程是需要時間的,網路越差,耗費時間越長,如果按照同步程式設計方式實現,在等待資料返回這段時間,JavaScript是不能處理其他任務的,此時頁面的互動,滾動等任何操作也都會被阻塞,這顯然是及其不友好,不可接受的,而這正是需要非同步程式設計大顯身手的場景,如下圖,耗時任務A會阻塞任務B的執行,等到任務A執行完才能繼續執行B:

JavaScript非同步程式設計 | 掘金技術徵文
同步程式設計任務阻塞流程

當使用非同步程式設計時,在等待當前任務的響應返回之前,可以繼續執行後續程式碼,即當前執行任務不會阻塞後續執行。

非同步程式設計,不同於同步程式設計的請求-響應模式,其是一種事件驅動程式設計,請求呼叫函式或方法後,無需立即等待響應,可以繼續執行其他任務,而之前任務響應返回後可以通過狀態、通知和回撥來通知呼叫者。

多執行緒

前面說明了非同步程式設計能很好的解決同步程式設計阻塞的問題,那麼實現非同步的方式有哪些呢?通常實現非同步方式是多執行緒,如C#, 即同時開啟多個執行緒,不同操作能並行執行,如下圖,耗時任務A執行的同時,線上程二中任務B也可以執行:

JavaScript非同步程式設計 | 掘金技術徵文
多執行緒非同步程式設計無阻塞流程

JavaScript單執行緒

JavaScript語言執行環境是單執行緒的,單執行緒在程式執行時,所走的程式路徑按照連續順序排下來,前面的必須處理好,後面的才會執行,而使用非同步實現時,多個任務可以併發執行。那麼JavaScript的非同步程式設計如何實現呢,下一節將詳細闡述其非同步機制。

並行與併發

前文提到多執行緒的任務可以並行執行,而JavaScript單執行緒非同步程式設計可以實現多工併發執行,這裡有必要說明一下並行與併發的區別。

  • 並行,指同一時刻內多工同時進行;
  • 併發,指在同一時間段內,多工同時進行著,但是某一時刻,只有某一任務執行;

通常所說的併發連線數,是指瀏覽器向伺服器發起請求,建立TCP連線,每秒鐘伺服器建立的總連線數,而假如,伺服器處10ms能處理一個連線,那麼其併發連線數就是100。

JavaScript非同步機制

本節介紹JavaScript非同步機制,首先來看一個例子:


    for (var i = 0; i < 5; i ++) {
        setTimeout(function(){
            console.log(i);
        }, 0);
    }
    console.log(i);
    //5 ; 5 ; 5 ; 5; 5複製程式碼

應該明白最後輸出的全是5:

  1. i在此處是for迴圈所在上下文環境的變數,有且只有一個i;
  2. 迴圈結束時i==5;
  3. JavaScript單執行緒事件處理器線上程空閒前不會執行下一事件。

如上面第三點所述,如果要真正理解以上例子中的setTimeout(),及JavaScript非同步機制,需要理解JavaScript的事件迴圈和併發模型。

併發模型(Concurrency model)

目前,我們已經知道,JavaScript執行非同步任務時,不需要等待響應返回,可以繼續執行其他任務,而在響應返回時,會得到通知,執行回撥或事件處理程式。那麼這一切具體是如何完成的,又以什麼規則或順序運作呢?接下來我們需要解答這個問題。

注:回撥和事件處理程式本質上並無區別,只是在不同情況下,不同的叫法。

前文已經提到,JavaScript非同步程式設計使得多個任務可以併發執行,而實現這一功能的基礎是JavScript擁有一個基於事件迴圈的併發模型。

堆疊與佇列

介紹JavaScript併發模型之前,先簡單介紹堆疊和佇列的區別:

  • 堆(heap):記憶體中某一未被阻止的區域,通常儲存物件(引用型別);
  • 棧(stack):後進先出的順序儲存資料結構,通常儲存函式引數和基本型別值變數(按值訪問);
  • 佇列(queue):先進先出順序儲存資料結構。

事件迴圈(Event Loop)

JavaScript引擎負責解析,執行JavaScript程式碼,但它並不能單獨執行,通常都得有一個宿主環境,一般如瀏覽器或Node伺服器,前文說到的單執行緒是指在這些宿主環境建立單一執行緒,提供一種機制,呼叫JavaScript引擎完成多個JavaScript程式碼塊的排程,執行(是的,JavaScript程式碼都是按塊執行的),這種機制就稱為事件迴圈(Event Loop)。

注:這裡的事件與DOM事件不要混淆,可以說這裡的事件包括DOM事件,所有非同步操作都是一個事件,諸如ajax請求就可以看作一個request請求事件。

JavaScript執行環境中存在的兩個結構需要了解:

  • 訊息佇列(message queue),也叫任務佇列(task queue):儲存待處理訊息及對應的回撥函式或事件處理程式;
  • 執行棧(execution context stack),也可以叫執行上下文棧:JavaScript執行棧,顧名思義,是由執行上下文組成,當函式呼叫時,建立並插入一個執行上下文,通常稱為執行棧幀(frame),儲存著函式引數和區域性變數,當該函式執行結束時,彈出該執行棧幀;

注:關於全域性程式碼,由於所有的程式碼都是在全域性上下文執行,所以執行棧頂總是全域性上下文就很容易理解,直到所有程式碼執行完畢,全域性上下文退出執行棧,棧清空了;也即是全域性上下文是第一個入棧,最後一個出棧。

任務

分析事件迴圈流程前,先闡述兩個概念,有助於理解事件迴圈:同步任務和非同步任務。

任務很好理解,JavaScript程式碼執行就是在完成任務,所謂任務就是一個函式或一個程式碼塊,通常以功能或目的劃分,比如完成一次加法計算,完成一次ajax請求;很自然的就分為同步任務和非同步任務。同步任務是連續的,阻塞的;而非同步任務則是不連續,非阻塞的,包含非同步事件及其回撥,當我們談及執行非同步任務時,通常指執行其回撥函式。

事件迴圈流程

關於事件迴圈流程分解如下:

  1. 宿主環境為JavaScript建立執行緒時,會建立堆(heap)和棧(stack),堆記憶體儲JavaScript物件,棧記憶體儲執行上下文;
  2. 棧內執行上下文的同步任務按序執行,執行完即退棧,而當非同步任務執行時,該非同步任務進入等待狀態(不入棧),同時通知執行緒:當觸發該事件時(或該非同步操作響應返回時),需向訊息佇列插入一個事件訊息;
  3. 當事件觸發或響應返回時,執行緒向訊息佇列插入該事件訊息(包含事件及回撥);
  4. 當棧內同步任務執行完畢後,執行緒從訊息佇列取出一個事件訊息,其對應非同步任務(函式)入棧,執行回撥函式,如果未繫結回撥,這個訊息會被丟棄,執行完任務後退棧;
  5. 當執行緒空閒(即執行棧清空)時繼續拉取訊息佇列下一輪訊息(next tick,事件迴圈流轉一次稱為一次tick)。

使用程式碼可以描述如下:


    var eventLoop = [];
    var event;
    var i = eventLoop.length - 1; // 後進先出

    while(eventLoop[i]) {
        event = eventLoop[i--]; 
        if (event) { // 事件回撥存在
            event();
        }
        // 否則事件訊息被丟棄
    }複製程式碼

這裡注意的一點是等待下一個事件訊息的過程是同步的。

併發模型與事件迴圈

    var ele = document.querySelector('body');

    function clickCb(event) {
        console.log('clicked');
    }
    function bindEvent(callback) {
        ele.addEventListener('click', callback);
    }    

    bindEvent(clickCb);複製程式碼

針對如上程式碼我們可以構建如下併發模型:

JavaScript非同步程式設計 | 掘金技術徵文
JavaScript併發模型

如上圖,當執行棧同步程式碼塊依次執行完直到遇見非同步任務時,非同步任務進入等待狀態,通知執行緒,非同步事件觸發時,往訊息佇列插入一條事件訊息;而當執行棧後續同步程式碼執行完後,讀取訊息佇列,得到一條訊息,然後將該訊息對應的非同步任務入棧,執行回撥函式;一次事件迴圈就完成了,也即處理了一個非同步任務。

再談setTimeout(...0)

瞭解了JavaScript事件迴圈後我們再看前文關於setTimeout(...0)的例子就比較清晰了:

setTimeout(...0)所表達的意思是:等待0秒後(這個時間由第二個引數值確定),往訊息佇列插入一條定時器事件訊息,並將其第一個引數作為回撥函式;而當執行棧內同步任務執行完畢時,執行緒從訊息佇列讀取訊息,將該非同步任務入棧,執行;執行緒空閒時再次從訊息佇列讀取訊息。

再看一個例項:


    var start = +new Date();
    var arr = [];

    setTimeout(function(){
        console.log('time: ' + (new Date().getTime() - start));
    },10);

    for(var i=0;i<=1000000;i++){
        arr.push(i);
    }複製程式碼

執行多次輸出如下:

JavaScript非同步程式設計 | 掘金技術徵文
setTimeout(...0)

setTimeout非同步回撥函式裡我們輸出了非同步任務註冊到執行的時間,發現並不等於我們指定的時間,而且兩次時間間隔也都不同,考慮以下兩點:

  • 在讀取訊息佇列的訊息時,得等同步任務完成,這個是需要耗費時間的;
  • 訊息佇列先進先出原則,讀取此非同步事件訊息之前,可能還存在其他訊息,執行也需要耗時;

所以非同步執行時間不精確是必然的,所以我們有必要明白無論是同步任務還是非同步任務,都不應該耗時太長,當一個訊息耗時太長時,應該儘可能的將其分割成多個訊息。

Web Workers

每個Web Worker或一個跨域的iframe都有各自的堆疊和訊息佇列,這些不同的文件只能通過postMessage方法進行通訊,當一方監聽了message事件後,另一方才能通過該方法向其傳送訊息,這個message事件也是非同步的,當一方接收到另一方通過postMessage方法傳送來的訊息後,會向自己的訊息佇列插入一條訊息,而後續的併發流程依然如上文所述。

JavaScript非同步實現

關於JavaScript的非同步實現,以前有:回撥函式,釋出訂閱模式,Promise三類,而在ES6中提出了生成器(Generator)方式實現,關於回撥函式和釋出訂閱模式實現可參見另一篇文章,後續將推出一篇詳細介紹Promise和Generator。

歡迎踩踩我的個人部落格

參考:

Concurrency model and Event Loop

掘金技術徵文活動的連結: https://juejin.im/post/58d8e99261ff4b006cd6874d

相關文章