前言:該篇說明:請見 說明 —— 瀏覽器工作原理與實踐 目錄
前面我們講到了每個渲染進程都有一個主線程,並且主線程非常繁忙,既要處理 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 腳本來處理該點擊事件。
那么如何設計好一個線程模型,能讓其能夠接收其他線程發送的消息呢?
一個通用模式是使用消息隊列。在解釋如何實現之前,我們先說說什么是消息隊列,可以參考下圖:
從圖中可以看出,消息隊列是一種數據結構,可以存放要執行的任務。它符合隊列 “先進先出” 的特點,也就是說要添加任務的話,添加到隊列的尾部;要取出任務的話,從隊列頭部去取。
有了隊列之后,我們就可以繼續改造線程模型了,改造方案如下圖所示:
第三版線程模型:隊列 + 循環
從上圖可以看出,我們的改造可以分為下面三個步驟:
- 添加一個消息隊列;
- IO 線程中產生馬丹新任務添加進消息隊列尾部;
- 渲染主線程會循環地從消息隊列頭部中讀取任務,執行任務。
有了這些步驟之后,那么接下來我們就可以按步驟使用代碼來實現第三版的線程模型。
首先,構造一個隊列。當然,在本篇文章中我們不需要考慮隊列實現的細節,只是構造隊列的接口:
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、接收到消息之后,會將這些消息組裝成任務發送給渲染主線程
這里的【渲染主線程】應該是【消息隊列】吧?
作者回復: 嗯,你的理解的是對的,這塊語言我考慮重新組織下