JavaScript 概述

2023-03-20 15:48 更新

單線程模型

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

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

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

這種模式的好處是實現(xiàn)起來比較簡單,執(zhí)行環(huán)境相對單純;壞處是只要有一個任務耗時很長,后面的任務都必須排隊等著,會拖延整個程序的執(zhí)行。常見的瀏覽器無響應(假死),往往就是因為某一段 JavaScript 代碼長時間運行(比如死循環(huán)),導致整個頁面卡在這個地方,其他任務無法執(zhí)行。JavaScript 語言本身并不慢,慢的是讀寫外部數(shù)據(jù),比如等待 Ajax 請求返回結果。這個時候,如果對方服務器遲遲沒有響應,或者網絡不通暢,就會導致腳本的長時間停滯。

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

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

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

同步任務和異步任務

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

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

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

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

任務隊列和事件循環(huán)

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

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

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

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

異步操作的模式

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

回調函數(shù)

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

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

function f1() {
  // ...
}

function f2() {
  // ...
}

f1();
f2();

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

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

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

function f2() {
  // ...
}

f1(f2);

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

事件監(jiān)聽

另一種思路是采用事件驅動模式。異步任務的執(zhí)行不取決于代碼的順序,而取決于某個事件是否發(fā)生。

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

f1.on('done', f2);

上面這行代碼的意思是,當f1發(fā)生done事件,就執(zhí)行f2。然后,對f1進行改寫:

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

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

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

發(fā)布/訂閱

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

這個模式有多種實現(xiàn),下面采用的是 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執(zhí)行完成后,向信號中心jQuery發(fā)布done信號,從而引發(fā)f2的執(zhí)行。

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

jQuery.unsubscribe('done', f2);

這種方法的性質與“事件監(jiān)聽”類似,但是明顯優(yōu)于后者。因為可以通過查看“消息中心”,了解存在多少信號、每個信號有多少訂閱者,從而監(jiān)控程序的運行。

異步操作的流程控制

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

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

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

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

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);
        });
      });
    });
  });
});
// 參數(shù)為 1 , 1秒后返回結果
// 參數(shù)為 2 , 1秒后返回結果
// 參數(shù)為 3 , 1秒后返回結果
// 參數(shù)為 4 , 1秒后返回結果
// 參數(shù)為 5 , 1秒后返回結果
// 參數(shù)為 6 , 1秒后返回結果
// 完成:  12

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

串行執(zhí)行

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

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

function async(arg, callback) {
  console.log('參數(shù)為 ' + 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());

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

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

并行執(zhí)行

流程控制函數(shù)也可以是并行執(zhí)行,即所有異步任務同時執(zhí)行,等到全部完成以后,才執(zhí)行final函數(shù)。

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

function async(arg, callback) {
  console.log('參數(shù)為 ' + 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方法會同時發(fā)起六個異步任務,等到它們全部完成以后,才會執(zhí)行final函數(shù)。

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

并行與串行的結合

所謂并行與串行的結合,就是設置一個門檻,每次最多只能并行執(zhí)行n個異步任務,這樣就避免了過分占用系統(tǒng)資源。

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

function async(arg, callback) {
  console.log('參數(shù)為 ' + 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記錄當前正在運行的任務數(shù),只要低于門檻值,就再啟動一個新的任務,如果等于0,就表示所有任務都執(zhí)行完了,這時就執(zhí)行final函數(shù)。

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


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號