單線程模型
單線程模型指的是饰躲,JavaScript 只在一個線程上運行。也就是說亚脆,JavaScript 同時只能執(zhí)行一個任務(wù)做院,其他任務(wù)都必須在后面排隊等待。
注意濒持,JavaScript 只在一個線程上運行键耕,不代表 JavaScript 引擎只有一個線程。事實上柑营,JavaScript 引擎有多個線程屈雄,單個腳本只能在一個線程上運行(稱為主線程),其他線程都是在后臺配合官套。
JavaScript 之所以采用單線程酒奶,而不是多線程蚁孔,跟歷史有關(guān)系。JavaScript 從誕生起就是單線程讥蟆,原因是不想讓瀏覽器變得太復(fù)雜勒虾,因為多線程需要共享資源、且有可能修改彼此的運行結(jié)果瘸彤,對于一種網(wǎng)頁腳本語言來說修然,這就太復(fù)雜了。如果 JavaScript 同時有兩個線程质况,一個線程在網(wǎng)頁 DOM 節(jié)點上添加內(nèi)容愕宋,另一個線程刪除了這個節(jié)點,這時瀏覽器應(yīng)該以哪個線程為準结榄?是不是還要有鎖機制中贝?所以,為了避免復(fù)雜性臼朗,JavaScript 一開始就是單線程邻寿,這已經(jīng)成了這門語言的核心特征,將來也不會改變视哑。
這種模式的好處是實現(xiàn)起來比較簡單绣否,執(zhí)行環(huán)境相對單純;壞處是只要有一個任務(wù)耗時很長挡毅,后面的任務(wù)都必須排隊等著蒜撮,會拖延整個程序的執(zhí)行。常見的瀏覽器無響應(yīng)(假死)跪呈,往往就是因為某一段 JavaScript 代碼長時間運行(比如死循環(huán))段磨,導(dǎo)致整個頁面卡在這個地方,其他任務(wù)無法執(zhí)行耗绿。JavaScript 語言本身并不慢苹支,慢的是讀寫外部數(shù)據(jù),比如等待 Ajax 請求返回結(jié)果缭乘。這個時候沐序,如果對方服務(wù)器遲遲沒有響應(yīng),或者網(wǎng)絡(luò)不通暢堕绩,就會導(dǎo)致腳本的長時間停滯。
如果排隊是因為計算量大邑时,CPU 忙不過來奴紧,倒也算了,但是很多時候 CPU 是閑著的晶丘,因為 IO 操作(輸入輸出)很慢(比如 Ajax 操作從網(wǎng)絡(luò)讀取數(shù)據(jù))黍氮,不得不等著結(jié)果出來唐含,再往下執(zhí)行。JavaScript 語言的設(shè)計者意識到沫浆,這時 CPU 完全可以不管 IO 操作捷枯,掛起處于等待中的任務(wù),先運行排在后面的任務(wù)专执。等到 IO 操作返回了結(jié)果淮捆,再回過頭,把掛起的任務(wù)繼續(xù)執(zhí)行下去本股。這種機制就是 JavaScript 內(nèi)部采用的“事件循環(huán)”機制(Event Loop)攀痊。
單線程模型雖然對 JavaScript 構(gòu)成了很大的限制,但也因此使它具備了其他語言不具備的優(yōu)勢拄显。如果用得好苟径,JavaScript 程序是不會出現(xiàn)堵塞的,這就是為什么 Node 可以用很少的資源躬审,應(yīng)付大流量訪問的原因棘街。
為了利用多核 CPU 的計算能力,HTML5 提出 Web Worker 標準承边,允許 JavaScript 腳本創(chuàng)建多個線程遭殉,但是子線程完全受主線程控制,且不得操作 DOM炒刁。所以恩沽,這個新標準并沒有改變 JavaScript 單線程的本質(zhì)。
同步任務(wù)和異步任務(wù)
程序里面所有的任務(wù)翔始,可以分成兩類:同步任務(wù)(synchronous
)和異步任務(wù)(asynchronous
)罗心。
同步任務(wù)是那些沒有被引擎掛起、在主線程上排隊執(zhí)行的任務(wù)城瞎。只有前一個任務(wù)執(zhí)行完畢渤闷,才能執(zhí)行后一個任務(wù)。
異步任務(wù)是那些被引擎放在一邊脖镀,不進入主線程飒箭、而進入任務(wù)隊列的任務(wù)。只有引擎認為某個異步任務(wù)可以執(zhí)行了(比如 Ajax 操作從服務(wù)器得到了結(jié)果)蜒灰,該任務(wù)(采用回調(diào)函數(shù)的形式)才會進入主線程執(zhí)行弦蹂。排在異步任務(wù)后面的代碼,不用等待異步任務(wù)結(jié)束會馬上運行强窖,也就是說凸椿,異步任務(wù)不具有“堵塞”效應(yīng)。
舉例來說翅溺,Ajax 操作可以當作同步任務(wù)處理脑漫,也可以當作異步任務(wù)處理髓抑,由開發(fā)者決定。如果是同步任務(wù)优幸,主線程就等著 Ajax 操作返回結(jié)果吨拍,再往下執(zhí)行;如果是異步任務(wù)网杆,主線程在發(fā)出 Ajax 請求以后羹饰,就直接往下執(zhí)行,等到 Ajax 操作有了結(jié)果跛璧,主線程再執(zhí)行對應(yīng)的回調(diào)函數(shù)严里。
任務(wù)隊列和事件循環(huán)
JavaScript 運行時,除了一個正在運行的主線程追城,引擎還提供一個任務(wù)隊列(task queue
)刹碾,里面是各種需要當前程序處理的異步任務(wù)。(實際上座柱,根據(jù)異步任務(wù)的類型迷帜,存在多個任務(wù)隊列。為了方便理解色洞,這里假設(shè)只存在一個隊列戏锹。)
首先,主線程會去執(zhí)行所有的同步任務(wù)火诸。等到同步任務(wù)全部執(zhí)行完锦针,就會去看任務(wù)隊列里面的異步任務(wù)。如果滿足條件置蜀,那么異步任務(wù)就重新進入主線程開始執(zhí)行奈搜,這時它就變成同步任務(wù)了。等到執(zhí)行完盯荤,下一個異步任務(wù)再進入主線程開始執(zhí)行馋吗。一旦任務(wù)隊列清空,程序就結(jié)束執(zhí)行秋秤。
異步任務(wù)的寫法通常是回調(diào)函數(shù)宏粤。一旦異步任務(wù)重新進入主線程,就會執(zhí)行對應(yīng)的回調(diào)函數(shù)灼卢。如果一個異步任務(wù)沒有回調(diào)函數(shù)绍哎,就不會進入任務(wù)隊列,也就是說鞋真,不會重新進入主線程蛇摸,因為沒有用回調(diào)函數(shù)指定下一步的操作。
JavaScript 引擎怎么知道異步任務(wù)有沒有結(jié)果灿巧,能不能進入主線程呢赶袄?答案就是引擎在不停地檢查,一遍又一遍抠藕,只要同步任務(wù)執(zhí)行完了饿肺,引擎就會去檢查那些掛起來的異步任務(wù),是不是可以進入主線程了盾似。這種循環(huán)檢查的機制敬辣,就叫做事件循環(huán)(Event Loop)。
異步操作的模式
下面總結(jié)一下異步操作的幾種模式零院。
回調(diào)函數(shù)
回調(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ù)嵌套的情況),而且每個任務(wù)只能指定一個回調(diào)函數(shù)稿蹲。
事件監(jiān)聽
另一種思路是采用事件驅(qū)動模式扭勉。異步任務(wù)的執(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ū)動型,運行流程會變得很不清晰朝群。閱讀代碼的時候燕耿,很難看出主流程。
發(fā)布/訂閱
事件完全可以理解成“信號”姜胖,如果存在一個“信號中心”誉帅,某個任務(wù)執(zhí)行完成,就向信號中心“發(fā)布”(publish
)一個信號谭期,其他任務(wù)可以向信號中心“訂閱”(subscribe
)這個信號堵第,從而知道什么時候自己可以開始執(zhí)行。這就叫做”發(fā)布/訂閱模式”(publish-subscribe 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ù)是一個異步任務(wù)庐舟,非常耗時欣除,每次執(zhí)行需要1秒才能完成,然后再調(diào)用回調(diào)函數(shù)挪略。
如果有六個這樣的異步任務(wù),需要全部完成后滔岳,才能執(zhí)行最后的final
函數(shù)杠娱。請問應(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
上面代碼中谱煤,六個回調(diào)函數(shù)的嵌套摊求,不僅寫起來麻煩,容易出錯刘离,而且難以維護室叉。
串行執(zhí)行
我們可以編寫一個流程控制函數(shù),讓它來控制異步任務(wù)硫惕,一個任務(wù)完成以后茧痕,再執(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í)行異步任務(wù)豁辉,所有任務(wù)都完成后令野,才會執(zhí)行final
函數(shù)。items
數(shù)組保存每一個異步任務(wù)的參數(shù)徽级,results
數(shù)組保存每一個異步任務(wù)的運行結(jié)果气破。
注意,上面的寫法需要六秒餐抢,才能完成整個腳本现使。
并行執(zhí)行
流程控制函數(shù)也可以是并行執(zhí)行,即所有異步任務(wù)同時執(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ā)起六個異步任務(wù)殴胧,等到它們?nèi)客瓿梢院螅艜?zhí)行final
函數(shù)。
相比而言团滥,上面的寫法只要一秒竿屹,就能完成整個腳本。這就是說灸姊,并行執(zhí)行的效率較高拱燃,比起串行執(zhí)行一次只能執(zhí)行一個任務(wù),較為節(jié)約時間力惯。但是問題在于如果并行的任務(wù)較多碗誉,很容易耗盡系統(tǒng)資源,拖慢運行速度父晶。因此有了第三種流程控制方式哮缺。
并行與串行的結(jié)合
所謂并行與串行的結(jié)合,就是設(shè)置一個門檻甲喝,每次最多只能并行執(zhí)行n
個異步任務(wù)尝苇,這樣就避免了過分占用系統(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();
上面代碼中埠胖,最多只能同時運行兩個異步任務(wù)糠溜。變量running
記錄當前正在運行的任務(wù)數(shù),只要低于門檻值直撤,就再啟動一個新的任務(wù)非竿,如果等于0
,就表示所有任務(wù)都執(zhí)行完了谊惭,這時就執(zhí)行final
函數(shù)汽馋。
這段代碼需要三秒完成整個腳本,處在串行執(zhí)行和并行執(zhí)行之間圈盔。通過調(diào)節(jié)limit
變量豹芯,達到效率和資源的最佳平衡。