單線程模型指的是,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ù)。
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ù)是異步操作最基本的方法。
下面是兩個函數(shù)f1
和f2
,編程的意圖是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ù)。
另一種思路是采用事件驅(qū)動模式。異步任務的執(zhí)行不取決于代碼的順序,而取決于某個事件是否發(fā)生。
還是以f1
和f2
為例。首先,為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ū)動型,運行流程會變得很不清晰。閱讀代碼的時候,很難看出主流程。
事件完全可以理解成“信號”,如果存在一個“信號中心”,某個任務執(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ù)的嵌套,不僅寫起來麻煩,容易出錯,而且難以維護。
我們可以編寫一個流程控制函數(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é)果。
注意,上面的寫法需要六秒,才能完成整個腳本。
流程控制函數(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é)合,就是設(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
變量,達到效率和資源的最佳平衡。
更多建議: