學習JavaScript的時候瞭解到JavaScript是單執行緒的,剛開始很疑惑,單執行緒怎麼處理網路請求、檔案讀寫等耗時操作呢?效率豈不是會很低?隨著對這方面內容的瞭解和深入,知道了其中的奧祕。本篇文章就主要講解一下JavaScript怎麼處理非同步問題。
一、同步與非同步
在介紹JavaScript的非同步機制之前,首先介紹一下:什麼是同步?什麼是非同步?
同步
如果在函式返回的時候,呼叫者就能夠得到預期結果(即拿到了預期的返回值或者看到了預期的效果),那麼這個函式就是同步的。
如下所示:
//在函式返回時,獲得了預期值,即2的平方根
Math.sqrt(2);
//在函式返回時,獲得了預期的效果,即在控制檯上列印了'hello'
console.log('hello');
複製程式碼
上面兩個函式就是同步的。
如果函式是同步的,即使呼叫函式執行的任務比較耗時,也會一直等待直到得到預期結果。
非同步
如果在函式返回的時候,呼叫者還不能夠得到預期結果,而是需要在將來通過一定的手段得到,那麼這個函式就是非同步的。
如下所示:
//讀取檔案
fs.readFile('hello.txt', 'utf8', function(err, data) {
console.log(data);
});
//網路請求
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = xxx; // 新增回撥函式
xhr.open('GET', url);
xhr.send(); // 發起函式
複製程式碼
上述示例中讀取檔案函式 readFile
和網路請求的發起函式 send
都將執行耗時操作,雖然函式會立即返回,但是不能立刻獲取預期的結果,因為耗時操作交給其他執行緒執行,暫時獲取不到預期結果(後面介紹)。而在JavaScript中通過回撥函式 function(err, data) { console.log(data); }
和 onreadystatechange
,在耗時操作執行完成後把相應的結果資訊傳遞給回撥函式,通知執行JavaScript程式碼的執行緒執行回撥。
如果函式是非同步的,發出呼叫之後,馬上返回,但是不會馬上返回預期結果。呼叫者不必主動等待,當被呼叫者得到結果之後會通過回撥函式主動通知呼叫者。
二、單執行緒與多執行緒
在上面介紹非同步的過程中就可能會納悶:既然JavaScript是單執行緒,怎麼還存在非同步,那些耗時操作到底交給誰去執行了?JavaScript其實就是一門語言,說是單執行緒還是多執行緒得結合具體執行環境。JS的執行通常是在瀏覽器中進行的,具體由JS引擎去解析和執行。下面我們來具體瞭解一下瀏覽器。
瀏覽器
目前最為流行的瀏覽器為:Chrome,IE,Safari,FireFox,Opera。瀏覽器的核心是多執行緒的。
一個瀏覽器通常由以下幾個常駐的執行緒:
- 渲染引擎執行緒:顧名思義,該執行緒負責頁面的渲染
- JS引擎執行緒:負責JS的解析和執行
- 定時觸發器執行緒:處理定時事件,比如setTimeout, setInterval
- 事件觸發執行緒:處理DOM事件
- 非同步http請求執行緒:處理http請求
需要注意的是,渲染執行緒和JS引擎執行緒是不能同時進行的。渲染執行緒在執行任務的時候,JS引擎執行緒會被掛起。因為JS可以操作DOM,若在渲染中JS處理了DOM,瀏覽器可能就不知所措了。
JS引擎
通常講到瀏覽器的時候,我們會說到兩個引擎:渲染引擎和JS引擎。渲染引擎就是如何渲染頁面,Chrome/Safari/Opera用的是Webkit引擎,IE用的是Trident引擎,FireFox用的是Gecko引擎。不同的引擎對同一個樣式的實現不一致,就導致了經常被人詬病的瀏覽器樣式相容性問題。這裡我們不做具體討論。
JS引擎可以說是JS虛擬機器,負責JS程式碼的解析和執行。通常包括以下幾個步驟:
- 詞法分析:將原始碼分解為有意義的分詞
- 語法分析:用語法分析器將分詞解析成語法樹
- 程式碼生成:生成機器能執行的程式碼
- 程式碼執行
不同瀏覽器的JS引擎也各不相同,Chrome用的是V8,FireFox用的是SpiderMonkey,Safari用的是JavaScriptCore,IE用的是Chakra。
之所以說JavaScript是單執行緒,就是因為瀏覽器在執行時只開啟了一個JS引擎執行緒來解析和執行JS。那為什麼只有一個引擎呢?如果同時有兩個執行緒去操作DOM,瀏覽器是不是又要不知所措了。
所以,雖然JavaScript是單執行緒的,可是瀏覽器內部不是單執行緒的。一些I/O操作、定時器的計時和事件監聽(click, keydown...)等都是由瀏覽器提供的其他執行緒來完成的。
三、訊息佇列與事件迴圈
通過以上了解,可以知道其實JavaScript也是通過JS引擎執行緒與瀏覽器中其他執行緒互動協作實現非同步。但是回撥函式具體何時加入到JS引擎執行緒中執行?執行順序是怎麼樣的?
這一切的解釋就需要繼續瞭解訊息佇列和事件迴圈。
如上圖所示,左邊的棧儲存的是同步任務,就是那些能立即執行、不耗時的任務,如變數和函式的初始化、事件的繫結等等那些不需要回撥函式的操作都可歸為這一類。右邊的堆用來儲存宣告的變數、物件。下面的佇列就是訊息佇列,一旦某個非同步任務有了響應就會被推入佇列中。如使用者的點選事件、瀏覽器收到服務的響應和setTimeout中待執行的事件,每個非同步任務都和回撥函式相關聯。
JS引擎執行緒用來執行棧中的同步任務,當所有同步任務執行完畢後,棧被清空,然後讀取訊息佇列中的一個待處理任務,並把相關回撥函式壓入棧中,單執行緒開始執行新的同步任務。
JS引擎執行緒從訊息佇列中讀取任務是不斷迴圈的,每次棧被清空後,都會在訊息佇列中讀取新的任務,如果沒有新的任務,就會等待,直到有新的任務,這就叫事件迴圈。
上圖以AJAX非同步請求為例,發起非同步任務後,由AJAX執行緒執行耗時的非同步操作,而JS引擎執行緒繼續執行堆中的其他同步任務,直到堆中的所有非同步任務執行完畢。然後,從訊息佇列中依次按照順序取出訊息作為一個同步任務在JS引擎執行緒中執行,那麼AJAX的回撥函式就會在某一時刻被呼叫執行。四、示例
引用一篇文章中提到的考察JavaScript非同步機制的面試題來具體介紹。
執行下面這段程式碼,執行後,在 5s 內點選兩下,過一段時間(>5s)後,再點選兩下,整個過程的輸出結果是什麼?
setTimeout(function(){
for(var i = 0; i < 100000000; i++){}
console.log('timer a');
}, 0)
for(var j = 0; j < 5; j++){
console.log(j);
}
setTimeout(function(){
console.log('timer b');
}, 0)
function waitFiveSeconds(){
var now = (new Date()).getTime();
while(((new Date()).getTime() - now) < 5000){}
console.log('finished waiting');
}
document.addEventListener('click', function(){
console.log('click');
})
console.log('click begin');
waitFiveSeconds();
複製程式碼
要想了解上述程式碼的輸出結果,首先介紹下定時器。
setTimeout
的作用是在間隔一定的時間後,將回撥函式插入訊息佇列中,等棧中的同步任務都執行完畢後,再執行。因為棧中的同步任務也會耗時,所以間隔的時間一般會大於等於指定的時間。
setTimeout(fn, 0)
的意思是,將回撥函式fn立刻插入訊息佇列,等待執行,而不是立即執行。看一個例子:
setTimeout(function() {
console.log("a")
}, 0)
for(let i=0; i<10000; i++) {}
console.log("b")
複製程式碼
b a
複製程式碼
列印結果表明回撥函式並沒有立刻執行,而是等待棧中的任務執行完畢後才執行的。棧中的任務執行多久,它就得等多久。
理解了定時器的作用,那麼對於輸出結果就容易得出了。
首先,先執行同步任務。其中waitFiveSeconds
是耗時操作,持續執行長達5s。
0
1
2
3
4
click begin
finished waiting
複製程式碼
然後,在JS引擎執行緒執行的時候,'timer a'對應的定時器產生的回撥、 'timer b'對應的定時器產生的回撥和兩次 click 對應的回撥被先後放入訊息佇列。由於JS引擎執行緒空閒後,會先檢視是否有事件可執行,接著再處理其他非同步任務。因此會產生 下面的輸出順序。
click
click
timer a
timer b
複製程式碼
最後,5s 後的兩次 click 事件被放入訊息佇列,由於此時JS引擎執行緒空閒,便被立即執行了。
click
click
複製程式碼
參考文章
JavaScript:徹底理解同步、非同步和事件迴圈(Event Loop)
從setTimeout說事件迴圈模型
JavaScript單執行緒和非同步機制
JavaScript的單執行緒機制
JavaScript單執行緒非同步的背後——事件迴圈機制
JavaScript 執行機制詳解:再談Event Loop