單線程模型指的是,JavaScript 只在一個(gè)線程上運(yùn)行。也就是說(shuō),JavaScript 同時(shí)只能執(zhí)行一個(gè)任務(wù),其他任務(wù)都必須在后面排隊(duì)等待。
注意,JavaScript 只在一個(gè)線程上運(yùn)行,不代表 JavaScript 引擎只有一個(gè)線程。事實(shí)上,JavaScript 引擎有多個(gè)線程,單個(gè)腳本只能在一個(gè)線程上運(yùn)行(稱為主線程),其他線程都是在后臺(tái)配合。
JavaScript 之所以采用單線程,而不是多線程,跟歷史有關(guān)系。JavaScript 從誕生起就是單線程,原因是不想讓瀏覽器變得太復(fù)雜,因?yàn)槎嗑€程需要共享資源、且有可能修改彼此的運(yùn)行結(jié)果,對(duì)于一種網(wǎng)頁(yè)腳本語(yǔ)言來(lái)說(shuō),這就太復(fù)雜了。如果 JavaScript 同時(shí)有兩個(gè)線程,一個(gè)線程在網(wǎng)頁(yè) DOM 節(jié)點(diǎn)上添加內(nèi)容,另一個(gè)線程刪除了這個(gè)節(jié)點(diǎn),這時(shí)瀏覽器應(yīng)該以哪個(gè)線程為準(zhǔn)?是不是還要有鎖機(jī)制?所以,為了避免復(fù)雜性,JavaScript 一開(kāi)始就是單線程,這已經(jīng)成了這門(mén)語(yǔ)言的核心特征,將來(lái)也不會(huì)改變。
這種模式的好處是實(shí)現(xiàn)起來(lái)比較簡(jiǎn)單,執(zhí)行環(huán)境相對(duì)單純;壞處是只要有一個(gè)任務(wù)耗時(shí)很長(zhǎng),后面的任務(wù)都必須排隊(duì)等著,會(huì)拖延整個(gè)程序的執(zhí)行。常見(jiàn)的瀏覽器無(wú)響應(yīng)(假死),往往就是因?yàn)槟骋欢?JavaScript 代碼長(zhǎng)時(shí)間運(yùn)行(比如死循環(huán)),導(dǎo)致整個(gè)頁(yè)面卡在這個(gè)地方,其他任務(wù)無(wú)法執(zhí)行。JavaScript 語(yǔ)言本身并不慢,慢的是讀寫(xiě)外部數(shù)據(jù),比如等待 Ajax 請(qǐng)求返回結(jié)果。這個(gè)時(shí)候,如果對(duì)方服務(wù)器遲遲沒(méi)有響應(yīng),或者網(wǎng)絡(luò)不通暢,就會(huì)導(dǎo)致腳本的長(zhǎng)時(shí)間停滯。
如果排隊(duì)是因?yàn)橛?jì)算量大,CPU 忙不過(guò)來(lái),倒也算了,但是很多時(shí)候 CPU 是閑著的,因?yàn)?IO 操作(輸入輸出)很慢(比如 Ajax 操作從網(wǎng)絡(luò)讀取數(shù)據(jù)),不得不等著結(jié)果出來(lái),再往下執(zhí)行。JavaScript 語(yǔ)言的設(shè)計(jì)者意識(shí)到,這時(shí) CPU 完全可以不管 IO 操作,掛起處于等待中的任務(wù),先運(yùn)行排在后面的任務(wù)。等到 IO 操作返回了結(jié)果,再回過(guò)頭,把掛起的任務(wù)繼續(xù)執(zhí)行下去。這種機(jī)制就是 JavaScript 內(nèi)部采用的“事件循環(huán)”機(jī)制(Event Loop)。
單線程模型雖然對(duì) JavaScript 構(gòu)成了很大的限制,但也因此使它具備了其他語(yǔ)言不具備的優(yōu)勢(shì)。如果用得好,JavaScript 程序是不會(huì)出現(xiàn)堵塞的,這就是 Node.js 可以用很少的資源,應(yīng)付大流量訪問(wèn)的原因。
為了利用多核 CPU 的計(jì)算能力,HTML5 提出 Web Worker 標(biāo)準(zhǔn),允許 JavaScript 腳本創(chuàng)建多個(gè)線程,但是子線程完全受主線程控制,且不得操作 DOM。所以,這個(gè)新標(biāo)準(zhǔn)并沒(méi)有改變 JavaScript 單線程的本質(zhì)。
程序里面所有的任務(wù),可以分成兩類(lèi):同步任務(wù)(synchronous)和異步任務(wù)(asynchronous)。
同步任務(wù)是那些沒(méi)有被引擎掛起、在主線程上排隊(duì)執(zhí)行的任務(wù)。只有前一個(gè)任務(wù)執(zhí)行完畢,才能執(zhí)行后一個(gè)任務(wù)。
異步任務(wù)是那些被引擎放在一邊,不進(jìn)入主線程、而進(jìn)入任務(wù)隊(duì)列的任務(wù)。只有引擎認(rèn)為某個(gè)異步任務(wù)可以執(zhí)行了(比如 Ajax 操作從服務(wù)器得到了結(jié)果),該任務(wù)(采用回調(diào)函數(shù)的形式)才會(huì)進(jìn)入主線程執(zhí)行。排在異步任務(wù)后面的代碼,不用等待異步任務(wù)結(jié)束會(huì)馬上運(yùn)行,也就是說(shuō),異步任務(wù)不具有“堵塞”效應(yīng)。
舉例來(lái)說(shuō),Ajax 操作可以當(dāng)作同步任務(wù)處理,也可以當(dāng)作異步任務(wù)處理,由開(kāi)發(fā)者決定。如果是同步任務(wù),主線程就等著 Ajax 操作返回結(jié)果,再往下執(zhí)行;如果是異步任務(wù),主線程在發(fā)出 Ajax 請(qǐng)求以后,就直接往下執(zhí)行,等到 Ajax 操作有了結(jié)果,主線程再執(zhí)行對(duì)應(yīng)的回調(diào)函數(shù)。
JavaScript 運(yùn)行時(shí),除了一個(gè)正在運(yùn)行的主線程,引擎還提供一個(gè)任務(wù)隊(duì)列(task queue),里面是各種需要當(dāng)前程序處理的異步任務(wù)。(實(shí)際上,根據(jù)異步任務(wù)的類(lèi)型,存在多個(gè)任務(wù)隊(duì)列。為了方便理解,這里假設(shè)只存在一個(gè)隊(duì)列。)
首先,主線程會(huì)去執(zhí)行所有的同步任務(wù)。等到同步任務(wù)全部執(zhí)行完,就會(huì)去看任務(wù)隊(duì)列里面的異步任務(wù)。如果滿足條件,那么異步任務(wù)就重新進(jìn)入主線程開(kāi)始執(zhí)行,這時(shí)它就變成同步任務(wù)了。等到執(zhí)行完,下一個(gè)異步任務(wù)再進(jìn)入主線程開(kāi)始執(zhí)行。一旦任務(wù)隊(duì)列清空,程序就結(jié)束執(zhí)行。
異步任務(wù)的寫(xiě)法通常是回調(diào)函數(shù)。一旦異步任務(wù)重新進(jìn)入主線程,就會(huì)執(zhí)行對(duì)應(yīng)的回調(diào)函數(shù)。如果一個(gè)異步任務(wù)沒(méi)有回調(diào)函數(shù),就不會(huì)進(jìn)入任務(wù)隊(duì)列,也就是說(shuō),不會(huì)重新進(jìn)入主線程,因?yàn)闆](méi)有用回調(diào)函數(shù)指定下一步的操作。
JavaScript 引擎怎么知道異步任務(wù)有沒(méi)有結(jié)果,能不能進(jìn)入主線程呢?答案就是引擎在不停地檢查,一遍又一遍,只要同步任務(wù)執(zhí)行完了,引擎就會(huì)去檢查那些掛起來(lái)的異步任務(wù),是不是可以進(jìn)入主線程了。這種循環(huán)檢查的機(jī)制,就叫做事件循環(huán)(Event Loop)。維基百科的定義是:“事件循環(huán)是一個(gè)程序結(jié)構(gòu),用于等待和發(fā)送消息和事件(a programming construct that waits for and dispatches events or messages in a program)”。
下面總結(jié)一下異步操作的幾種模式。
回調(diào)函數(shù)是異步操作最基本的方法。
下面是兩個(gè)函數(shù)f1
和f2
,編程的意圖是f2
必須等到f1
執(zhí)行完成,才能執(zhí)行。
function f1() {
// ...
}
function f2() {
// ...
}
f1();
f2();
上面代碼的問(wèn)題在于,如果f1
是異步操作,f2
會(huì)立即執(zhí)行,不會(huì)等到f1
結(jié)束再執(zhí)行。
這時(shí),可以考慮改寫(xiě)f1
,把f2
寫(xiě)成f1
的回調(diào)函數(shù)。
function f1(callback) {
// ...
callback();
}
function f2() {
// ...
}
f1(f2);
回調(diào)函數(shù)的優(yōu)點(diǎn)是簡(jiǎn)單、容易理解和實(shí)現(xiàn),缺點(diǎn)是不利于代碼的閱讀和維護(hù),各個(gè)部分之間高度耦合(coupling),使得程序結(jié)構(gòu)混亂、流程難以追蹤(尤其是多個(gè)回調(diào)函數(shù)嵌套的情況),而且每個(gè)任務(wù)只能指定一個(gè)回調(diào)函數(shù)。
另一種思路是采用事件驅(qū)動(dòng)模式。異步任務(wù)的執(zhí)行不取決于代碼的順序,而取決于某個(gè)事件是否發(fā)生。
還是以f1
和f2
為例。首先,為f1
綁定一個(gè)事件(這里采用的 jQuery 的寫(xiě)法)。
f1.on('done', f2);
上面這行代碼的意思是,當(dāng)f1
發(fā)生done
事件,就執(zhí)行f2
。然后,對(duì)f1
進(jìn)行改寫(xiě):
function f1() {
setTimeout(function () {
// ...
f1.trigger('done');
}, 1000);
}
上面代碼中,f1.trigger('done')
表示,執(zhí)行完成后,立即觸發(fā)done
事件,從而開(kāi)始執(zhí)行f2
。
這種方法的優(yōu)點(diǎn)是比較容易理解,可以綁定多個(gè)事件,每個(gè)事件可以指定多個(gè)回調(diào)函數(shù),而且可以“去耦合”(decoupling),有利于實(shí)現(xiàn)模塊化。缺點(diǎn)是整個(gè)程序都要變成事件驅(qū)動(dòng)型,運(yùn)行流程會(huì)變得很不清晰。閱讀代碼的時(shí)候,很難看出主流程。
事件完全可以理解成“信號(hào)”,如果存在一個(gè)“信號(hào)中心”,某個(gè)任務(wù)執(zhí)行完成,就向信號(hào)中心“發(fā)布”(publish)一個(gè)信號(hào),其他任務(wù)可以向信號(hào)中心“訂閱”(subscribe)這個(gè)信號(hào),從而知道什么時(shí)候自己可以開(kāi)始執(zhí)行。這就叫做”發(fā)布/訂閱模式”(publish-subscribe pattern),又稱“觀察者模式”(observer pattern)。
這個(gè)模式有多種實(shí)現(xiàn),下面采用的是 Ben Alman 的 Tiny Pub/Sub,這是 jQuery 的一個(gè)插件。
首先,f2
向信號(hào)中心jQuery
訂閱done
信號(hào)。
jQuery.subscribe('done', f2);
然后,f1
進(jìn)行如下改寫(xiě)。
function f1() {
setTimeout(function () {
// ...
jQuery.publish('done');
}, 1000);
}
上面代碼中,jQuery.publish('done')
的意思是,f1
執(zhí)行完成后,向信號(hào)中心jQuery
發(fā)布done
信號(hào),從而引發(fā)f2
的執(zhí)行。
f2
完成執(zhí)行后,可以取消訂閱(unsubscribe)。
jQuery.unsubscribe('done', f2);
這種方法的性質(zhì)與“事件監(jiān)聽(tīng)”類(lèi)似,但是明顯優(yōu)于后者。因?yàn)榭梢酝ㄟ^(guò)查看“消息中心”,了解存在多少信號(hào)、每個(gè)信號(hào)有多少訂閱者,從而監(jiān)控程序的運(yùn)行。
如果有多個(gè)異步操作,就存在一個(gè)流程控制的問(wèn)題:如何確定異步操作執(zhí)行的順序,以及如何保證遵守這種順序。
function async(arg, callback) {
console.log('參數(shù)為 ' + arg +' , 1秒后返回結(jié)果');
setTimeout(function () { callback(arg * 2); }, 1000);
}
上面代碼的async
函數(shù)是一個(gè)異步任務(wù),非常耗時(shí),每次執(zhí)行需要1秒才能完成,然后再調(diào)用回調(diào)函數(shù)。
如果有六個(gè)這樣的異步任務(wù),需要全部完成后,才能執(zhí)行最后的final
函數(shù)。請(qǐng)問(wèn)應(yīng)該如何安排操作流程?
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
上面代碼中,六個(gè)回調(diào)函數(shù)的嵌套,不僅寫(xiě)起來(lái)麻煩,容易出錯(cuò),而且難以維護(hù)。
我們可以編寫(xiě)一個(gè)流程控制函數(shù),讓它來(lái)控制異步任務(wù),一個(gè)任務(wù)完成以后,再執(zhí)行另一個(gè)。這就叫串行執(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ù),它會(huì)依次執(zhí)行異步任務(wù),所有任務(wù)都完成后,才會(huì)執(zhí)行final
函數(shù)。items
數(shù)組保存每一個(gè)異步任務(wù)的參數(shù),results
數(shù)組保存每一個(gè)異步任務(wù)的運(yùn)行結(jié)果。
注意,上面的寫(xiě)法需要六秒,才能完成整個(gè)腳本。
流程控制函數(shù)也可以是并行執(zhí)行,即所有異步任務(wù)同時(shí)執(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
方法會(huì)同時(shí)發(fā)起六個(gè)異步任務(wù),等到它們?nèi)客瓿梢院螅艜?huì)執(zhí)行final
函數(shù)。
相比而言,上面的寫(xiě)法只要一秒,就能完成整個(gè)腳本。這就是說(shuō),并行執(zhí)行的效率較高,比起串行執(zhí)行一次只能執(zhí)行一個(gè)任務(wù),較為節(jié)約時(shí)間。但是問(wèn)題在于如果并行的任務(wù)較多,很容易耗盡系統(tǒng)資源,拖慢運(yùn)行速度。因此有了第三種流程控制方式。
所謂并行與串行的結(jié)合,就是設(shè)置一個(gè)門(mén)檻,每次最多只能并行執(zhí)行n
個(gè)異步任務(wù),這樣就避免了過(guò)分占用系統(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();
上面代碼中,最多只能同時(shí)運(yùn)行兩個(gè)異步任務(wù)。變量running
記錄當(dāng)前正在運(yùn)行的任務(wù)數(shù),只要低于門(mén)檻值,就再啟動(dòng)一個(gè)新的任務(wù),如果等于0
,就表示所有任務(wù)都執(zhí)行完了,這時(shí)就執(zhí)行final
函數(shù)。
這段代碼需要三秒完成整個(gè)腳本,處在串行執(zhí)行和并行執(zhí)行之間。通過(guò)調(diào)節(jié)limit
變量,達(dá)到效率和資源的最佳平衡。
更多建議: