圖解 Chrome 瀏覽器架構是什麼

CPU 與 GPU

CPU 和 GPU 作爲計算機中最重要的兩個計算單元直接決定了計算性能。

CPU

CPU 是計算機的大腦,負責處理各種不同的任務。在過去,大多數 CPU 是單芯片的,核心被安置在同一個芯片上。更新的 CPU 可以支持多核心,運算能力大大加強。而最新的的 cpu 已經達到 10 核心 20 線程數的能力了。

GPU

GPU 是另一個計算機的組成部分,與 CPU 不同,GPU 更擅長利用多核心同時處理單一的任務。像命名那樣,GPU 最初被用於處理圖像。這就是爲什麼使用 GPU 可以更快、更順暢的渲染頁面內容。隨着 GPU 的發展,越來越多的計算任務也可以使用 GPU 來處理。甚至有人說 GPU 是人工智能的大功臣,可見 GPU 已經不再僅用於圖像處理上了。

計算機架構

我們可以把計算機自下而上分成三層:硬件、操作系統和應用。有了操作系統的存在,上層運行的應用可以使用操作系統提供的能力使用硬件資源而不會直接訪問硬件資源。

進程與線程

一個進程是應用正在運行的程序。而線程是進程中更小的一部分。當應用被啓動,進程就被創建出來。程序可以創建線程來幫助其工作。操作系統會爲進程分配私有的內存空間以供使用,當關閉程序時,這段私有的內存也會被釋放。其實還有比線程更小的存在就是協程,而協成是運行在線程中更小的單位。async/await 就是基於協程實現的。

進程間通信(IPC)

一個進程可以讓操作系統開啓另一個進程處理不同的任務。當兩個進程需要通信時,可以時用 IPC(Inter Process Communication)。

多數程序被設計成使用 IPC 來進行進程間的通信,好處在於當一個進程給另一個進程發消息而沒有迴應時,並不影響當前的進程繼續工作。

瀏覽器架構

藉助進程和線程,瀏覽器可以被設計成單進程、多線程架構,或者利用 IPC 實現多進程、多線程架構。

這裏我們以 Chrome 多進程架構介紹,在 Chrome 中存在這不同種類型的進程,它們各司其職。

瀏覽器進程做爲 Chrome 中最核心的進程管理着 Chrome 中的其他進程,而 Renderer 則負責渲染不同的站點。

進程工作內容

瀏覽器進程(Browser process)

瀏覽器進程負責管理 Chrome 應用本身,包括地址欄、書籤、前進和後退按鈕。同時也負責可不見的功能,比如網絡請求、文件按訪問等,也負責其他進程的調度。

渲染進程(Renderer process)

渲染進程負責站點的渲染,其中也包括 JavaScript 代碼的運行,web worker 的管理等。

插件進程(Plugin process)

插件進程負責爲瀏覽器提供各種額外的插件功能,例如 flash。

GPU 進程(GPU process)

GPU 進程負責提供成像的功能。

當然還有其他像擴展進程或工具進程等其他進程,可以在 Chrome 的 Task Manager 面板中查看,面板中列出了運行的進程和其佔用的 CPU、內存情況。

多進程架構的好處

當我們訪問一個站點時,渲染進程會負責運行站點的代碼,渲染站點的頁面,同時響應用戶的交互動作,當我們在 Chrome 中打開三個頁籤同時訪問三個站點時,如果其中一個沒有響應,我們可以關閉它然後使用其他的頁籤,這是因爲 Chrome 爲每個站點創建一個獨立的渲染進程,專門處理當前站點的渲染工作。如果所有的頁面運行在同一個進程中,當有一個頁面沒有響應時,所有的頁面就都卡住了。

另一個好處是,藉助操作系統對進程安全的控制,瀏覽器可以將頁面放置在沙箱中,站點的代碼可以運行在隔離的環境中,保證核心進程的安全。

雖然多進程的架構優於單進程架構,但由於進程獨享自己的私有內存,以渲染進程爲例,雖然渲染的站點不同,但工作內容大體相似,爲了完成渲染工作它們會在自己的內存中包含相同的功能,例如 V8 引擎(用於解析和運行 Javascript),這意味着這部分相同的功能需要佔用每個進程的內存空間。爲了節省內存,Chrome 限制了最大進程數,最大進程數取決於硬件的能力,同時當使用多個頁籤訪問相同的站點時瀏覽器不會創建新的渲染進程。

面向服務的架構

Chrome 將架構從多進程模型轉變成面向服務。瀏覽器將功能以服務的方式提供,以解決多進程架構中的問題。

當 Chrome 運行在擁有強大硬件的計算機上時,會將一個服務以多個進程的方式實現,提高穩定性,當計算機硬件資源緊張時,則可以將多個服務放在一個進程中節省資源。

基於站點隔離的渲染進程

利用 iframe 我們可以在同一個頁面訪問不同站點的資源,但從安全的角度考慮,同源策略不允許一個站點在未得到同意的情況下訪問其他站點的資源,所以從 Chrome 67 開始每個站點由獨立的渲染進程處理被默認啓用。

瀏覽器進程

瀏覽器進程負責處理除了渲染外的大部分工作,瀏覽器進程包括幾個線程:

當我們在地址欄中輸入一個地址時,瀏覽器進程中的 UI 線程最先得知這個動作,並開始處理。

一次訪問

下面我們就從一次常見的訪問入手,逐步瞭解瀏覽器是如何展示頁面的。

Step 1:輸入處理

當我們在地址欄中輸入時,UI 線程會先判斷我們輸入的內容是要搜索的內容還是要訪問一個站點,因爲地址欄同時也是一個搜索框。

Step 2:訪問開始

當我們按下回車開始訪問時,UI 線程將藉助網絡線程訪問站點資源. 瀏覽器頁籤的標題上會出現加載中的圖標,同時網絡線程會根據適當的網絡協議,例如 DNS lookup 和 TLS 爲這次請求建立連接。

當服務器返回給瀏覽器重定向請求時,網絡線程會通知 UI 線程需要重定向,然後會以新的地址做開始請求資源。

Step 3:處理響應數據

當網絡線程收到來自服務器的數據時,會試圖從數據中的前面的一些字節中得到數據的類型(Content-Type),以試圖瞭解數據的格式。

當返回的數據類型是 HTML 時,會將數據傳遞給渲染進程做進一步的渲染工作。但是如果數據類型是 zip 文件或者其他文件格式時,會將數據傳遞給下載管理器做進一步的文件預覽或者下載工作。

在開始渲染之前,網絡線程要先檢查數據的安全性,這裏也是瀏覽器保證安全的地方。如果返回的數據來自一些惡意的站點,網絡線程會顯示警告的頁面。同時,Cross Origin Read Blocking(CORB) 策略也會確保跨域的敏感數據不會被傳遞給渲染進程。

Step 3:渲染過程

當所有的檢查結束後,網絡線程確信瀏覽器可以訪問站點時,網絡線程通知 UI 線程數據已經準備好了。UI 線程會根據當前的站點找到一個渲染進程完成接下來的渲染工作。

在第二步,UI 線程將請求地址傳遞給網絡線程時,UI 線程就已經知道了要訪問的站點。此時 UI 線程就可以開始查找或啓動一個渲染進程,這個動作與讓網絡線程下載數據是同時的。如果網絡線程按照預期獲取到數據,則渲染進程就已經可以開始渲染了,這個動作減少了從網絡線程開始請求數據到渲染進程可以開始渲染頁面的時間。當然,如果出現重定向的請求時,提前初始化的渲染進程可能就不會被使用了,但相比正常訪問站點的場景,重定向往往是少數,在實際工作中,也需要根據特定的場景給出特定的方案,不必追求完美的方案。

Step 4:提交訪問

經歷前面的步驟,數據和渲染進程都已經準備好了。瀏覽器進程會通過 IPC 向渲染進程提交這次訪問,同時也會保證渲染進程可以通過網絡線程繼續獲取數據。一旦瀏覽器進程收到來自渲染進程的確認完畢的消息,就意味着訪問的過程結束了,文檔渲染的過程就開始了。

這時,地址欄顯示出表明安全的圖標,同時顯示出站點的信息。訪問歷史中也會加入當前的站點信息。爲了能恢復訪問歷史信息,當頁籤或窗口被關閉時,訪問歷史的信息會被存儲在硬盤中。

Extra Step:加載完畢

當訪問被提交給渲染進程,渲染進程會繼續加載頁面資源並且渲染頁面。當渲染進程 "結束" 渲染工作,會給瀏覽器進程發送消息,這個消息會在頁面中所有子頁面(frame)結束加載後發出,也就是 onLoad 事件觸發後發送。當收到 "結束" 消息後,UI 線程會隱藏頁籤標題上的加載狀態圖標,表明頁面加載完畢。

但這裏 "結束" 並不意味着所有的加載工作都結束了,因爲可能還有 JavaScript 在加載額外的資源或者渲染新的視圖。

訪問不同的站點

一次普通的訪問到此就結束了。當我們輸入另外一個地址時,瀏覽器進程會重複上面的過程。但是在開始新的訪問前,會確認當前的站點是否關心beforeunload事件。

beforeunload事件可以提醒用戶是否要訪問新的站點或者關閉頁籤,如果用戶拒絕則新的訪問或關閉會被阻止。

由於所有的包括渲染、運行 Javascript 的工作都發生在渲染進程中,瀏覽器進程需要在新的訪問開始前與渲染進程確認當前的站點是否關心unload

如果一次訪問是從一個渲染進程中發起的,例如用戶點擊一個鏈接或者運行 JavaScript 代碼location = 'http://newsite.com'時,渲染進程首先檢查beforeunload。然後再執行和瀏覽器進程初始化訪問同樣的步驟,只不過區別在於這樣的訪問請求是由渲染進程向瀏覽器進程發起的。

當新的站點請求被創建時,一個獨立的渲染進程將被用於處理這個請求。爲了支持像unload的事件觸發,老的渲染進程需要保持住當前的狀態。更詳細的生命週期介紹可以參考 Page lifecycle。

Service worker

Service worker 是一種可以 web 開發者控制緩存的技術。如果 Service worker 被實現成從本地存儲獲取數據時,那麼原本的請求就不會被瀏覽器發送給服務器了。

值得注意的是,Service worker 中的代碼是運行在渲染進程中的。當訪問開始時,網絡線程會根據域名檢查是否有 Service worker 會處理當前地址的請求,如果有,則 UI 線程會找到對應的渲染進程去執行 Service worker 的代碼,而 Service worker 可以讓開發者決定這個請求是從本地存儲還是從網絡中獲取數據。

scope_lookup.png

訪問預加載

如果 Service worker 最終決定要從網絡中獲取數據時,我們會發現這種跨進程的通信會造成一些延遲。Navigation Preload 是一種可以在 Service worker 啓動的同時加載資源的優化機制。藉助特殊的請求頭,服務器可以決定返回什麼樣的內容給瀏覽器。

渲染進程負責頁面的內容

渲染進程負責所有發生在瀏覽器頁籤中的事情。在一個渲染進程中,主線程負責解析,編譯或運行代碼等工作,當我們使用 Worker 時,Worker 線程會負責運行一部分代碼。合成線程和光柵線程是也是運行在渲染進程中的,負責更高效和順暢的渲染頁面。

渲染進程最重要的工作就是將 HTML、CSS 和 Javascript 代碼轉換成一個可以與用戶產生交互的頁面。

解析過程

下面的章節主要介紹渲染進程如何將從網絡線程中獲取的文本轉化成圖像的過程。

DOM 的創建

當渲染進程接收到來自瀏覽器進程提交訪問的消息後就開始接受 HTML 數據,主線程開始解析 HTML 文本字符串,並且將其轉化成 Document Object Model(DOM)。

DOM 是一種瀏覽器內部用於表達頁面結構的數據,同時也爲 Web 開發者提供了操作頁面元素的接口,讓 web 開發者可以在 Javascript 代碼中獲取和操作頁面中的元素。

將 HTML 文本轉化成 DOM 的標準被 HTML Standard 定義。我們會發現在轉化過程中瀏覽器從來不會拋出異常,類似關閉標籤的丟失,開始、關閉標籤匹配錯誤等等。這是因爲 HTML 標準中定義了要靜默的處理這些錯誤,如果對此感興趣可以閱讀 An introduction to error handling and strange cases in the parser。

額外資源的加載

一個網站通常還會使用類似圖片,樣式文件和 JavaScript 代碼等額外的資源。這些資源也需要從網絡或緩存中獲取。主線程在轉化 HTML 的過程中理應挨個加載它們,但是爲了提高效率,預加載掃描(Preload Scanner)與轉換過程會同時運行着。當預加載掃描在分析器分析 HTML 過程中發現了類似 img 或 link 這樣的標籤時,就會發送請求給瀏覽器進程的網絡線程,而主線程會根據這些額外資源是否會阻塞轉化過程而決定是否等待資源加載完畢。

JavaScript 會阻塞轉化過程

當 HTML 分析器發現<script>標籤時,會暫停接下來的 HTML 轉化工作,然後加載、解析並且運行 Javascript 代碼。因爲在 Javascript 代碼中可能會使用類似document.write這樣的 API 去改變 DOM 的結構。這就是爲什麼 HTML 分析器必須等待 Javascript 代碼運行結束才能繼續分析的原因。

告訴瀏覽器要如何加載資源

如果我們的 Javascript 代碼並不需要改變 DOM,可以爲<script>標籤添加asyncdefer屬性,這樣瀏覽器就會異步的加載這些資源並且不會阻塞 HTML 轉化過程。如果 script 標籤是由 JavaScript 代碼創建的,標籤的 async 屬性會默認爲 true。同時我們也可以使用一些預加載技術,比如<link ref="preload">來通知瀏覽器這些資源需要越快下載越好。

樣式計算(Style calculation)

對於展示一個頁面,光有 DOM 是不夠的,因爲我們還需要樣式來讓頁面變得更美觀。主線程會解析樣式(CSS)並決定每個 DOM 元素的樣式。這些樣式取決於 CSS 選擇器的範圍,在瀏覽器開發者工具中我們可以看到這些信息。

即使我們沒有給 DOM 指定任何的樣式,<h1>標籤也會比<h2>標籤顯示的大。這是因爲瀏覽器爲不同的標籤內置了不同的樣式。可以通過 Chromium 源代碼得到這些默認樣式。

佈局(layout)

完成了樣式計算工作後,渲染進程已經知道了 DOM 的結構和每個節點的樣式,但是依然不足以渲染一個頁面。想象一下,讓你在電話中向朋友描述一張圖:“圖中有一個大紅色圓和一個小的、藍色的方塊” 是不足以讓朋友知道這張圖到底是什麼樣的。

佈局是爲元素指定幾何信息的過程。主線程遍歷 DOM 結構中的元素及其樣式,同時創建出帶有座標和元素尺寸信息的佈局樹(Layout tree)。佈局樹的結構與 DOM 樹的結構十分相似,但只包含將會在頁面中顯示的元素。當一個元素的樣式被設置成 display: none 時,元素就不會出現在佈局樹中,但那些樣式被設置成 visiblility:hidden 的元素會出現在佈局樹中。相似的,當我們使用一個包含內容的僞元素(例如p::before { content: 'Hi!' })時,元素會出現在佈局樹中即使這個元素不存在於 DOM 樹中,這也是爲什麼我們使用 DOM 提供的 API 無法獲取僞元素的原因。

描述頁面佈局信息是一項具有挑戰性的工作,即使在只有塊元素的頁面中也必須要考慮字體的大小和在哪裏換行,因爲在計算下一個元素的位置時需要知道上一個元素的尺寸和形狀。

CSS 可以讓元素浮動、可以讓元素在父元素中溢出,可以改變文字的方向。可以想象,在佈局這個階段是多麼繁重的工作。在 Chrome 中,有一整個團隊在維護佈局工作,更詳細的信息可以觀看視頻。

繪製(Paint)

有了 DOM、樣式和佈局還是無法完成渲染工作。試想,當我們試圖複製一張圖畫。我們知道圖畫中元素的尺寸、形狀和位置,我們還需要知道繪製這些元素的順序。

例如,當一個元素 z-index 屬性被設置後,繪製的順序會導致渲染成錯誤的結果。

在這個階段,主線程遍歷佈局樹並創建繪製記錄,繪製記錄是一系列由繪製步驟組成的流程,例如先繪製背景,然後是文字,然後是形狀。

渲染過程是昂貴的

在渲染過程中,任何一個步驟中產生的數據變化都會引起後續一系列的的變化。例如,當佈局樹改變時,繪製需要重構頁面中變化的部分。

當一些元素有動畫發生時,瀏覽器需要在每一幀中繪製這些元素。當無法保證每一幀繪製的連續性時,用戶就會感覺到卡頓。

正常情況下渲染操作可以與屏幕刷新保持同步,但由於這些操作運行在主線程中,也就意味這些操作可能被正在運行的 Javascript 代碼所阻塞。

爲了不影響渲染操作,我們可以將 Javascript 操作優化成小塊,然後使用requestAnimationFrame(),關於如何優化可以參考 Optimize JavaScript Exectuion。當需要大量計算時,也可以使用 Worker 來避免阻塞主進程。

合成(Compositing)

現在,瀏覽器已經知道了文檔結構、每一個元素的樣式,元素的幾何信息,繪製的順序。將這些信息轉化成屏幕上像素的過程叫做光柵化,光柵化是圖形學的範疇。

傳統的做法是將可視區域的內容進行光柵化。隨着用戶滾動頁面,不斷的光柵化更多的區域。然而對於現代瀏覽器,有着更復雜的的過程,這個過程被稱做合成。

合成是一種將頁面拆分成多層的技術,合成線程可以將各個層在不同線程中光柵化,再組合成一個頁面。當滾動時,如果層已經被光柵化,則會使用已經存在的層合成新的幀,動畫則可以通過移動層來實現。

層(Layer)

爲了決定層包含哪些元素,主線程需要遍歷佈局樹以找到需要生成的部分。對開發者來說,當某一部分需要用獨立的層渲染,我們可以使用 css 屬性will-change讓瀏覽器創建層,關於瀏覽器如何生成層的標準可自行查閱。

雖然通過分層可以優化瀏覽器性能,但並不意味着應該給每個元素一個層,過多的層反而影響性能,所以在層的劃分上應該具體形況具體分析。

柵格線程與合成線程

當佈局樹和繪製順序確定以後,主線程會將這些信息提交給合成線程。合成線程會光柵化各個層。一個層包含的內容可能是一個完整的頁面,也可能是頁面的部分,所以合成線程將層拆分成許多塊,並將它們發送給柵格線程。柵格線程光柵化這些塊並將它們存儲在 GPU 緩存中。

合成線程可以決定柵格線程光柵塊的優先級,這樣可以保證用戶能看到的部分可以先被光柵化。一個層也會包含多種塊以支持類似縮放這樣的功能。

當塊被光柵化後,合成線程會使用 draw quads 收集這些信息並創建合成幀(Compositor frame)。

Draw quads

存儲在緩存中,包含類似塊位置這樣的信息,用於描述如何使用塊合成頁面。

Compositor frame

用於存儲表現頁面一幀中包含哪些 Draw quads 的集合。

然後一個合成幀被提交給瀏覽器進程。這時如果瀏覽器 UI 有變化,或者插件的 UI 有變化時,另一個合成幀就會被創建出來。所以每當有交互發生時,合成線程就會創建更多的合成幀然後通過 GPU 將新的部分渲染出來。

合成的好處在於其獨立於主線程。合成線程不需要等待樣式計算和 Javascript 代碼的運行。這也是爲什麼合成更適合優化交互性能,但如果佈局或者繪製需要重新計算則主線程是必須要參與的。

本質上,瀏覽器的渲染過程就是將文本轉換成圖像的過程,而當用戶與頁面發生交互動作時,則顯示新的圖像。在這個過程中由渲染進程中的主線程完成計算工作,由合成線程和柵格線程完成圖像的繪製工作。而在計算過程中,還有強制佈局、重排、重繪等更加細節的概念會在後面的文章中做講解。

從瀏覽器的角度看事件

當我們聽到事件時,通常會聯想到在一個文本框中輸入或者單擊鼠標,但從瀏覽器的角度看,輸入事件意味着所有的用戶動作。鼠標滾輪滾動或者屏幕觸摸都是輸入事件。

當用戶與頁面發生交互時,瀏覽器進程首先接收到事件,然而,瀏覽器進程只關心事件發生時是在哪個頁籤中,所以瀏覽器進程會將事件類型和位置信息等發送給負責當前頁籤的渲染進程,渲染進程會恰當的找到事件發生的元素並且觸發事件監聽器。

合成線程對事件的處理

在前面的章節中,我們知道了合成線程可以通過合成技術合成不同的光柵層優化性能,如果頁面並不監聽任何事件,合成線程可以完全獨立於主線程生成新的合成幀。但如果頁面監聽了事件呢?

標記 “慢滾動” 區域

由於運行 Javascript 是主線程的工作,當頁面被合成線程合成過,合成線程會標記那些有事件監聽的區域。有了這些信息,當事件發生在響應的區域時,合成線程就會將事件發送給主線程處理。如果在非事件監聽區域,則渲染進程直接創建新的幀而不關心主線程。

在事件監聽時標記

在 web 開發中常見的方式就是事件代理。利用事件冒泡,我們可以在目標元素的上層元素中監聽事件。參照下面的代碼。

document.body.addEventListener('touchstart', event => {
if (event.target === area) {
    event.preventDefault();
  }
});

通過這種寫法,可以更高效的監聽事件。但如果從瀏覽器的角度看,此時整個頁面會被標記成 “慢滾動” 區域。這意味着雖然頁面中的某些部分並不需要事件監聽,但合成線程依然要在每次交互發生後等待主線程處理事件,合成線程的優化效果不復存在。

nfsr2.png

爲了解決這個問題,我們可在事件代理時傳入passive: true(IE 不支持)參數。這樣告訴渲染線程,依然需要將事件發送給主線程處理,但不需要等待。

document.body.addEventListener('touchstart', event => {
if (event.target === area) {
        event.preventDefault()
    }
 }, {passive: true});

關於使用 passive 改善滾屏性能,可以參考 MDN 使用 passive 改善滾屏性能。

查找事件目標

hittest.png

當渲染線程將事件發送給主線程後,第一件事就是找到事件觸發的目標。通過在渲染過程中生成的繪製信息,可以根據座標找到目標元素。

減少發送給主線程的事件數量

爲了保證動畫的順暢,需要顯示器在每秒刷新 60 次。對於典型的觸摸事件由合成線程提交給主線程的事件頻率可以達到每秒 60-120 次,對於典型的鼠標事件每秒會發送 100 次。事件發送的頻率通常比屏幕刷新頻率要高。

如果類似touchmove這樣的事件每秒向主線程發送 120 次可能會造成主線程執行時間過長而影響性能。

rawevents.png

爲了減少發送給主線程的事件數量,Chrome 合併了連續的事件。類似wheelmousewheelmousemovepointermovetouchmove這樣的事件會被延遲到下一次requestAnimationFrame前觸發.

coalescedevents.png

而任何的離散事件,類似keydown, keyup, mouseup, mousedown, touchstarttouchend都會立即被髮送給主線程處理。

總結

到此,我們已經可以通過從用戶在瀏覽器地址欄中的一次輸入到頁面圖像的顯示瞭解瀏覽器是如何工作的。這裏我們總結一下。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UEBtgJw8IxmoV0-Ajx0wFQ