瀏覽器輸入 URL 後發生了什麼?

 

這是一道非常經典的題目,相信你被面試或者面試別人有非常大的概率接觸過,也可能只是其中某一部分進行提問。這道題涵蓋的知識點非常多,考察得比較全面,網上一搜也有成百上千篇文章,不同的人有不同的見解,然而大部分都是千篇一律。如果你沒有深入透徹系統性地研究過,光靠死記硬背,面試官稍稍針對某一點提問,或者換成另外一種方式提問,就有可能露出破綻。仔細想想,學習積累到了一定階段,也該憑技術儲備對知識體系進行一遍全面的梳理總結。

開放性的題目,沒有固定的答案,涉及計算機圖形學、操作系統、編譯原理、計算機網絡、通信原理、分佈式系統、瀏覽器原理等多個不同的學科、領域。但無論從哪個領域入手,軟件角度或硬件角度,鋪開來講都可以是長篇大論。如果你專精某個學科領域多年,那你在這一方面肯定比我有更深厚的經驗、更獨特的見解,歡迎指點。

從我的角度來看,在題意不夠明確、缺少情景和限定條件的情況下,沒法直接作答。在計算機越來越複雜的今天,任何一個條件的變化與組合,都有可能產生千千萬萬種可能,打破常規。對題目本身而言,就會包括但不僅限於以下幾種條件:

  • 請求資源類型
  • 瀏覽器類型及版本
  • 服務器類型及版本
  • 網絡協議類型及版本
  • 網絡鏈路狀況
  • 經過哪些中間設備
  • 局域網類型及標準
  • 物理媒介類型
  • 運營商路線

如果請求的是靜態資源,那麼流量有可能到達 CDN 服務器;如果請求的是動態資源,那麼情況更加複雜,流量可能依次經過代理/網關、Web 服務器、應用服務器、數據庫。圖 1 爲阿里雲 SLB(Server Load Balancer,負載均衡)高可用部署示意圖,它不同於傳統的主備切換模式過於依賴單機處理能力,來自公網的請求通過上層交換機的 ECMP(Equal-cost multi-path routing,等價多路徑路由)將流量轉發給 LVS 集羣(四層 SLB),對於 TCP/UDP 請求,LVS 集羣直接轉發給後端 ECS 集羣,對於 HTTP 請求,則轉發給 Tengine 集羣(七層 SLB),由 Tengine 集羣再轉發給後端 ECS 集羣,集羣之間通過實現會話同步、健康檢查等機制來保證高可用。

圖 1:阿里雲負載均衡服務

隨着業務規模的不斷擴大,爲了承載千萬級甚至億級流量及海量存儲,對系統的多機房容災、自由擴容的要求越來越高,系統還可能演進爲異地多活架構(圖 2)。與傳統的災備設計不同的是,多個數據中心同時對外提供服務,同時保證異地單元間數據庫數據的一致性和完整性(CAP 理論)。整個系統架構分爲流量層、應用層、數據層,利用 DNS 技術實現 GSLB(Global Server Load Balance,全局負載均衡),實現用戶就近訪問。如果某個地域的系統發生整體故障,則把所有流量請求切換到另一個地域,來滿足異地容災,這也類似於餓了麼現階段整體架構方案。

圖 2:阿里雲異地多活解決方案

但是,不同於動態資源,考慮部署成本和流量成本,靜態資源一般是通過 CDN,利用中間服務器作緩存,如果沒有命中緩存,再回源到 OSS(Object Storage Service,阿里雲對象存儲服務)或者私有服務器。

因此,現實世界的情況是千變萬化的,你也很難想象一個 GET 請求甚至觸發銀行的轉賬操作,一切取決於人爲實現。重新回到本文的主題,我們排除一切特殊條件,把問題簡化一下,如果僅僅考慮:

  • 一個 Chrome 瀏覽器
  • 一臺 Linux 服務器
  • 發起 HTML 請求
  • 不考慮任何緩存和優化機制
  • 採用 HTTP/1.1 + TLS/1.2 + TCP 協議

這個過程如下:

DNS 解析過程

首先,瀏覽器向本地 DNS 服務器發起請求,由於本地 DNS 服務器沒有緩存不能直接將域名轉換爲 IP 地址,需要採用遞歸或者迭代查詢的方式(圖 3)依次向根域名服務器、頂級域名服務器、權威域名服務器發起查詢請求,直至找到一個或一組 IP 地址,返回給瀏覽器。一般本地 DNS 地址由 ISP(Internet Service Provider,互聯網服務提供商)通過 DHCP 協議動態分配,我們仍可以手動把它修改爲公共 DNS,比如 Google 提供的 8.8.8.8,國內的 114.114.114.114,它們分佈在不同的地理位置上,藉助 Anycast 技術,將請求路由到離用戶最近的 DNS 服務器上。爲了讓 DNS 解析更加精確,客戶端還需在請求包裏帶上自己的源 IP 地址,否則類似 GSLB 的 DNS 服務器不能夠精準地匹配判斷離用戶最近的目標 IP 地址。

圖 3:DNS 查詢過程

HTTP 請求過程

由於 HTTP 是基於更易於閱讀的文本格式,除了在瀏覽器直接發起 HTTP 之外,你也可以用命令行 telnet 來與服務器指定端口建立 TCP 連接,按照協議規定的格式,來發送請求頭和請求實體。另外,如果想查看詳細具體的封包內容,可以使用網絡封包分析工具 Wireshark 或命令行 tcpdump,來捕獲某一塊網卡上的數據包。在上一步我們通過 DNS 解析拿到服務器 IP 地址後,瀏覽器再通過系統調用 Socket 接口與服務器 443 端口進行通信,整個過程可以分解爲建立連接、發送 HTTP 請求、返回 HTTP 響應、維持連接、釋放連接五個部分(圖 4)。圖中所示箭頭有可能代表一個 TCP 報文段,也有可能代表一個完整的應用層報文,在實際傳輸過程中,會被組合爲一個或分片爲多個 TCP 報文段。

圖 4:HTTP 請求過程

建立連接

  1. 在連接建立之前,服務器必須做好接受連接的準備,通過調用 socket、bind、listen 和 accept 四個函數來完成綁定公網 IP、監聽 443 端口和接受請求的任務。
  2. 客戶端通過 socket 和 connect 兩個函數來主動打開連接,給服務器發送帶有 SYN 標誌位的分組,隨機生成一個初始序列號 x,以及附帶 MSS(Maximum Segment Size,最大段大小)等額外信息。爲了避免在網絡層被 IP 協議分片使得出現丟失錯誤的概率增加,及達到最佳的傳輸效果,MSS 的值一般爲以太網 MTU(Maximum Transmission Unit,最大傳輸單元)的值減去 IP 頭部和 TCP 頭部大小,等於 1460 字節。
  3. 服務器必須確認收到客戶端的分組,發送帶有 SYN+ACK 標誌位的分組,隨機生成一個初始序列號 y,確認號爲 x+1,以及附帶 MSS 等額外信息。當一端收到另外一端的 MSS 值時,會根據兩者的 MSS 取最小值來決定隨後的 TCP 最大報文段大小。
  4. 客戶端確認收到服務器的分組,發送帶有 ACK 標誌位的分組,確認號爲 y+1,從而建立 TCP 連接。
  5. 如果客戶端此前未與服務器建立會話,那麼雙方需要進行一次完整的 TLS 四次握手。客戶端首先向服務器發送 Client Hello 報文,包含一個隨機數、TLS 協議版本、按優先級排列的加密套件列表。
  6. 服務器向客戶端發送 Server Hello 報文,包含一個新的隨機數、TLS 協議版本、經過選擇後的一個加密套件。
  7. 服務器向客戶端發送 Certificate 報文,包含服務器 X.509 證書鏈,其中,第一個爲主證書,中間證書按照順序跟在主證書之後,而根 CA 證書通常內置在操作系統或瀏覽器中,無需服務器發送。
  8. 如果密鑰交換選擇 DH 算法,服務器會向客戶端發送 Server Key Exchange 報文,包含密鑰交換所需的 DH 參數;如果密鑰交換選擇 RSA 算法,則跳過這一步。
  9. 服務器向客戶端發送 Server Hello Done 報文,表明已經發送完所有握手消息。
  10. 客戶端向服務器發送 Client Key Exchange 報文,如果密鑰交換選擇 RSA 算法,由客戶端生成預主密鑰,使用服務器證書中的公鑰對其加密,包含在報文中,服務器只需使用自己的私鑰解密就可以取出預主密鑰;如果密鑰交換選擇 DH 算法,客戶端會在報文中包含自己的 DH 參數,之後雙方都根據 DH 算法計算出相同的預主密鑰。需要注意的是,密鑰交換的只是預主密鑰,這個值還需進一步加工,結合客戶端和服務器兩個隨機數種子,雙方使用 PRF(pseudorandom function,僞隨機函數)生成相同的主密鑰。
  11. 客戶端向服務器發送 Change Cipher Spec 報文,表明已經生成主密鑰,在隨後的傳輸過程都使用這個主密鑰對消息進行對稱加密。
  12. 客戶端向服務器發送 Finished 報文,這條消息是經過加密的,因此在 Wireshark 中顯示的是 Encrypted Handshake Message。如果服務器能解密出報文內容,則說明雙方生成的主密鑰是一致的。
  13. 服務器向客戶端發送 New Session Ticket 報文,而這個 Session Ticket 只有服務器才能解密,客戶端把它保存下來,在以後的 TLS 重新握手過程中帶上它進行快速會話恢復,減少往返延遲。
  14. 服務器向客戶端發送 Change Cipher Spec 報文,同樣表明已經生成主密鑰,在隨後的傳輸過程都使用這個主密鑰對消息進行對稱加密。
  15. 服務器向客戶端發送 Finished 報文,如果客戶端能解密出報文內容,則說明雙方生成的主密鑰是一致的。至此,完成所有握手協商。

發送 HTTP 請求

建立起安全的加密信道後,瀏覽器開始發送 HTTP 請求,一個請求報文由請求行、請求頭、空行、實體(Get 請求沒有)組成。請求頭由通用首部、請求首部、實體首部、擴展首部組成。其中,通用首部表示無論是請求報文還是響應報文都可以使用,比如 Date;請求首部表示只有在請求報文中才有意義,分爲 Accept 首部、條件請求首部、安全請求首部和代理請求首部這四類;實體首部作用於實體內容,分爲內容首部和緩存首部這兩類;擴展首部表示用戶自定義的首部,通過 X- 前綴來添加。另外需要注意的是,HTTP 請求頭是不區分大小寫的,它基於 ASCII 進行編碼,而實體可以基於其它編碼方式,由 Content-Type 決定。

返回 HTTP 響應

服務器接受並處理完請求,返回 HTTP 響應,一個響應報文格式基本等同於請求報文,由響應行、響應頭、空行、實體組成。區別於請求頭,響應頭有自己的響應首部集,比如 Vary、Set-Cookie,其它的通用首部、實體首部、擴展首部則共用。此外,瀏覽器和服務器必須保證 HTTP 的傳輸順序,各自維護的隊列中請求/響應順序必須一一對應,否則會出現亂序而出錯的情況。

維持連接

完成一次 HTTP 請求後,服務器並不是馬上斷開與客戶端的連接。在 HTTP/1.1 中,Connection: keep-alive 是默認啓用的,表示持久連接,以便處理不久後到來的新請求,無需重新建立連接而增加慢啓動開銷,提高網絡的吞吐能力。在反向代理軟件 Nginx 中,持久連接超時時間默認值爲 75 秒,如果 75 秒內沒有新到達的請求,則斷開與客戶端的連接。同時,瀏覽器每隔 45 秒會向服務器發送 TCP keep-alive 探測包,來判斷 TCP 連接狀況,如果沒有收到 ACK 應答,則主動斷開與服務器的連接。注意,HTTP keep-alive 和 TCP keep-alive 雖然都是一種保活機制,但是它們完全不相同,一個作用於應用層,一個作用於傳輸層。

斷開連接

  1. 服務器向客戶端發送 Alert 報文,類型爲 Close Notify,通知客戶端不再發送數據,即將關閉連接,同樣,這條報文也是經過加密處理的。
  2. 服務器通過調用 close 函數主動關閉連接,向客戶端發送帶有 FIN 標誌位的分組,序列號爲 m。
  3. 客戶端確認收到該分組,向服務器發送帶有 ACK 標誌位的分組,確認號爲 m+1。
  4. 客戶端發送完所有數據後,向服務器發送帶有 FIN 標誌位的分組,序列號爲 n。
  5. 服務器確認收到該分組,向客戶端發送帶有 ACK 標誌位的分組,序列號爲 n+1。客戶端收到確認分組後,立即進入 CLOSED 狀態;同時,服務器等待 2 個 MSL(Maximum Segment Lifetime,最大報文生存時間) 的時間後,進入 CLOSED 狀態。

瀏覽器解析過程

現代瀏覽器是一個及其龐大的大型軟件,在某種程度上甚至不亞於一個操作系統,它由多媒體支持、圖形顯示、GPU 渲染、進程管理、內存管理、沙箱機制、存儲系統、網絡管理等大大小小數百個組件組成。雖然開發者在開發 Web 應用時,無需關心底層實現細節,只需將頁面代碼交付於瀏覽器計算,就可以展示出豐富的內容。但頁面性能不僅僅關乎瀏覽器的實現方式,更取決於開發者的水平,對工具的熟悉程度,代碼優化是無止盡的。顯然,瞭解瀏覽器的基本原理,瞭解 W3C 技術標準,瞭解網絡協議,對設計、開發一個高性能 Web 應用幫助非常大。

當我們在使用 Chrome 瀏覽器時,其背後的引擎是 Google 開源的 Chromium 項目,而 Chromium 的內核則是渲染引擎 Blink(基於 Webkit)和 JavaScript 引擎 V8。在闡述瀏覽器解析 HTML 文件之前,先簡單介紹一下 Chromium 的多進程多線程架構(圖 5),它包括多個進程:

  • 一個 Browser 進程
  • 多個 Renderer 進程
  • 一個 GPU 進程
  • 多個 NPAPI Render 進程
  • 多個 Pepper Plugin 進程

而每個進程包括若干個線程:

  • 一個主線程
  • 在 Browser 進程中:渲染更新界面
  • 在 Renderer 進程中:使用持有的內核 Blink 實例解析渲染更新界面
  • 一個 IO 線程
  • 在 Browser 進程中:處理 IPC 通信和網絡請求
  • 在 Renderer 進程中:處理與 Browser 進程之間的 IPC 通信
  • 一組專用線程
  • 一個通用線程池

圖 5:Chromium 多進程多線程架構

Chromium 支持多種不同的方式管理 Renderer 進程,不僅僅是每一個開啓的 Tab 頁面,iframe 頁面也包括在內,每個 Renderer 進程是一個獨立的沙箱,相互之間隔離不受影響。

  • Process-per-site-instance:每個域名開啓一個進程,並且從一個頁面鏈接打開的新頁面共享一個進程(noopener 屬性除外),這是默認模式
  • Process-per-site:每個域名開啓一個進程
  • Process-per-tab:每個 Tab 頁面開啓一個進程
  • Single process:所有頁面共享一個進程

當 Renderer 進程需要訪問網絡請求模塊(XHR、Fetch),以及訪問存儲系統(同步 Local Storage、同步 Cookie、異步 Cookie Store)時,則調用 RenderProcess 全局對象通過 IO 線程與 Browser 進程中的 RenderProcessHost 對象建立 IPC 信道,底層通過 socketpair 來實現。正由於這種機制,Chromium 可以更好地統一管理資源、調度資源,有效地減少網絡、性能開銷。

主流程

頁面的解析工作是在 Renderer 進程中進行的,Renderer 進程通過在主線程中持有的 Blink 實例邊接收邊解析 HTML 內容(圖 6),每次從網絡緩衝區中讀取 8KB 以內的數據。瀏覽器自上而下逐行解析 HTML 內容,經過詞法分析、語法分析,構建 DOM 樹。當遇到外部 CSS 鏈接時,主線程調用網絡請求模塊異步獲取資源,不阻塞而繼續構建 DOM 樹。當 CSS 下載完畢後,主線程在合適的時機解析 CSS 內容,經過詞法分析、語法分析,構建 CSSOM 樹。瀏覽器結合 DOM 樹和 CSSOM 樹構建 Render 樹,並計算佈局屬性,每個 Node 的幾何屬性和在座標系中的位置,最後進行繪製展示在屏幕上。當遇到外部 JS 鏈接時,主線程調用網絡請求模塊異步獲取資源,由於 JS 可能會修改 DOM 樹和 CSSOM 樹而造成迴流和重繪,此時 DOM 樹的構建是處於阻塞狀態的。但主線程並不會掛起,瀏覽器會使用一個輕量級的掃描器去發現後續需要下載的外部資源,提前發起網絡請求,而腳本內部的資源不會識別,比如 document.write。當 JS 下載完畢後,瀏覽器調用 V8 引擎在 Script Streamer 線程中解析、編譯 JS 內容,並在主線程中執行(圖 7)。

圖 6:Webkit 主流程

圖 7:V8 解釋流程,Chrome 66 以前對比 Chrome 66

渲染流程

當 DOM 樹構建完畢後,還需經過好幾次轉換,它們有多種中間表示(圖 8)。首先計算佈局、繪圖樣式,轉換爲 RenderObject 樹(也叫 Render 樹)。再轉換爲 RenderLayer 樹,當 RenderObject 擁有同一個座標系(比如 canvas、absolute)時,它們會合併爲一個 RenderLayer,這一步由 CPU 負責合成。接着轉換爲 GraphicsLayer 樹,當 RenderLayer 滿足合成層條件(比如 transform,熟知的硬件加速)時,會有自己的 GraphicsLayer,否則與父節點合併,這一步同樣由 CPU 負責合成。最後,每個 GraphicsLayer 都有一個 GraphicsContext 對象,負責將層繪製成位圖作爲紋理上傳給 GPU,由 GPU 負責合成多個紋理,最終顯示在屏幕上。

圖 8:從 DOM 樹到 GraphicsLayer 樹的轉換

另外,爲了提升渲染性能效率,瀏覽器會有專用的 Compositor 線程來負責層合成(圖 9),同時負責處理部分交互事件(比如滾動、觸摸),直接響應 UI 更新而不阻塞主線程。主線程把 RenderLayer 樹同步給 Compositor 線程,由它開啓多個 Rasterizer 線程,進行光柵化處理,在可視區域以瓦片爲單位把頂點數據轉換爲片元,最後交付給 GPU 進行最終合成渲染。

圖 9:Chromium 多線程渲染

頁面生命週期

頁面從發起請求開始,結束於跳轉、刷新或關閉,會經過多次狀態變化和事件通知,因此瞭解整個過程的生命週期非常有必要。瀏覽器提供了 Navigation Timing 和 Resource Timing 兩種 API 來記錄每一個資源的事件發生時間點,你可以用它來收集 RUM(Real User Monitoring,真實用戶監控)數據,發送給後端監控服務,綜合分析頁面性能來不斷改善用戶體驗。圖 10 表示 HTML 資源加載的事件記錄全過程,而中間黃色部分表示其它資源(CSS、JS、IMG、XHR)加載事件記錄過程,它們都可以通過調用 window.performance.getEntries() 來獲取具體指標數據。

圖 10:頁面加載事件記錄流程

衡量一個頁面性能的方式有很多,但能給用戶帶來直接感受的是頁面何時渲染完成、何時可交互、何時加載完成。其中,有兩個非常重要的生命週期事件,DOMContentLoaded 事件表示 DOM 樹構建完畢,可以安全地訪問 DOM 樹所有 Node 節點、綁定事件等等;load 事件表示所有資源都加載完畢,圖片、背景、內容都已經完成渲染,頁面處於可交互狀態。但是迄今爲止瀏覽器並不能像 Android 和 iOS app 一樣完全掌控應用的狀態,在前後臺切換的時候,重新分配資源,合理地利用內存。實際上,現代瀏覽器都已經在做這方面的相關優化,並且自 Chrome 68 以後提供了Page Lifecycle API,定義了全新的瀏覽器生命週期(圖 11),讓開發者可以構建更出色的應用。

圖 11:新版頁面生命週期

現在,你可以通過給 window 和 document 綁定上所有生命週期監聽事件(圖 12),來監測頁面切換、用戶交互行爲所觸發的狀態變化過程。不過,開發者只能感知事件在何時發生,不能直接獲取某一刻的頁面狀態(圖 11 中的 STATE)。即使如此,利用這個 API,也可以讓腳本在合適的時機執行某項任務或進行界面 UI 反饋。

圖 12:生命週期監聽事件

總結

篇幅有限,本文並不是一篇大而全的面試寶典,只涉及其中一些關鍵路徑和知識點,開篇也提到問題本身沒有標準答案。文中也有很多沒有深入展開分析的地方,一是過於偏離主題,二是個人能力有限,比如編碼規則、加密算法、摘要算法、路由算法、壓縮算法、協議標準、緩存機制、V8 原理、GPU 原理等等。如果想透徹理解每一個部分的技術細節和原理,可以結合參考其它書籍、資料和源碼。

⚠️最後,注意防脫。

參考文獻