JS異步操作:概述

目錄

1. 單線程模型

  • 什麼是單線程模型
  • JS爲什麼採用單線程模式
  • 單線程模式的優缺點

2. 同步任務 和 異步任務

3. 任務隊列 和 事件循環

4. 單線程模型

  • 回調函數
  • 事件監聽
  • 發佈/訂閱

5. 單線程模型

  • 串行執行
  • 並行執行
  • 並行和串行的結合

本文轉載於異步操作概述


(一)單線程模型

1. 什麼是單線程模型?

單線程模型指:JavaScript 只在一個線程上運行。也就是說,JavaScript 同時只能執行一個任務,其他任務都必須在後面排隊等待。

注意:JavaScript 只在一個線程上運行,不代表 JavaScript 引擎只有一個線程。事實上,JavaScript 引擎有多個線程,單個腳本只能在一個線程上運行(稱爲主線程),其他線程都是在後臺配合。

2. JS爲什麼採用單線程模式?

JavaScript 之所以採用單線程,而不是多線程,跟歷史有關係。

JavaScript 從誕生起就是單線程,原因是不想讓瀏覽器變得太複雜,因爲多線程需要共享資源、且有可能修改彼此的運行結果,對於一種網頁腳本語言來說,這就太複雜了。如果 JavaScript 同時有兩個線程,一個線程在網頁 DOM 節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程爲準?是不是還要有鎖機制?所以,爲了避免複雜性,JavaScript 一開始就是單線程,這已經成了這門語言的核心特徵,將來也不會改變。

3. 單線程模式的優缺點

這種模式的好處是實現起來比較簡單,執行環境相對單純;

壞處是只要有一個任務耗時很長,後面的任務都必須排隊等着,會拖延整個程序的執行。常見的瀏覽器無響應(假死),往往就是因爲某一段 JavaScript 代碼長時間運行(比如死循環),導致整個頁面卡在這個地方,其他任務無法執行。JavaScript 語言本身並不慢,慢的是讀寫外部數據,比如等待 Ajax 請求返回結果。這個時候,如果對方服務器遲遲沒有響應,或者網絡不通暢,就會導致腳本的長時間停滯。

如果排隊是因爲計算量大,CPU 忙不過來,倒也算了,但是很多時候 CPU 是閒着的,因爲 IO 操作(輸入輸出)很慢(比如 Ajax 操作從網絡讀取數據),不得不等着結果出來,再往下執行。JavaScript 語言的設計者意識到,這時 CPU 完全可以不管 IO 操作,掛起處於等待中的任務,先運行排在後面的任務。等到 IO 操作返回了結果,再回過頭,把掛起的任務繼續執行下去。這種機制就是 JavaScript 內部採用的“事件循環”機制(Event Loop)。

單線程模型雖然對 JavaScript 構成了很大的限制,但也因此使它具備了其他語言不具備的優勢。如果用得好,JavaScript 程序是不會出現堵塞的,這就是爲什麼 Node 可以用很少的資源,應付大流量訪問的原因。

爲了利用多核 CPU 的計算能力,HTML5 提出 Web Worker 標準,允許 JavaScript 腳本創建多個線程,但是子線程完全受主線程控制,且不得操作 DOM。所以,這個新標準並沒有改變 JavaScript 單線程的本質。

二)同步任務和異步任務

程序裏面所有的任務,可以分成兩類:同步任務(synchronous)和異步任務(asynchronous)。

同步任務:是那些沒有被引擎掛起、在主線程上排隊執行的任務。只有前一個任務執行完畢,才能執行後一個任務。

異步任務:是那些被引擎放在一邊,不進入主線程、而進入任務隊列的任務。只有引擎認爲某個異步任務可以執行了(比如 Ajax 操作從服務器得到了結果),該任務(採用回調函數的形式)纔會進入主線程執行。排在異步任務後面的代碼,不用等待異步任務結束會馬上運行,也就是說,異步任務不具有“堵塞”效應。

舉例來說,Ajax 操作可以當作同步任務處理,也可以當作異步任務處理,由開發者決定。如果是同步任務,主線程就等着 Ajax 操作返回結果,再往下執行;如果是異步任務,主線程在發出 Ajax 請求以後,就直接往下執行,等到 Ajax 操作有了結果,主線程再執行對應的回調函數。

(三)任務隊列和事件循環

JavaScript 運行時,除了一個正在運行的主線程,引擎還提供一個任務隊列(task queue),裏面是各種需要當前程序處理的異步任務。(實際上,根據異步任務的類型,存在多個任務隊列。爲了方便理解,這裏假設只存在一個隊列。)

首先,主線程會去執行所有的同步任務。等到同步任務全部執行完,就會去看任務隊列裏面的異步任務。如果滿足條件,那麼異步任務就重新進入主線程開始執行,這時它就變成同步任務了。等到執行完,下一個異步任務再進入主線程開始執行。一旦任務隊列清空,程序就結束執行。

異步任務的寫法通常是回調函數。一旦異步任務重新進入主線程,就會執行對應的回調函數。如果一個異步任務沒有回調函數,就不會進入任務隊列,也就是說,不會重新進入主線程,因爲沒有用回調函數指定下一步的操作。

JavaScript 引擎怎麼知道異步任務有沒有結果,能不能進入主線程呢?答案就是引擎在不停地檢查,一遍又一遍,只要同步任務執行完了,引擎就會去檢查那些掛起來的異步任務,是不是可以進入主線程了。這種循環檢查的機制,就叫做事件循環(Event Loop)。維基百科的定義是:“事件循環是一個程序結構,用於等待和發送消息和事件(a programming construct that waits for and dispatches events or messages in a program)”。

(四)異步操作的模式

下面總結一下異步操作的幾種模式:

1. 回調函數

回調函數是異步操作最基本的方法。

下面是兩個函數f1f2,編程的意圖是f2必須等到f1執行完成,才能執行。

function f1() {
  // ...
}

function f2() {
  // ...
}

f1();
f2();

上面代碼的問題在於,如果f1是異步操作,f2會立即執行,不會等到f1結束再執行。

這時,可以考慮改寫f1,把f2寫成f1的回調函數。

function f1(callback) {
  // ...
  callback();
}

function f2() {
  // ...
}

f1(f2);

回調函數的優點是簡單、容易理解和實現,缺點是不利於代碼的閱讀和維護,各個部分之間高度耦合(coupling),使得程序結構混亂、流程難以追蹤(尤其是多個回調函數嵌套的情況),而且每個任務只能指定一個回調函數。

2. 事件監聽

另一種思路是採用事件驅動模式。異步任務的執行不取決於代碼的順序,而取決於某個事件是否發生。

還是以f1f2爲例。首先,爲f1綁定一個事件(這裏採用的 jQuery 的寫法)。

f1.on('done', f2);

上面這行代碼的意思是,當f1發生done事件,就執行f2。然後,對f1進行改寫:

function f1() {
  setTimeout(function () {
    // ...
    f1.trigger('done');
  }, 1000);
}

上面代碼中,f1.trigger('done')表示,執行完成後,立即觸發done事件,從而開始執行f2

這種方法的優點是比較容易理解,可以綁定多個事件,每個事件可以指定多個回調函數,而且可以“去耦合”(decoupling),有利於實現模塊化。缺點是整個程序都要變成事件驅動型,運行流程會變得很不清晰。閱讀代碼的時候,很難看出主流程。

3. 發佈/訂閱

事件完全可以理解成“信號”,如果存在一個“信號中心”,某個任務執行完成,就向信號中心“發佈”(publish)一個信號,其他任務可以向信號中心“訂閱”(subscribe)這個信號,從而知道什麼時候自己可以開始執行。這就叫做”發佈/訂閱模式”(publish-subscribe pattern),又稱“觀察者模式”(observer pattern)。

這個模式有多種實現,下面採用的是 Ben Alman 的 Tiny Pub/Sub,這是 jQuery 的一個插件。

首先,f2向信號中心jQuery訂閱done信號。

jQuery.subscribe('done', f2);

然後,f1進行如下改寫。

function f1() {
  setTimeout(function () {
    // ...
    jQuery.publish('done');
  }, 1000);
}

上面代碼中,jQuery.publish('done')的意思是,f1執行完成後,向信號中心jQuery發佈done信號,從而引發f2的執行。

f2完成執行後,可以取消訂閱(unsubscribe)。

jQuery.unsubscribe('done', f2);

這種方法的性質與“事件監聽”類似,但是明顯優於後者。因爲可以通過查看“消息中心”,瞭解存在多少信號、每個信號有多少訂閱者,從而監控程序的運行。

(五)異步操作的流程控制

如果有多個異步操作,就存在一個流程控制的問題:如何確定異步操作執行的順序,以及如何保證遵守這種順序。

function async(arg, callback) {
  console.log('參數爲 ' + arg +' , 1秒後返回結果');
  setTimeout(function () { callback(arg * 2); }, 1000);
}

上面代碼的async函數是一個異步任務,非常耗時,每次執行需要1秒才能完成,然後再調用回調函數。

如果有六個這樣的異步任務,需要全部完成後,才能執行最後的final函數。請問應該如何安排操作流程?

function final(value) {
  console.log('完成: ', value);
}
async(1, function (value) {
  async(2, function (value) {
    async(3, function (value) {
      async(4, function (value) {
        async(5, function (value) {
          async(6, final);
        });
      });
    });
  });
});
// 參數爲 1 , 1秒後返回結果
// 參數爲 2 , 1秒後返回結果
// 參數爲 3 , 1秒後返回結果
// 參數爲 4 , 1秒後返回結果
// 參數爲 5 , 1秒後返回結果
// 參數爲 6 , 1秒後返回結果
// 完成:  12

上面代碼中,六個回調函數的嵌套,不僅寫起來麻煩,容易出錯,而且難以維護。

1. 串行執行

我們可以編寫一個流程控制函數,讓它來控制異步任務,一個任務完成以後,再執行另一個。這就叫串行執行。

var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];

function async(arg, callback) {
  console.log('參數爲 ' + arg +' , 1秒後返回結果');
  setTimeout(function () { callback(arg * 2); }, 1000);
}

function final(value) {
  console.log('完成: ', value);
}

function series(item) {
  if(item) {
    async( item, function(result) {
      results.push(result);
      return series(items.shift());
    });
  } else {
    return final(results[results.length - 1]);
  }
}

series(items.shift());

上面代碼中,函數series就是串行函數,它會依次執行異步任務,所有任務都完成後,纔會執行final函數。items數組保存每一個異步任務的參數,results數組保存每一個異步任務的運行結果。

注意,上面的寫法需要六秒,才能完成整個腳本。

2. 並行執行

流程控制函數也可以是並行執行,即所有異步任務同時執行,等到全部完成以後,才執行final函數。

var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];

function async(arg, callback) {
  console.log('參數爲 ' + arg +' , 1秒後返回結果');
  setTimeout(function () { callback(arg * 2); }, 1000);
}

function final(value) {
  console.log('完成: ', value);
}

items.forEach(function(item) {
  async(item, function(result){
    results.push(result);
    if(results.length === items.length) {
      final(results[results.length - 1]);
    }
  })
});

上面代碼中,forEach方法會同時發起六個異步任務,等到它們全部完成以後,纔會執行final函數。

相比而言,上面的寫法只要一秒,就能完成整個腳本。這就是說,並行執行的效率較高,比起串行執行一次只能執行一個任務,較爲節約時間。但是問題在於如果並行的任務較多,很容易耗盡系統資源,拖慢運行速度。因此有了第三種流程控制方式。

3. 並行與串行的結合

所謂並行與串行的結合,就是設置一個門檻,每次最多隻能並行執行n個異步任務,這樣就避免了過分佔用系統資源。

var items = [ 1, 2, 3, 4, 5, 6 ];
var results = [];
var running = 0;
var limit = 2;

function async(arg, callback) {
  console.log('參數爲 ' + arg +' , 1秒後返回結果');
  setTimeout(function () { callback(arg * 2); }, 1000);
}

function final(value) {
  console.log('完成: ', value);
}

function launcher() {
  while(running < limit && items.length > 0) {
    var item = items.shift();
    async(item, function(result) {
      results.push(result);
      running--;
      if(items.length > 0) {
        launcher();
      } else if(running == 0) {
        final(results);
      }
    });
    running++;
  }
}

launcher();

上面代碼中,最多隻能同時運行兩個異步任務。變量running記錄當前正在運行的任務數,只要低於門檻值,就再啓動一個新的任務,如果等於0,就表示所有任務都執行完了,這時就執行final函數。

這段代碼需要三秒完成整個腳本,處在串行執行和並行執行之間。通過調節limit變量,達到效率和資源的最佳平衡。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章