瀏覽器中的頁面循環系統:15 | 消息隊列和事件循環:頁面是怎么“活”起來的?


前言:該篇說明:請見 說明 —— 瀏覽器工作原理與實踐 目錄

 

  前面我們講到了每個渲染進程都有一個主線程,並且主線程非常繁忙,既要處理 DOM,又要計算樣式,還要處理布局,同時還需要處理 JavaScript 任務以及各種輸入事件。要讓這么多不同類型的任務在主線程中有條不紊地執行,這就需要一個系統來統籌調度這些任務,這個統籌調度系統就是我們今天要講的消息隊列和事件循環系統。

 

  在寫這篇文章之前,我翻閱了大量的資料,卻發現沒有一篇文章能把消息循環系統給講清楚的,所以我決定用一篇文章來專門介紹頁面的事件循環系統。事件循環非常底層且非常重要,學會它能讓你理解頁面到底是如何運行的,所以在本篇文章中,我們會將頁面的事件循環給梳理清楚、講透徹。

 

  為了能讓你更加深刻地理解事件循環機制,我們就從最簡單的場景來分析,然后帶你一步步了解瀏覽器頁面主線程是如何運作的。

 

  需要說明的是,文章中的代碼我會采用 C++ 來示范。如果你不熟悉 C++,也沒有關系,這里並沒有涉及到任何復雜的知識點,只要你了解 JavaScript 或 Python,你就會看懂。

 

使用單線程處理安排好的任務

  我們先從最簡單的場景講起,比如有如下一系列的任務:

  • 任務1:1+2
  • 任務2:20/5
  • 任務3:7*8
  • 任務4:打印出任務1、任務2、任務3 的運算結果

 

  現在要在一個線程中去執行這些任務,通常我們會這樣編寫代碼:

void MainThread(){
    int num1 = 1+2; // 任務1
    int num2 = 20/5; // 任務2
    int num3 = 7*8; // 任務3
    print("最終計算的值為:%d,%d,%d",num1,num2,num3); // 任務4
}

  在上面的執行代碼中,我們把所有任務按照順序寫進主線程里,等線程執行時,這些任務會按照順序在線程中依次被執行;等所有任務執行完成之后,線程會自動退出。 可以參考下圖來直觀地理解下其執行過程:

第一版:線程的一次執行

 

在線程運行過程中處理新任務

  但並不是所有的任務都是在執行之前統一安排好的,大部分情況下,新的任務是在線程運行過程中產生的。比如在線程執行過程中,又接收到了一個新的任務要求計算 “10+2”,那上面那種方式就無法處理這種情況了。

 

  要想在線程運行過程中,能接收並執行新的任務,就需要采用事件循環機制。我們可以通過一個 for 循環語句來監聽是否有新的任務,如下面的示例代碼:

// GetInput
// 等待用戶從鍵盤輸入一個數字,並返回該輸入的數字
int GetInput(){
    int input_number = 0;
    cout<<"請輸入一個數:";
    cin>>input_number;
    return input_number;
}

// 主線程(Main Thread)
void MainThread(){
    for(;;){
        int first_num = GetInput();
        int second_num = GetInput();
        result_num = first_num + second_num;
    }
}

 

  相較於第一版的線程,這一版的線程做了兩點改進。

  • 第一點引入了循環機制,具體實現方式是在線程語句最后添加了一個 for 循環語句,線程會一直循環執行。
  • 第二點是引入了事件,可以在線程運行過程中,等待用戶輸入的數字,等待過程中線程處於暫停狀態,一旦接收到用戶輸入的信息,那么線程會被激活,然后執行相加運算,最后輸出結果。

 

  通過引入事件循環機制,就可以讓該線程 “活” 起來了,我們每次輸入兩個數字,都會打印出兩數字相加的結果,你可以結合下圖來參考下這個改進版的線程:

第二版:在線程中引入事件循環

 

處理其他線程發送過來的任務

  上面我們改進了線程的執行方式,引入了事件循環機制,可以讓其在執行過程中接受新的任務。不過在第二版的線程模型中,所有的任務都是來自於線程內部的,如果另外一個線程想讓主線程執行一個任務,利用第二版的線程模型是無法做到的。

 

  那下面我們就來看看其他線程是如何發送消息給渲染主線程的,具體形式你可以參考下圖:

渲染進程線程之間發送任務

 

  從上圖可以看出,渲染主線程會頻繁接收到來自於 IO 線程的一些任務,接收到這些任務之后,渲染進程就需要着手處理,比如接收到資源加載完成的消息后,渲染進程就要着手進行 DOM 解析了;接收到鼠標點擊的消息后,渲染主線程就要開始執行相應的 JavaScript 腳本來處理該點擊事件。

 

  那么如何設計好一個線程模型,能讓其能夠接收其他線程發送的消息呢?

 

  一個通用模式是使用消息隊列。在解釋如何實現之前,我們先說說什么是消息隊列,可以參考下圖:

  從圖中可以看出,消息隊列是一種數據結構,可以存放要執行的任務。它符合隊列 “先進先出” 的特點,也就是說要添加任務的話,添加到隊列的尾部;要取出任務的話,從隊列頭部去取

 

  有了隊列之后,我們就可以繼續改造線程模型了,改造方案如下圖所示:

第三版線程模型:隊列 + 循環

 

  從上圖可以看出,我們的改造可以分為下面三個步驟:

  1. 添加一個消息隊列;
  2. IO 線程中產生馬丹新任務添加進消息隊列尾部;
  3. 渲染主線程會循環地從消息隊列頭部中讀取任務,執行任務。

 

  有了這些步驟之后,那么接下來我們就可以按步驟使用代碼來實現第三版的線程模型

 

  首先,構造一個隊列。當然,在本篇文章中我們不需要考慮隊列實現的細節,只是構造隊列的接口:

class TaskQueue{
    public:
    Task takeTask(); // 取出隊列頭部的一個任務
    void pushTask(Task task); // 添加一個任務到隊列尾部
};

 

  接下來,改造主線程,讓主線程從隊列中讀取任務:

TaskQueue task_queue;
void ProcessTask();
void MainThread(){
    for(;;){
        Task task = task_queue.takeTask();
        ProcessTask(task);
    }
}

  

  在上面的代碼中,我們添加了一個消息隊列的對象,然后在主線程的 for 循環代碼塊中,從消息隊列中讀取一個任務,然后執行該任務,主線程就這樣一直循環往下執行,因此只要消息隊列中有任務,主線程就會去執行。

 

  主線程的代碼就這樣改造完成了。這樣改造后,主線程執行的任務都全部從消息隊列中獲取。所以如果有其他線程想要發送任務讓主線程去執行,只需要將任務添加到該消息隊列中就可以了,添加任務的代碼如下:

Task clickTask;
task_queue.pushTask(clickTask)

 

  由於是多個線程操作同一個消息隊列,所以在添加任務和取出任務時還會加上一個同步鎖,這塊內容你也要注意下。

 

處理其他進程發送過來的任務

  通過使用消息隊列,我們實現了線程之間的消息通信。在 Chrome 中,跨進程之間的任務也是頻繁發生的,那么如何處理其他進程發送過來的任務?你可以參考下圖:

跨進程發送消息

 

  從圖中可以看出,渲染進程專門有一個 IO 線程用來接收其他進程傳進來的消息,接收到消息之后,會將這些消息組裝成任務發送給渲染主線程,后續的步驟就和前面講解的 “處理其他線程發送的任務” 一樣了,這里就不再重復了。

 

消息隊列中的任務類型

  現在你知道頁面主線程是如何接收外部任務的了,那接下來我們再來看看消息隊列中的任務類型有哪些。你可以參考下 Chromium 的官方源碼,這里面包含了很多內部消息類型,如輸入事件(鼠標滾動、點擊、移動)、微任務、文件讀寫、WebSocket、JavaScript 定時器 等等。

 

  除此之外,消息隊列中還包含了很多與頁面相關的事件,如 JavaScript 執行、解析 DOM、樣式計算、布局計算、CSS 動畫等。

 

  以上這些事件都是在主線程中執行的,所以在編寫 Web 應用時,你還需要衡量這些事件所占用的時長,並想辦法解決單個任務占用主線程過久的問題。

 

如何安全退出

  當頁面主線程執行完成之后,又該如何保證頁面主線程能夠安全退出呢?Chrome 是這樣解決的,確定要退出當前頁面時,頁面主線程會設置一個退出標志的變量,在每次執行完一個任務時,判斷是否有設置退出標志。

 

  如果設置了,那么就直接中斷當前的所有任務,退出線程,你可以參考下面代碼:

TaskQueue task_queue;
void ProcessTask();
bool keep_running = true;
void MainThread(){
    for(;;){
        Task task = task_queue.takeTask();
        ProcessTask(task);
        if(!keep_running) // 如果設置了退出標志,那么直接退出線程循環
            breakj;
    }
}

 

頁面使用單線程的缺點

  上面講述的就是頁面線程的循環系統是如何工作的,那接下來,我們繼續探討頁面線程的一些特征。

 

  通過上面的介紹,你應該清楚了,頁面線程所有執行的任務都來自於消息隊列。消息隊列是 “先進先出” 的屬性,也就是說放入隊列中的任務,需要等待前面的任務被執行完,才會被執行。鑒於這個屬性,就有如下兩個問題需要解決。

 

  第一個問題是如何處理高優先級的任務。

  

  比如一個典型的場景是監控 DOM 節點的變化情況(節點的插入、修改、刪除等動態變化),然后根據這些變化來處理相應的業務邏輯。一個通用的設計的是,利用JavaScript 設計一套監聽接口,當變化發生時,渲染引擎同步調用這些接口,這是一個典型的觀察者模式。

 

  不過這個模式有點問題,因為 DOM 變化非常頻繁,如果每次發生變化的時候,都直接調用相應的 JavaScript 接口,那么這個當前的任務執行時間會被拉長,從而導致執行效率的下降

 

  如果將這些 DOM 變化做成異步的消息事件,添加到消息隊列的尾部,那么又會影響到監控的實時性,因為在添加到消息隊列的過程中,可能前面就有很多任務在排隊了。

 

  這樣也就是說,如果 DOM 發生變化,采用同步通知的方式,會影響當前任務的執行效率;如果采用異步方式,又會影響到監控的實時性

 

  那該如何權衡效率實時性呢?

 

  針對這種情況,微任務就應用而生了,下面我們來看看微任務是如何權衡效率和實時性的。

 

  通常我們把消息隊列中的任務稱為宏任務,每個宏任務中都包含了一個微任務隊列,在執行宏任務的過程中,如果 DOM 有變化,那么就會將該變化添加到微任務列表中,這樣就不會影響到宏任務的繼續執行,因此也就解決了執行效率的問題。

 

  等宏任務中的主要功能都直接完成之后,這時候,渲染引擎並不着急去執行下一個宏任務,而是執行當前宏任務中的微任務,因為 DOM 變化的事件都保存在這些微任務隊列中,這樣也就解決了實時性問題。

 

  第二個是如何解決單個任務執行時長過久的問題。

  

  因為所有的任務都是在單線程中執行的,所以每次只能執行一個任務,而其他任務就都處於等待狀態。如果其中一個任務執行時間過久,那么下一個任務就要等待很長時間。可以參考下圖:

單個任務執行時間過久

 

  從圖中你可以看到,如果在執行動畫過程中,其中有個 JavaScript 任務因執行時間過久,占用了動畫單幀的時間,這樣會給用戶制造了卡頓的感覺,這當然是極不好的用戶體驗。針對這種情況,JavaScript 可以通過回調功能來規避這種問題,也就是讓執行的 JavaScript 任務滯后執行。至於瀏覽器是如何實現回調功能的,我們在后面的章節中再詳細介紹。

 

實踐:瀏覽器頁面是如何運行的

  有了上面的基礎知識之后,我們最后來看看瀏覽器的頁面是如何運行的。

  

  你可以打開開發者工具,點擊 “Performance” 標簽,選擇左上角的 “start porfiling and load page” 來記錄整個頁面加載過程中的事件執行情況,如下圖所示:

Performance 頁面

 

  從圖中可以看出,我們點擊展開了 Main 這個項目,其記錄了主線程執行過程中的所有任務。圖中灰色的就是一個個任務,每個任務下面還有子任務,其中的 Parse HTML 任務,是把 HTML 解析為 DOM 的任務。值得注意的是,在執行 Parse HTML 的時候,如果遇到了 JavaScript 腳本,那么會暫停當前的 HTML 解析而去執行 JavaScript 腳本。

 

  至於 Performance 工具,在后面的章節中我們還會詳細介紹,在這里你只需要建立一個直觀的印象就可以了。

 

總結

  好了,今天就講到這里,下面我來總結下今天所講的內容。

  • 如果有一些確定好的任務,可以使用一個單線程來按照順序處理這些任務,這是第一版線程模型。
  • 要在線程執行過程中接收並處理新的任務,就需要引入循環語句和事件系統,這是第二版線程模型。
  • 如果要接收其他線程發送過來的任務,就需要引入消息隊列,這是第三版線程模型。
  • 如果其他進程想要發送任務給頁面主線程,那么先通過 IPC 把任務發送給渲染進程的 IO 線程,IO 線程再把任務發送給頁面主線程。
  • 消息隊列機制並不太靈活,為了適應效率和實時性,引入了微任務。

 

  基於消息隊列的設計是目前使用最廣的消息架構,無論是安卓還是 Chrome 都采用了類似的任務機制,所以理解了本篇文章的內容后,你再理解其他項目的任務機制也會比較輕松。

 

思考時間

  今天給你留的思考題是:結合消息隊列和事件循環,你任務微任務是什么?引入微任務能帶來什么優勢呢?

 

問題記錄

1、宏任務是開會分配的工作內容,微任務是工作過程中被臨時安排的內容,可以這么比喻嗎?

作者回復: 這個比喻形象

 

2、老師請教個問題 用CSS3實現動畫是不是不會影響主線程,和用JS實現動畫會影響主線程,這個說法對么

作者回復: 是這樣的,部分css3的動畫效果是在合成線程上實現的,不需要主線程介入,所以省去了重拍和重繪的過程,這就大大提升了渲染效率。

JavaScript都是在在主線程上執行的,所以JavaScript的動畫需要主線程的參與,所以效率會大打折扣!

 

3、老師,為什么說頁面是單線程架構?

默認情況下每個標簽頁都會配套一個渲染進程,而一個渲染進程里不是有主線程、合成線程、IO線程等多個線程嗎

是因為【排版引擎 blink】 和【JavaScript引擎 v8】都工作在渲染進程的主線程上並且是互斥的,基於這點說頁面是單線程架構?

作者回復: 是的,他們都是在渲染進程的主線程上工作,所以同時只能執行一個。

比如v8除了在主線程上執行JavaScript代碼之外,還會在主線程上執行垃圾回收,所以執行垃圾回收時停止主線程上的所有任務,我們把垃圾回收這個特性叫着全停頓。

 

4、老師,可以請問下:渲染進程的主線程和V8執行機主線程是同一個線程嗎?一個渲染進程有幾個線程,分別有啥作用?

作者回復: 主要有IO線程,用開負責和其它進程IPC通信的,然后主線程主要跑頁面的!

V8是在主線程上執行的,因為dom操作啥的都是在主線程上執行的。

當然還有其它很多輔助線程,比如預解析DOM的線程,垃圾回收也有一些輔助線程。

 

5、老師,所以,事件循環其實是監聽執行任務的循環機制嗎?而每一個執行任務都存檔在消息隊列里面,這些統稱為宏任務,微任務是執行宏任務中遇到的異步操作吧,就是異步代碼,如promise,settimeout任務。執行宏任務遇到異步任務先將其放入微任務列表,等該宏任務執行一遍后再執行該宏任務的微任務列表,我這樣理解對嗎?

作者回復: 第一個理解沒錯,事件循環系統就是在監聽並執行消息隊列中的任務!

第二個理解也沒問題,不過promise觸發的微任務,settimeout觸發的是宏任務!

 

6、由於是多個線程操作同一個消息隊列,所以在添加任務和取出任務時還會加上一個同步鎖。
請問老師,JS執行不是單線程的嗎?為什么這里會說是由多個線程操作同一個隊列?

作者回復: 這里提到的任務是指瀏覽器所以需要處理的任務!

瀏覽器是基於多進程+多線程架構的,所以多進程通訊(IPC)和多線程同步的問題!

因為JavaScript引擎是運行在渲染進程的主線程上的,所以我們說JavaScript是單線程執行的!

 

7、在渲染進程里面,除了I/O線程,其他線程也會往消息隊列中添加任務,是嗎?

作者回復: 有啊,比如渲染過程就有合成線程,解析DOM過程中還有預解析線程,這些現場都會和主線程有交互的

 

8、老師,請問瀏覽器的事件循環和js event loop是一回事嗎?

作者回復: JavaScript沒有自己循環系統,它依賴的就是瀏覽器的循環系統,也就是渲染進程提供的循環系統!

所以可以說是一回事

 

9、何為高優先級的任務?如果當前正在執行一個任務,突然有個高優先級的任務,那么當前這個任務要暫停,先執行這個高優先級的任務嗎?這個高優先級的任務執行完后,在接着執行當前的任務?

作者回復: 任務是原子性的,執行了就不會中斷

 

10、老師,微任務隊列是不是只可能存在與任務隊列中當前正在執行的任務中?就是說在當前任務中創建微任務隊列?

作者回復: 對的,當前任務創建的微任務一定會在當前任務結束之前執行掉!

 

11、接收到消息之后,會將這些消息組裝成任務發送給渲染主線程

這里的【渲染主線程】應該是【消息隊列】吧?

作者回復: 嗯,你的理解的是對的,這塊語言我考慮重新組織下


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM