JavaScript 概述

2023-03-20 15:48 更新

單線程模型

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

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

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

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

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

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

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

同步任務和異步任務

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

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

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

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

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

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

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

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

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

異步操作的模式

下面總結(jié)一下異步操作的幾種模式。

回調(diào)函數(shù)

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

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

function f1() {
  // ...
}

function f2() {
  // ...
}

f1();
f2();

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

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

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

function f2() {
  // ...
}

f1(f2);

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

事件監(jiān)聽

另一種思路是采用事件驅(qū)動模式。異步任務的執(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)點是比較容易理解,可以綁定多個事件,每個事件可以指定多個回調(diào)函數(shù),而且可以“去耦合”(decoupling),有利于實現(xiàn)模塊化。缺點是整個程序都要變成事件驅(qū)動型,運行流程會變得很不清晰。閱讀代碼的時候,很難看出主流程。

發(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);

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

異步操作的流程控制

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

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

上面代碼的async函數(shù)是一個異步任務,非常耗時,每次執(zhí)行需要1秒才能完成,然后再調(diào)用回調(diào)函數(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秒后返回結(jié)果
// 參數(shù)為 2 , 1秒后返回結(jié)果
// 參數(shù)為 3 , 1秒后返回結(jié)果
// 參數(shù)為 4 , 1秒后返回結(jié)果
// 參數(shù)為 5 , 1秒后返回結(jié)果
// 參數(shù)為 6 , 1秒后返回結(jié)果
// 完成:  12

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

串行執(zhí)行

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

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

function async(arg, callback) {
  console.log('參數(shù)為 ' + arg +' , 1秒后返回結(jié)果');
  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ù)組保存每一個異步任務的運行結(jié)果。

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

并行執(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秒后返回結(jié)果');
  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ā)起六個異步任務,等到它們?nèi)客瓿梢院?,才會?zhí)行final函數(shù)。

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

并行與串行的結(jié)合

所謂并行與串行的結(jié)合,就是設(shè)置一個門檻,每次最多只能并行執(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秒后返回結(jié)果');
  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í)行之間。通過調(diào)節(jié)limit變量,達到效率和資源的最佳平衡。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號