一、單線程
- 主線程:JavaScript是單線程的,所謂單線程,是指在JS引擎中負責解釋和執行JavaScript代碼的線程只有一個,叫它主線程;
- 工作線程:實際上瀏覽器還存在其他的線程,例如:處理AJAX請求的線程、處理DOM事件的線程、定時器線程、讀寫文件的線程(例如在Node.js中)等等,這些線程可能存在于JS引擎之內,也可能存在于JS引擎之外,在此我們不作區分,統一叫它們工作線程;
- 總結:
?① JavaScript引擎是單線程運行的,瀏覽器無論在什么時候都有且只有一個線程在運行JavaScript程序;
?② JavaScript引擎用單線程運行也是有意義的,單線程不必理會線程同步這些復雜的問題,問題得到簡化;
二、同步和異步、阻塞和非阻塞
- 區別:在于程序中的各個任務是否按順序執行,異步操作可以改變程序的正常執行順序;
console.log("1");
setTimeout(function() {
console.log("2")
}, 0);
setTimeout(function() {
console.log("3")
}, 0);
setTimeout(function() {
console.log("4")
}, 0);
console.log("5");
// 1
// 5
// 2
// 3
// 4
什么是異步任務?上述代碼中,盡管setTimeout的time延遲時間為0,其中的function也會被放入任務隊列中等待下一個機會到來時執行,而不需要加入任務隊列中的程序必須在任務隊列的程序之前完成,因此程序的執行順序可能和代碼中的順序不一致;
任務隊列的執行時機:任務隊列中的回調函數必須等待任務隊列之外的所有代碼執行完畢之后再執行,這是因為執行程序的時候,瀏覽器默認setTimeout以及ajax請求這一類的方法為耗時程序(盡管有時候并不耗時),將其加入一個隊列,該隊列是一個存儲耗時程序的隊列,在所有不耗時程序執行完后,再來依次執行任務隊列中的程序;
任務排隊:因為javascript是單線程的,這意味著所有的任務需要排隊處理,前一個任務結束,才會執行后一個任務,如果前一個任務耗時很長,后一個任務就不得不一直等著,于是就有了任務隊列這個概念;如果排隊是因為計算量大,CPU忙不過來倒也還好,很多時候CPU是閑著的,因為IO設備很慢(比如AJAX操作從網絡讀取數據),不得不等著結果出來,再往下執行,于是JS語言的設計者意識到,這時主線程完全可以不管IO設備,掛起處于等待中的任務,先運行排在后面的任務,等到IO設備返回了結果,再回來把掛起的任務繼續執行下去;
兩種任務:一種是同步任務(synchronous),是指在主線程上排隊執行的任務,只有前一個任務執行完畢,才能執行后一個任務;二是異步任務(asynchronous):是指不進入主線程、而進入“任務隊列”(task queue)的任務,只有等主線程任務執行完畢,任務隊列才開始通知主線程請求執行任務,該任務才會進入主線程執行;
具體的異步運行機制:
1)所有的同步任務都在主線程上執行,形成一個執行棧(execution context stack);
2)主線程之外,還存在一個“任務隊列”(task queue),只要異步任務有了運行結果,就在“任務隊列”之中放置一個事件;
3)一旦“執行棧”中的所有同步任務執行完畢,系統就會讀取“任務隊列”,看看里面有哪些事件,那些對應的異步任務,于是結束等待狀態,開始執行;
4)主線程不斷重復上面的第三步進行事件循環,只要主線程空了,就會去讀取“任務隊列”,這就是JS的運行機制,這個過程會不斷重復;任務隊列中的事件:任務隊列是一個事件的隊列,也可以理解成消息的隊列,IO設備完成一項任務,就在任務隊列中添加一個事件,表示相關的異步任務可以進入“執行棧”了,主線程讀取“任務隊列”,就是讀里面有哪些事件;任務隊列中的事件,除了IO設備的事件以外,還包括一些用戶產生的事件(比如鼠標點擊、頁面滾動等),比如$(selectot).click(function),這些都是相對耗時的操作,只要指定過這些事件的回調函數,這些事件發生時就會進入任務隊列等待主線程讀取;
回調函數(callback):就是那些會被主線程掛起來的代碼,前面所說的點擊事件$(selectot).click(function)中的function就是一個回調函數,異步任務必須指定回調函數,當主線程開始執行異步任務,就是執行對應的回調函數,例如ajax的success,complete,error也都指定了各自的回調函數,這些函數就會加入任務隊列中,等待執行;
下面以AJAX請求為例,來看一下同步和異步的區別:
異步AJAX:
主線程:“你好,AJAX線程。請你幫我發個HTTP請求吧,我把請求地址和參數都給你了。”
AJAX線程:“好的,主線程。我馬上去發,但可能要花點兒時間呢,你可以先去忙別的。”
主線程::“謝謝,你拿到響應后告訴我一聲啊。”
(接著,主線程做其他事情去了。一頓飯的時間后,它收到了響應到達的通知。)
同步AJAX:
主線程:“你好,AJAX線程。請你幫我發個HTTP請求吧,我把請求地址和參數都給你了。”
AJAX線程:“......”
主線程::“喂,AJAX線程,你怎么不說話?”
AJAX線程:“......”
主線程::“喂!喂喂喂!”
AJAX線程:“......”
(一炷香的時間后)
主線程::“喂!求你說句話吧!”
AJAX線程:“主線程,不好意思,我在工作的時候不能說話。你的請求已經發完了,拿到響應數據了,給你。”
??????正是由于JavaScript是單線程的,而異步容易實現非阻塞,所以在JavaScript中對于耗時的操作或者時間不確定的操作,使用異步就成了必然的選擇;
- 同步阻塞案例:
// 這是一個阻塞式函數, 將一個文件復制到另一個文件上
// 調用這個”copyBigFile()”函數,將一個大文件復制到另一個文件上,將耗時1小時,意味著這個函數的將在一個小時之后返回
function copyBigFile(afile, bfile){
var result = copyFileSync(afile,bfile);
return result;
}
//這是一段程序
console.log("start copying ... ");
var a = copyBigFile('A.txt', 'B.txt'); //這行程序將耗時1小時
console.log("Finished"); // 這行程序將在一小時后執行
console.log("處理一下別的事情"); // 這行程序將在一小時后執行
console.log("Hello World, 整個程序已加載完畢,請享用"); // 這行程序將在一小時后執行
- 同步非阻塞案例:
// 這是一個非阻塞式函數
// 如果復制已完成,則返回 true, 如果未完成則返回 false
// 調用這個函數將立刻返回結果
function copyBigFile(afile,bfile){
var copying = copyFileAsync(afile, bfile);
var isFinished = !copying;
return !isFinished;
}
console.log("start copying ... ");
// 同步的程序需要在一個循環中輪詢結果
while( a = copyBigFile('A.txt', 'B.txt')){
console.log("在這之間還可以處理別的事情");
} ;
console.log("Finished"); // 這行程序將在一小時后執行
console.log("Hello World, 整個程序已加載完畢,請享用"); // 這行程序將在一小時后執行
// 非阻塞式的函數給編程帶來了更多的便利,在長IO操作的同時,可以寫點其他的程序,提高效率,執行結果如下:
// start copying ...
// 在這之間還可以處理別的事情
// 在這之間還可以處理別的事情
// 在這之間還可以處理別的事情
// ...
// Finished
// Hello World, 整個程序已加載完畢,請享用
-
異步非阻塞案例:
??????同步的程序需要在一個循環中輪詢結果,循環里面的程序會被執行好多遍,所以并不好控制來寫一些正常的程序,很難再利用起來,更為合理的方式是對非阻塞式的函數進行利用,也就是主線程不會主動地去詢問結果,而是當任務有了結果的時候再來通知主線程;
//非阻塞式的有異步通知能力的函數
//以下不需要看懂,只用知道這個函數會在完成copy操作之后,執行success
function copyBigFile(afile,bfile, callback){
var copying = copyFileAsync(afile, bfile, function(){ callback();});
var isFinished = !copying;
return !isFinished;
}
// 不同于上一個同步非阻塞函數的地方在于它具有通知功能,能夠在完成操作之后主動地通知程序,“我完成了”
console.log("start copying ... ");
copyBigFile("A.txt","B.txt", function(){
console.log("Finished"); //一個小時后被執行
console.log("Hello World, 整個程序已加載完畢,請享用"); //一個小時后被執行
})
console.log("干別的事情");
console.log("做一些別的處理");
// 程序在調用copyBigFile函數之后,可以立即獲得返回值,線程沒有被阻塞住,于是還可以去干些別的事情,然后當copyBigFile完成之后,會執行指定的函數
// start copying ...
// 干別的事情
// 做一些別的處理
// Finished
// Hello World, 整個程序已加載完畢,請享用
三、異步過程的構成要素
??????從上文可以看出,異步函數實際上很快就調用完成了,但是后面還有工作線程執行異步任務、通知主線程、主線程調用回調函數等很多步驟,我們把整個過程叫做異步過程,異步函數的調用在整個異步過程中,只是一小部分;
??????一個異步過程通常是這樣的:主線程發起一個異步請求,相應的工作線程接收請求并告知主線程已收到(異步函數返回);主線程可以繼續執行后面的代碼,同時工作線程執行異步任務;工作線程完成工作后,通知主線程;主線程收到通知后,執行一定的動作(調用回調函數);
??????異步調用一般分為兩個階段,提交請求和處理結果,這兩個階段之間有事件循環的調用,它們屬于兩個不同的事件循環(tick),彼此沒有關聯,異步調用一般以傳入callback的方式來指定異步操作完成后要執行的動作,而異步調用本體和callback屬于不同的事件循環;
try/catch語句只能捕獲當次事件循環的異常,對callback無能為力
// 異步函數通常具有以下的形式:
A(args...,callbackFn)
// 它可以叫做異步過程的發起函數,或者叫做異步任務注冊函數,args是這個函數需要的參數,callbackFn也是這個函數的參數,但是它比較特殊所以單獨列出來;
從主線程的角度看,一個異步過程包括下面兩個要素:
1)發起函數(或叫注冊函數)A提交請求
2)回調函數callbackFn
它們都是在主線程上調用的,其中注冊函數用來發起異步過程,回調函數用來處理結果;
// 舉個具體的例子:
setTimeout(fn, 1000);
// 其中的setTimeout就是異步過程的發起函數,fn是回調函數。
// 注意:前面說的形式A(args..., callbackFn)只是一種抽象的表示,并不代表回調函數一定要作為發起函數的參數,例如:
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = xxx; // 添加回調函數
xhr.open('GET', url);
xhr.send(); // 發起函數
// 發起函數和回調函數就是分離的。
四、消息隊列和事件循環
JS是單線程的,但卻能執行異步任務,這主要是因為JS中存在事件循環(Event Loop)和任務隊列(Task Queue);
事件循環:JS會創建一個類似于while(true)的循環,每執行一次循環體的過程稱為Tick,每次Tick的過程就是查看是否有待處理事件,如果有則取出相關事件及回調函數放入執行棧中由主線程執行,待處理的事件會存儲在一個任務隊列中,也就是每次Tick都會查看任務隊列中是否有需要執行的任務;實際上事件循環是指主線程重復從消息隊列取出消息、執行的過程;
任務隊列:也稱為消息隊列,是一個先進先出的隊列,它里面存放著各種消息,即異步操作的回調函數,異步操作會將相關回調添加到任務隊列中,而不同的異步操作添加到任務隊列的時機也不同,如onclick,setTimeout,ajax處理的方式都不同,這些異步操作都是由瀏覽器內核的不同模塊來執行的:
1)onclick由瀏覽器內核的DOM Binding模塊來處理,當事件觸發的時候,回調函數會立即添加到任務隊列中;
2)setTimeout會由瀏覽器內核的timer模塊來進行延時處理,當時間到達的時候,才會將回調函數添加到任務隊列中;
3)ajax會由瀏覽器內核的network模塊來處理,在網絡請求完成返回之后,才將回調添加到任務隊列中;主線程:JS只有一個線程,稱之為主線程,而事件循環是主線程中執行棧里的代碼執行完畢之后,才開始執行的,因此主線程中要執行的代碼時間過長,會阻塞事件循環的執行,也就會阻塞異步操作的執行,只有當主線程中執行棧為空的時候,即同步代碼執行完后,才會進行事件循環來觀察要執行的事件回調,當事件循環檢測到任務隊列中有事件就取出相關回調放入執行棧中由主線程執行;
ES6 新增的任務隊列:ES6 中新增的任務隊列是在事件循環之上的,事件循環每次 tick 后會查看 ES6 的任務隊列中是否有任務要執行,也就是 ES6 的任務隊列比事件循環中的任務隊列優先級更高,如 Promise 就使用了 ES6 的任務隊列特性;
AJAX異步:JS是單線程運行的,XMLHttpRequest在連接后是異步的,請求是由瀏覽器新開一個線程請求的,當請求的狀態變更時,如果先前已設置回調,這異步線程就產生狀態變更事件放到JS引擎的處理隊列中等待處理,當任務被處理時,JS引擎始終是單線程運行回調函數,即onreadystatechange所設置的函數;
異步過程中,工作線程在異步操作完成后需要通知主線程,那么這個通知機制是怎樣實現的呢?答案是利用消息隊列和事件循環;
工作線程將消息放到消息隊列,主線程通過事件循環過程去取消息;
實際上,主線程只會做一件事情,就是從消息隊列里面取消息、執行消息,再取消息、再執行,當消息隊列為空時,就會等待直到消息隊列變成非空,而且主線程只有在將當前的消息執行完成后,才會去取下一個消息,這種機制就叫做事件循環機制,取一個消息并執行的過程叫做一次循環;
// 事件循環用代碼表示大概是這樣的:
while(true) {
var message = queue.get();
execute(message);
}
- 那么消息隊列中放的消息具體是什么東西呢?消息的具體結構當然跟具體的實現有關,但是為了簡單起見,我們可以認為:消息就是注冊異步任務時添加的回調函數;
// 再次以異步AJAX為例,假設存在如下的代碼:
$.ajax('http://segmentfault.com', function(resp) {
console.log('我是響應:', resp);
});
// 其他代碼
...
...
...
- 主線程在發起AJAX請求后,會繼續執行其他代碼,AJAX線程負責請求segmentfault.com,拿到響應后,它會把響應封裝成一個JavaScript對象,然后構造一條消息:
// 消息隊列中的消息就長這個樣子
var message = function () {
callbackFn(response);
}
-
其中的callbackFn就是前面代碼中得到成功響應時的回調函數,主線程在執行完當前循環中的所有代碼后,就會到消息隊列取出這條消息(也就是message函數),并執行它,到此為止,就完成了工作線程對主線程的通知,回調函數也就得到了執行,如果一開始主線程就沒有提供回調函數,AJAX線程在收到HTTP響應后,也就沒必要通知主線程,從而也沒必要往消息隊列放消息,用圖表示這個過程就是:
屏幕快照 2018-08-20 下午6.57.24.png - 異步過程的回調函數,一定不在當前這一輪事件循環中執行;
- 還有一點需要注意的是:觸發和執行并不是同一概念,計時器的回調函數一定會在指定delay的時間后被觸發,但并不一定立即執行,可能需要等待,所有的js代碼都是在同一個線程里執行的,但像鼠標點擊和計時器之類的事件只有在js單線程空閑時才執行;
五、異步與事件
- 上文中所說的“事件循環”,為什么里面有個事件呢?那是因為:消息隊列中的每條消息實際上都對應著一個事件;
- 上文中一直沒有提到一類很重要的異步過程:DOM事件;
// 舉例
var button = document.getElement('#btn');
button.addEventListener('click', function(e) {
console.log();
});
- 從事件的角度來看,上述代碼表示:在按鈕上添加了一個鼠標單擊事件的事件監聽器,當用戶點擊按鈕時,鼠標單擊事件觸發,事件監聽器函數被調用;
- 從異步過程的角度看,addEventListener函數就是異步過程的發起函數,事件監聽器函數就是異步過程的回調函數,事件觸發時,表示異步任務完成,會將事件監聽器函數封裝成一條消息放到消息隊列中,等待主線程執行;
- 事件的概念實際上并不是必須的,事件機制實際上就是異步過程的通知機制,我覺得它的存在是為了編程接口對開發者更友好
- 另一方面,所有的異步過程也都可以用事件來描述,例如:setTimeout可以看成對應一個時間到了的事件,前文的setTimeout(fn,1000);可以看成:
timer.addEventListener('timeout', 1000, fn);
六、生產者與消費者
- 從生產者與消費者的角度看,異步過程是這樣的:
- 工作線程是生產者,主線程是消費者(只有一個消費者)。工作線程執行異步任務,執行完成后把對應的回調函數封裝成一條消息放到消息隊列中;主線程不斷地從消息隊列中取消息并執行,當消息隊列空時主線程阻塞,直到消息隊列再次非空。
七、總結
- 最后再用一個生活中的例子總結一下同步和異步:在公路上,汽車一輛接一輛,有條不紊的運行,這時,有一輛車壞掉了,假如它停在原地進行修理,那么后面的車就會被堵住沒法行駛,交通就亂套了,幸好旁邊有應急車道,可以把故障車輛推到應急車道修理,而正常的車流不會受到任何影響。等車修好了,再從應急車道回到正常車道即可。唯一的影響就是,應急車道用多了,原來的車輛之間的順序會有點亂;
- 這就是同步和異步的區別,同步可以保證順序一致,但是容易導致阻塞;異步可以解決阻塞問題,但是會改變順序性,改變順序性其實也沒有什么大不了的,只不過讓程序變得稍微難理解了一些;
- PS:ECMAScript 262規范中,并沒有對異步、事件隊列等概念及其實現的描述。這些都是具體的JavaScript運行時環境使用的機制。本文重點是描述異步過程的原理,為了便于理解做了很多簡化。所以文中的某些術語的使用可能是不準確的,具體細節也未必是正確的,例如消息隊列中消息的結構。請讀者注意。
八、Event Loop的其他解釋
- Event Loop是一個程序結構,用于等待和發送消息和事件;
-
簡單說,就是在程序中設置兩個線程:一個負責程序本身的運行,稱為"主線程";另一個負責主線程與其他進程(主要是各種I/O操作)的通信,被稱為"Event Loop線程"(可以譯為"消息線程");
Event Loop
??????上圖主線程的綠色部分,還是表示運行時間,而橙色部分表示空閑時間。每當遇到I/O的時候,主線程就讓Event Loop線程去通知相應的I/O程序,然后接著往后運行,所以不存在紅色的等待時間。等到I/O程序完成操作,Event Loop線程再把結果返回主線程。主線程就調用事先設定的回調函數,完成整個任務;
??????可以看到,由于多出了橙色的空閑時間,所以主線程得以運行更多的任務,這就提高了效率。這種運行方式稱為"異步模式"(asynchronous I/O)或"非堵塞模式"(non-blocking mode);
參考鏈接:
JavaScript:徹底理解同步、異步和事件循環(Event Loop)
js-關于異步原理的理解和總結
js中的同步和異步的個人理解
JS之異步概念
深入解析Javascript異步編程