試圖探尋JavaScript的非同步設計

芒僧發表於2019-03-02

閱讀本文,你將知道:

  1. 同步和非同步
  2. 阻塞和非阻塞
  3. JavaScript的非同步實現方式
  4. 進一步深入理解為什麼“JavaScript是非同步事件驅動的單執行緒程式語言”
試圖探尋JavaScript的非同步設計

前言

在最初學習JavaScript的時候,就從各個地方得知JavaScript是一門單執行緒程式語言,但是令人疑惑的是,為什麼一個單執行緒語言能夠同時執行HTTP請求同時渲染頁面?為什麼程式碼的書寫順序和執行順序並不一致?

這就是非同步帶來的效果

這裡我們必須達到如下的共識:

  • 瀏覽器是多執行緒的,常駐執行緒有:
    • 瀏覽器 GUI 渲染執行緒
    • JavaScript 引擎執行緒
    • 瀏覽器定時觸發器執行緒
    • 瀏覽器事件觸發執行緒
    • 瀏覽器 http 非同步請求執行緒
  • JavaScript是單執行緒的

這裡需要注意的是GUI渲染程式和JavaScript引擎程式是互斥的,因為如果這兩個執行緒可以同時執行的話,JavaScript的DOM操作將會擾亂渲染執行緒執行渲染前後的資料一致性。

本文想要探討的,是JavaScript執行緒裡的非同步設計,千萬別和多執行緒混淆了。

想要了解更多關於瀏覽器多執行緒機制請參考:www.cnblogs.com/hksac/p/659…

開始

一切得先從CPU開始講起:

CPU的指令執行速度是遠高於硬碟讀取速度和主存讀取速度的。而I/O操作就會涉及到硬碟存取和主存讀取,常見的I/O操作有檔案I/O,網路I/O。(I/O = Input / Output)。

所以,觀察以下這一段虛擬碼:

var a = 2;

for(let i =0;i<10;i++){
    doSomeWork();
}

let buffer = openFile(`./work.txt`)

buffer.add(`hello world`);

複製程式碼

在CPU眼中,他會把程式碼看成這兩部分:

試圖探尋JavaScript的非同步設計

綠色部分因為不涉及到I/O操作,所以CPU執行速度超快,但是當執行到紅色部分時,卻是一個非常耗時的操作,而這段時間,CPU是處於一個`無所事事`的狀態(DMA獲取匯流排控制權之後一切I/O與CPU無關),因為檔案如果沒有讀取進來,下面的工作也無法開展。

同步在這裡的意思,即書寫程式碼的順序就是程式碼執行的順序,如果JavaScript設計成同步的話,那麼當執行到openFile這一行的時候,將會等待該I/O操作完成CPU才繼續往下執行。

設想一下,當傳送Ajax請求(網路I/O)的時候,整個頁面被阻塞無法操作將會是多差的體驗。

而諸如滑鼠點選事件,滑動事件,失焦事件,在CPU看來,都是處理得特別慢的事件(雖然對我們來說是一瞬間的事情),如果將JavaScript設計成同步,也會特別浪費CPU效能。

阻塞和非阻塞關注的CPU在I/O發生時的工作情況

在上面這個讀取檔案的例子中

  • 如果在讀取檔案的同時,該執行緒被‘掛起’(可以理解為程式的阻塞態),CPU不在關注這個執行緒直到結果被返回,屬於阻塞式
  • 如果在讀取檔案的同時,CPU會時不時關注並檢查一遍結果是否返回,則屬於非阻塞式

如果無法區分同步阻塞,請參考這裡

非同步則解決了程式碼被耗時任務阻止其往下執行的缺點

多執行緒非同步有著比較好的解決方案:

  • 給涉及到I/O操作的部分新開一個執行緒執行
  • 主執行緒不等待繼續往下執行
  • I/O執行緒執行完之後將結果寫回公共區並通知主執行緒(也可以是主執行緒去輪詢)
  • 主執行緒執行其回撥

但是

JavaScript是一門單執行緒語言,本身無法提供多執行緒,那麼是通過怎樣的機制來實現非同步的?

先給出答案:JavaScript通過事件迴圈和瀏覽器各執行緒協調共同實現非同步

JavaScript認為任務分為兩種,一種是全由CPU決定完成速度的任務,我們稱其為同步任務,一種是由多種因素(如硬碟讀取速度,網速,點選反饋速度)決定完成速度的任務,我們稱其為非同步任務。

舉個簡單的例子

  • 函式宣告,for迴圈,變數宣告,賦值操作等都可以屬於同步任務
  • 讀取檔案,網路請求,網頁事件都看做非同步任務

JavaScript將所有的非同步任務都會放進一個佇列裡面,在執行完所有的同步任務之後,會去佇列中找到最先進入佇列的非同步任務執行。

試圖探尋JavaScript的非同步設計

仔細觀察上圖,結合本文在最開始提到的瀏覽器多執行緒設計:

  • JavaScript執行緒首先執行同步任務
  • 在執行完同步任務之後,會去非同步任務佇列的隊頭取出任務執行
  • 瀏覽器各個執行緒會在事件觸發且完成事件之後將回撥函式寫入非同步佇列(先進先出佇列)

因為諸如事件觸發,http請求都是耗時無法直接確定的任務,也就是說JavaScript執行緒無法得知非同步的任務回撥函式究竟什麼時候會寫入非同步任務佇列,那麼這個地方,就需要一個機制,去時刻輪詢這個任務佇列,這就是事件迴圈(event loop)

現在我們再看如下程式碼的執行順序:

var req = new XMLHttpRequest();
    req.open(`GET`, url);    
    req.onload = function (){};    
    req.onerror = function (){};    
    req.send();
複製程式碼

真正的執行順序是

var req = new XMLHttpRequest();
    req.open(`GET`, url);
    req.send();    // i am here
    req.onload = function (){};    
    req.onerror = function (){};    
複製程式碼

同理

while(1){
console.log(`1`)
}

setTimeOut(()=>{
	console.log(`00000000000000000`)
},1)
複製程式碼

也將同樣永遠不會輸出00000000000000000

討論下為什麼這樣設計

因為JavaScript的工作環境是一個典型的非同步應用場景:充斥著各種ajax事件和瀏覽器事件。各個事件的觸發時間和得到反饋的時間都不得而知,如果設計成同步語言,將會帶來極差的瀏覽器使用體驗。
需要設計一個成一個生產者-消費者模型(也可以看做是觀察者模式),來管理這樣的非同步任務。

瀏覽器需要做的事情太多了,一手需要負責渲染,一手需要負責http請求,一手還需要執行JavaScript,將JavaScript設計成單執行緒不僅能夠讓瀏覽器更好地控制各個執行緒,同時對開發者來說也更簡單。多執行緒涉及到鎖,臨界區,衝突解決的學習成本還是比較高的。

總結

再次來看一下這一句話:

JavaScript是非同步事件驅動的單執行緒程式語言

非同步:寫程式碼順序不一定是執行順序,JavaScript執行緒先執行同步任務。
事件驅動:其他執行緒在各事件完成後將回撥函式寫入佇列,都是以抽象事件作為觸發機制的。
單執行緒:不能開多執行緒而是用eventloop來實現非同步的。

JavaScript通過事件迴圈和瀏覽器各執行緒協調共同實現非同步

JavaScript的非同步設計非常優秀,這也讓基於V8引擎的Node在服務端大方異彩,能夠更加簡單地開發出適合高密集I/O的web應用。

之後會基於JavaScript的EventLoop總結下關於Node的非同步I/O(涉及到多執行緒)

如果喜歡,請關注

最新部落格會最先更新在http://www.helloyzy.cn

相關文章