1. 程式人生 > 實用技巧 >Js多執行緒和Event Loop

Js多執行緒和Event Loop

引子

幾乎在每一本js相關的書籍中,都會說js是單執行緒的,JS是通過事件佇列(Event Loop)的方式來實現非同步回撥的。 對很多初學JS的人來說,根本搞不清楚單執行緒的JS為什麼擁有非同步的能力,所以,我試圖從程序、執行緒的角度來解釋這個問題。

CPU

說到CPU和程序、執行緒,對計算機作業系統有過學習和了解的同學應該比較熟悉。

計算機的核心是CPU,它承擔了所有的計算任務。

它就像一座工廠,時刻在執行。

假定工廠的電力有限,一次只能供給一個車間使用。 也就是說,一個車間開工的時候,其他車間都必須停工。 背後的含義就是,單個CPU一次只能執行一個任務。

程序就好比工廠的車間,它代表CPU所能處理的單個任務。程序之間相互獨立,任一時刻,CPU總是執行一個程序,其他程序處於非執行狀態。 CPU使用時間片輪轉進度演算法來實現同時執行多個程序。

CPU、程序、執行緒之間的關係

從上文我們已經簡單瞭解了CPU、程序、執行緒,簡單彙總一下。

  • 程序是cpu資源分配的最小單位(是能擁有資源和獨立執行的最小單位)
  • 執行緒是cpu排程的最小單位(執行緒是建立在程序的基礎上的一次程式執行單位,一個程序中可以有多個執行緒)
  • 不同程序之間也可以通訊,不過代價較大
  • 單執行緒與多執行緒,都是指在一個程序內的單和多

瀏覽器是多程序的

我們已經知道了CPU、程序、執行緒之間的關係,對於計算機來說,每一個應用程式都是一個程序, 而每一個應用程式都會分別有很多的功能模組,這些功能模組實際上是通過子程序來實現的。 對於這種子程序的擴充套件方式,我們可以稱這個應用程式是多程序的。

而對於瀏覽器來說,瀏覽器就是多程序的,我在Chrome瀏覽器中打開了多個tab,然後開啟windows控制管理器:

如上圖,我們可以看到一個Chrome瀏覽器啟動了好多個程序。
總結一下:

  • 瀏覽器是多程序的
  • 每一個Tab頁,就是一個獨立的程序

瀏覽器包含了哪些程序

  • 主程序

    • 協調控制其他子程序(建立、銷燬)
    • 瀏覽器介面顯示,使用者互動,前進、後退、收藏
    • 將渲染程序得到的記憶體中的Bitmap,繪製到使用者介面上
    • 處理不可見操作,網路請求,檔案訪問等
  • 第三方外掛程序

    • 每種型別的外掛對應一個程序,僅當使用該外掛時才建立
  • GPU程序

    • 用於3D繪製等
  • 渲染程序,就是我們說的瀏覽器核心

    • 負責頁面渲染,指令碼執行,事件處理等
    • 每個tab頁一個渲染程序

那麼瀏覽器中包含了這麼多的程序,那麼對於普通的前端操作來說,最重要的是什麼呢?

答案是渲染程序,也就是我們常說的瀏覽器核心

瀏覽器核心(渲染程序)

從前文我們得知,程序和執行緒是一對多的關係,也就是說一個程序包含了多條執行緒。

而對於渲染程序來說,它當然也是多執行緒的了,接下來我們來看一下渲染程序包含哪些執行緒。

  • GUI渲染執行緒

    • 負責渲染頁面,佈局和繪製
    • 頁面需要重繪和迴流時,該執行緒就會執行
    • 與js引擎執行緒互斥,防止渲染結果不可預期
  • JS引擎執行緒

    • 負責處理解析和執行JavaScript指令碼程式
    • 只有一個JS引擎執行緒(單執行緒)
    • 與GUI渲染執行緒互斥,防止渲染結果不可預期
  • 事件觸發執行緒

    • 用來控制事件迴圈(滑鼠點選、setTimeout、ajax等)
    • 當事件滿足觸發條件時,將事件放入到JS引擎所在的執行佇列中
  • 定時觸發器執行緒

    • setInterval與setTimeout所在的執行緒
    • 定時任務並不是由JS引擎計時的,是由定時觸發執行緒來計時的
    • 計時完畢後,通知事件觸發執行緒
  • 非同步http請求執行緒

    • 瀏覽器有一個單獨的執行緒用於處理AJAX請求
    • 當請求完成時,若有回撥函式,通知事件觸發執行緒

當我們瞭解了渲染程序包含的這些執行緒後,我們思考兩個問題:

  1. 為什麼JavaScript是單執行緒的
  2. 為什麼 GUI 渲染執行緒為什麼與 JS 引擎執行緒互斥

為什麼 javascript 是單執行緒的

首先是歷史原因,在建立 javascript 這門語言時,多程序多執行緒的架構並不流行,硬體支援並不好。

其次是因為多執行緒的複雜性,多執行緒操作需要加鎖,編碼的複雜性會增高。

而且,如果同時操作 DOM ,在多執行緒不加鎖的情況下,最終會導致 DOM 渲染的結果不可預期。

為什麼 GUI 渲染執行緒與 JS 引擎執行緒互斥

這是由於 JS 是可以操作 DOM 的,如果同時修改元素屬性並同時渲染介面(即JS執行緒和UI執行緒同時執行), 那麼渲染執行緒前後獲得的元素就可能不一致了。

因此,為了防止渲染出現不可預期的結果,瀏覽器設定GUI渲染執行緒和JS引擎執行緒為互斥關係, 當JS引擎執行緒執行時GUI渲染執行緒會被掛起,GUI更新則會被儲存在一個佇列中等待JS引擎執行緒空閒時立即被執行。

從 Event Loop 看 JS 的執行機制

到了這裡,終於要進入我們的主題,什麼是 Event Loop

先理解一些概念:

  • JS 分為同步任務和非同步任務
  • 同步任務都在JS引擎執行緒上執行,形成一個執行棧
  • 事件觸發執行緒管理一個任務佇列,非同步任務觸發條件達成,將回調事件放到任務佇列中
  • 執行棧中所有同步任務執行完畢,此時JS引擎執行緒空閒,系統會讀取任務佇列,將可執行的非同步任務回撥事件新增到執行棧中,開始執行

在前端開發中我們會通過setTimeout/setInterval來指定定時任務,會通過XHR/fetch傳送網路請求, 接下來簡述一下setTimeout/setInterval和XHR/fetch到底做了什麼事

我們知道,不管是setTimeout/setInterval和XHR/fetch程式碼,在這些程式碼執行時, 本身是同步任務,而其中的回撥函式才是非同步任務。

當代碼執行到setTimeout/setInterval時,實際上是JS引擎執行緒通知定時觸發器執行緒,間隔一個時間後,會觸發一個回撥事件, 而定時觸發器執行緒在接收到這個訊息後,會在等待的時間後,將回調事件放入到由事件觸發執行緒所管理的事件佇列中。

當代碼執行到XHR/fetch時,實際上是JS引擎執行緒通知非同步http請求執行緒,傳送一個網路請求,並制定請求完成後的回撥事件, 而非同步http請求執行緒在接收到這個訊息後,會在請求成功後,將回調事件放入到由事件觸發執行緒所管理的事件佇列中。

當我們的同步任務執行完,JS引擎執行緒會詢問事件觸發執行緒,在事件佇列中是否有待執行的回撥函式,如果有就會加入到執行棧中交給JS引擎執行緒執行

用一張圖來解釋:

再用程式碼來解釋一下:

let timerCallback = function() {
  console.log('wait one second');
};
let httpCallback = function() {
  console.log('get server data success');
}

// 同步任務
console.log('hello');
// 同步任務
// 通知定時器執行緒 1s 後將 timerCallback 交由事件觸發執行緒處理
// 1s 後事件觸發執行緒將 timerCallback 加入到事件佇列中
setTimeout(timerCallback,1000);
// 同步任務
// 通知非同步http請求執行緒傳送網路請求,請求成功後將 httpCallback 交由事件觸發執行緒處理
// 請求成功後事件觸發執行緒將 httpCallback 加入到事件佇列中
$.get('www.xxxx.com',httpCallback);
// 同步任務
console.log('world');
//...
// 所有同步任務執行完後
// 詢問事件觸發執行緒在事件事件佇列中是否有需要執行的回撥函式
// 如果沒有,一直詢問,直到有為止
// 如果有,將回調事件加入執行棧中,開始執行回撥程式碼

總結一下:

  • JS引擎執行緒只執行執行棧中的事件
  • 執行棧中的程式碼執行完畢,就會讀取事件佇列中的事件
  • 事件佇列中的回撥事件,是由各自執行緒插入到事件佇列中的
  • 如此迴圈

資源搜尋網站大全 https://www.renrenfan.com.cn 廣州VI設計公司https://www.houdianzi.com

巨集任務、微任務

當我們基本瞭解了什麼是執行棧,什麼是事件佇列之後,我們深入瞭解一下事件迴圈中巨集任務、微任務

什麼是巨集任務

我們可以將每次執行棧執行的程式碼當做是一個巨集任務(包括每次從事件佇列中獲取一個事件回撥並放到執行棧中執行), 每一個巨集任務會從頭到尾執行完畢,不會執行其他。

我們前文提到過JS引擎執行緒和GUI渲染執行緒是互斥的關係,瀏覽器為了能夠使巨集任務和DOM任務有序的進行,會在一個巨集任務執行結果後,在下一個巨集任務執行前,GUI渲染執行緒開始工作,對頁面進行渲染。

// 巨集任務-->渲染-->巨集任務-->渲染-->渲染...

主程式碼塊,setTimeout,setInterval等,都屬於巨集任務

第一個例子:

document.body.style = 'background:black';
document.body.style = 'background:red';
document.body.style = 'background:blue';
document.body.style = 'background:grey';

我們可以將這段程式碼放到瀏覽器的控制檯執行以下,看一下效果:

我們會看到的結果是,頁面背景會在瞬間變成灰色,以上程式碼屬於同一次巨集任務,所以全部執行完才觸發頁面渲染,渲染時GUI執行緒會將所有UI改動優化合並,所以視覺效果上,只會看到頁面變成灰色。

第二個例子:

document.body.style = 'background:blue';
setTimeout(function(){
    document.body.style = 'background:black'
},0)

執行一下,再看效果:

我會看到,頁面先顯示成藍色背景,然後瞬間變成了黑色背景,這是因為以上程式碼屬於兩次巨集任務,第一次巨集任務執行的程式碼是將背景變成藍色,然後觸發渲染,將頁面變成藍色,再觸發第二次巨集任務將背景變成黑色。

什麼是微任務

我們已經知道巨集任務結束後,會執行渲染,然後執行下一個巨集任務, 而微任務可以理解成在當前巨集任務執行後立即執行的任務。

也就是說,當巨集任務執行完,會在渲染前,將執行期間所產生的所有微任務都執行完。

Promise,process.nextTick等,屬於微任務。

第一個例子:

document.body.style = 'background:blue'
console.log(1);
Promise.resolve().then(()=>{
    console.log(2);
    document.body.style = 'background:black'
});
console.log(3);

執行一下,再看效果:

控制檯輸出 1 3 2 , 是因為 promise 物件的 then 方法的回撥函式是非同步執行,所以 2 最後輸出

頁面的背景色直接變成黑色,沒有經過藍色的階段,是因為,我們在巨集任務中將背景設定為藍色,但在進行渲染前執行了微任務, 在微任務中將背景變成了黑色,然後才執行的渲染。

第二個例子:

setTimeout(() => {
    console.log(1)
    Promise.resolve(3).then(data => console.log(data))
}, 0)

setTimeout(() => {
    console.log(2)
}, 0)

// print : 1 3 2

上面程式碼共包含兩個 setTimeout ,也就是說除主程式碼塊外,共有兩個巨集任務, 其中第一個巨集任務執行中,輸出 1 ,並且建立了微任務佇列,所以在下一個巨集任務佇列執行前, 先執行微任務,在微任務執行中,輸出 3 ,微任務執行後,執行下一次巨集任務,執行中輸出 2

總結

  • 執行一個巨集任務(棧中沒有就從事件佇列中獲取)
  • 執行過程中如果遇到微任務,就將它新增到微任務的任務佇列中
  • 巨集任務執行完畢後,立即執行當前微任務佇列中的所有微任務(依次執行)
  • 當前巨集任務執行完畢,開始檢查渲染,然後GUI執行緒接管渲染
  • 渲染完畢後,JS執行緒繼續接管,開始下一個巨集任務(從事件佇列中獲取