CHROME源碼剖析 上《轉》

轉自:http://www.blogjava.net/xiaomage234/archive/2012/02/16/370122.html
原著:duguguiyu。
整理:July。
時間:二零一一年四月二日。
出處:http://blog.csdn.net/v_JULY_v
說明:此Chrome源碼剖析很大一部分編輯整理自此博客:http://flyvenus.net/。我對寫原創文章的作者向來是以最大的尊重的。近期想好好研究和學習下Chrome源碼,正巧看到了此duguguiyu兄臺的源碼剖析,處於學習的目的,就不客氣的根據他的博客整理了此文。若有諸多冒犯之處,還望海涵。

前言:
1、之所以整理此文,有倆個目的:一是爲了供自己學習研究之用;二是爲了備份,以作日後反覆研究。除此之外,無它。
2、此文的形式其實是有點倆不像的,既不是個人首創即原創,又非單純的轉載(有加工),無奈之下,權且稱作翻譯吧。有不妥之處,還望原作者,及讀者見諒。

文中加入了我自己的一些見解,請自行辨別。順便再說一句,duguguiyu寫的這個Chrome源碼剖析,真不錯,勾起了偶對源碼剖析的莫大興趣。

順便透露下:在此份Chrome源碼剖析之後,互聯網上即將,首次出現sgi stl v3.3版的源碼剖析拉。作者:本人July。是的,本人最近在研究sgi stl v3.3版的源碼,正在做源碼剖析,個人首創,敬請期待。

在具體針對源碼剖析之前,再粗略回答一下網友可能關心的問題:chrome速度維護如此之快?據網上資料顯示:有幾個主要的關鍵技術:DNS預解析、 Google自主開發的V8 Javacript引擎、DOM綁定技術以及多進程架構等等。但這不是本文的重點,所以略過不談。

ok,激動人心的Chrome源碼剖析旅程,即刻開始。

Chrome源碼剖析【序】

此序成於08年末,Chrome剛剛推出之際。

duguguiyu:“有的人一看到Chrome用到多進程就說垃圾廢物肯定低能。拜託,大家都是搞技術的,你知道多進程的缺點,Google也知道,他 們不是政客,除了搞個噱頭扯個蛋就一無所知了,人家也是有臉有皮的,寫一坨屎一樣的開源代碼放出來遭世人恥笑難道會很開心?所謂技術的優劣,是不能一概而 論的,同樣的技術在不同場合不同環境不同代碼實現下,效果是有所不同的。….”

Chrome對我來說,有吸引力的地方在於(排名分先後…):

  1. 它是如何利用多進程(其實也會有多線程一起)做併發的,又是如何解決多進程間的一些問題的,比如進程間通信,進程的開銷;
  2. 做爲一個後來者,它的擴展能力如何,如何去權衡對原有插件的兼容,提供怎麼樣的一個插件模型;
  3. 它的整體框架是怎樣,有沒有很NB的架構思想;
  4. 它如何實現跨平臺的UI控件系統;
  5. 傳說中的V8,爲啥那麼快。

但Chrome是一個跨平臺的瀏覽器,其Linux和Mac版本正在開發過程中,所以我把所有的眼光都放在了windows版本中,所有的代碼剖析都是基於windows版本的。有錯誤請指正。

關於Chrome的源碼下載和環境配置,大家可自行查找資料,強調一點,一定要嚴格按照說明來配置環境,特別是vs2005的補丁和windows SDK的安裝,否則肯定是編譯不過的。

最後,寫這部分唯一不是廢話的內容,請記住以下這幅圖,這是Chrome最精華的一個縮影:

Chrome的線程和進程模型

圖1 Chrome的線程和進程模型

【一】 Chrome的多線程模型

0. Chrome的併發模型

如果你仔細看了前面的圖,對Chrome的線程和進程框架應該有了個基本的瞭解。Chrome有一個主進程,稱爲Browser進程,它是老大,管理 Chrome大部分的日常事務;其次,會有很多Renderer進程,它們圈地而治,各管理一組站點的顯示和通信(Chrome在宣傳中一直宣稱一個 tab對應一個進程,其實是很不確切的…),它們彼此互不搭理,只和老大說話,由老大負責權衡各方利益。它們和老大說話的渠道,稱做IPC(Inter- Process Communication),這是Google搭的一套進程間通信的機制,基本的實現後面自會分解。

Chrome的進程模型

Google 在宣傳的時候一直都說,Chrome是one tab one process的模式,其實,這只是爲了宣傳起來方便如是說而已,基本等同廣告,實際療效,還要從代碼中來看。實際上,Chrome支持的進程模型遠比宣 傳豐富,簡單的說,Chrome支持以下幾種進程模型:

  1. Process-per-site-instance:就是你打開一個網站,然後從這個網站鏈開的一系列網站都屬於一個進程。這是Chrome的默認模式。
  2. Process-per-site:同域名範疇的網站放在一個進程,比如www.google.com(由於此文形成於08年,所以無法訪問,你懂的)和www.google.com/bookmarks就屬於一個域名內(google有自己的判定機制),不論有沒有互相打開的關係,都算作是一個進程中。用命令行–process-per-site開啓。
  3. Process-per-tab:這個簡單,一個tab一個process,不論各個tab的站點有無聯繫,就和宣傳的那樣。用–process-per-tab開啓。
  4. Single Process:這個很熟悉了吧,即傳統瀏覽器的模式:沒有多進程只有多線程,用–single-process開啓。

關於各種模式的優缺點,官方有官方的說法,大家自己也會有自己的評述。不論如何,至少可以說明,Google不是由於白癡而採取多進程的策略,而是實驗出來的效果。

大家可以用Shift+Esc觀察各模式下進程狀況,至少我是觀察失敗了(每種都和默認的一樣…),原因待跟蹤。

不論是Browser進程還是Renderer進程,都不只是光桿司令,它們都有一系列的線程爲自己打理各種業務。對於Renderer進程,它們通常有兩個線程:一個是Main thread,它負責與老大進行聯繫,有一些幕後黑手的意思;另一個是Render thread,它們負責頁面的渲染和交互,一看就知道是這個幫派的門臉級人物。

相比之下,Browser進程既 然是老大,小弟自然要多一些,除了大腦般的Main thread,和負責與各Renderer幫派通信的IO thread,其實還包括負責管文件的file thread,負責管數據庫的db thread等等,它們各盡其責,齊心協力爲老大打拼。它們和各Renderer進程的之間的關係不一樣,同一個進程內的線程,往往需要很多的協同工作, 這一坨線程間的併發管理,是Chrome最出彩的地方之一了。

閒話併發

單進程單線程的編程是最愜意的事情,所看即所得,一維的思考即可。但程序員的世界總是沒有那麼美好,在很多的場合,我們都需要有多線程、多進程、多機器攜起手來一齊上陣共同完成某項任務,統稱:併發(非官方版定義…)。在我看來,需要併發的場合主要是要兩類:

  1. 爲了更好的用戶體驗。有的事情處理起來太慢,比如 數據庫讀寫、遠程通信、複雜計算等等,如果在一個線程一個進程裏面來做,往往會影響用戶感受,因此需要另開一個線程或進程轉到後臺進行處理。它之所以能夠 生效,仰仗的是單CPU的分時機制,或者是多CPU協同工作。在單CPU的條件下,兩個任務分成兩撥完成的總時間,是大於兩個任務輪流完成的,但是由於彼 此交錯,給人的感覺更自然一些。
  2. 爲了加速完成某項工作。大名鼎鼎的 Map/Reduce,做的就是這樣的事情,它將一個大的任務,拆分成若干個小的任務,分配個若干個進程去完成,各自收工後,再彙集在一起,更快地得到最 後的結果。爲了達到這個目的,只有在多CPU的情形下才有可能,在單CPU的場合(單機單CPU…),是無法實現的。
    在第二種場合下,我們會自然而然的關注數據的分離,從而很好的利用上多CPU的能力;而在第一種場合,我們習慣了單CPU的模式,往往不注重數據與行爲的對應關係,導致在多CPU的場景下,性能不升反降。

1. Chrome的線程模型

仔細回憶一下我們大部分時候是怎麼來用線程的,在我足夠貧瘠的多線程經歷中,往往都是這樣用的:起一個線程,傳入一個特定的入口函數,看一下這個函數是否 是有副作用的(Side Effect),如果有,並且還會涉及到多線程的數據訪問,仔細排查,在可疑地點上鎖伺候。

Chrome的線程模型走的是另一個路子,即,極力規避鎖的存在。 換更精確的描述方式來說,Chrome的線程模型,將鎖限制了極小的範圍內(僅僅在將Task放入消息隊列的時候才存在…),並且使得上層完全不需要關心 鎖的問題(當然,前提是遵循它的編程模型,將函數用Task封裝併發送到合適的線程去執行…),大大簡化了開發的邏輯。

不過,從實現來說,Chrome的線程模型並沒有什麼神祕的地方,它用到了消息循環的手段。每一個Chrome的線程,入口函數都差不多,都是啓動一個消息循環(參見MessagePump類),等待並執行任務。

而其中,唯一的差別在於,根據線程處理事務類別的不同,所起的消息循環有所不同。比如處理進程間通信的線程(注意,在Chrome中,這類線程都叫做IO 線程)啓用的是MessagePumpForIO類,處理UI的線程用的是MessagePumpForUI類,一般的線程用到的是 MessagePumpDefault類(只討論windows)。

不同的消息循環類,主要差異有兩個,一是消息循環中需要處理什麼樣的消息和任務,第二個是循環流程(比如是死循環還是阻塞在某信號量上…)。下圖是一個完 整版的Chrome消息循環圖,包含處理Windows的消息,處理各種Task(Task是什麼,稍後揭曉,敬請期待),處理各個信號量觀察者 (Watcher),然後阻塞在某個信號量上等待喚醒。

Chrome的消息循環
圖2 Chrome的消息循環

當然,不是每一個消息循環類都需要跑那麼一大圈的,有些線程,它不會涉及到那麼多的事情和邏輯,白白浪費體力和時間,實在是不可饒恕的。因此,在實際中,不同的MessagePump類,實現是有所不同的,詳見下表:

不同的MessagePump類

2. Chrome中的Task

從上面的表不難看出,不論是哪一種消息循環,必須處理的,就是Task(暫且遺忘掉系統消息的處理和Watcher,以後,我們會緬懷它們的…)。刨去其 它東西的干擾,只留下Task的話,我們可以這樣認爲:Chrome中的線程從實現層面來看沒有任何區別,它的區別只存在於職責層面,不同職責的線程,會 處理不同的Task。最後,在鋪天蓋地西紅柿來臨之前,我說一下啥是Task。

簡單的看,Task就是一個類,一個包含了void Run()抽象方法的類(參見Task類…)。一個真實的任務,可以派生Task類,並實現其Run方法。每個MessagePump類中(pump:泵),會有一個 MessagePump::Delegate的類的對象(MessagePump::Delegate的一個實現,請參見MessageLoop類…), 在這個對象中,會維護若干個Task的隊列。當你期望,你的一個邏輯在某個線程內執行的時候,你可以派生一個Task,把你的邏輯封裝在Run方法中,然 後實例一個對象,調用期望線程中的PostTask方法,將該Task對象放入到其Task隊列中去,等待執行。我知道很多人已經抄起了板磚,因爲這種手法實在是太常見了,就不是一個簡單的依賴倒置,在線程池,Undo\Redo等模塊的實現中,用的太多了。

但,我想說的是,雖說誰家過年都是吃頓餃子,這餃子好不好吃還是得看手藝,不能一概而論。在Chrome中,線程模型是統一且唯一的,這就相當於有了一套標準,它需要滿足在各個線程上執行的幾十上百種任務的需求,因此,必須在靈活行和易用性上有良好的表現,這就是設計標準的難度。爲了滿足這些需 求,Chrome在底層庫上做了足夠的功夫:

  1. 它提供了一大套的模板封裝(參見task.h),可以將Task擺脫繼承結構、函數名、函數參數等限制(就是基於模板的僞function實現,想要更深入瞭解,建議直接看鼻祖《Modern C++》和它的Loki庫…);
  2. 同時派生出CancelableTask、ReleaseTask、DeleteTask等子類,提供更爲良好的默認實現;
  3. 在消息循環中,按邏輯的不同,將Task又分成即時處理的Task、延時處理的Task、Idle時處理的Task,滿足不同場景的需求;
  4. Task派生自tracked_objects::Tracked,Tracked是爲了實現多線程環境下的日誌記錄、統計等功能,使得Task天生就有良好的可調試性和可統計性;
    這一套七葷八素的都搭建完,這纔算是一個完整的Task模型,由此可知,這餃子,做的還是很費功夫的。

3. Chrome的多線程模型

工欲善其事,必先利其器。Chrome之所以費了老鼻子勁去磨底層框架這把刀,就是爲了面對多線程這坨怪獸的時候殺的更順暢一些。在Chrome的多線程 模型下,加鎖這個事情只發生在將Task放入某線程的任務隊列中,其他對任何數據的操作都不需要加鎖。當然,天下沒有免費的午餐,爲了合理傳遞Task, 你需要了解每一個數據對象所管轄的線程,不過這個事情,與紛繁的加鎖相比,真是小兒科了不知道多少倍。

Task的執行模型

圖3 Task的執行模型

如果你熟悉設計模式,你會發現這是一個Command模式,將創建與執行的環境相分離,在一個線程中創建行爲,在另一個線程中執行行爲。Command模式的優點在於,將實現操作與構造操作解耦,這就避免了鎖的問題,使得多線程與單線程編程模型統一起來,其次,Command還有一個優點,就是有利於命令 的組合和擴展,在Chrome中,它有效統一了同步和異步處理的邏輯。

Command模式

Command 模式,是一種看上去很酷的模式,傳統的面向對象編程,我們封裝的往往都是數據,在Command模式下,我們希望封裝的是行爲。這件事在函數式編程中很正常,封裝一個函數作爲參數,傳來傳去,稀疏平常的事兒;但在面向對象的編程中,我們需要通過繼承、模板、函數指針等手法,才能將其實現。

應用Command模式,我們是期望這個行爲能到一個不同於它出生的環境中去執行,簡而言 之,這是一種想生不想養的行爲。我們做Undo/Redo的時候,會把在任一一個環境中創建的Command,放到一個隊列環境中去,供統一的調度;在 Chrome中,也是如此,我們在一個線程環境中創建了Task,卻把它放到別的線程中去執行,這種寄居蟹似的生活方式,在很多場合都是有用武之地的。

在一般的多線程模型中,我們需要分清楚啥是同步啥是異步,在同步模式下,一切看上去和單線程沒啥區別,但同時也喪失了多線程的優勢(淪落成爲多線程串行…)。而如果採用異步的模式,那寫起來就麻煩多了,你需要註冊回調,小心管理對象的生命週期,程序寫出來是嗷嗷噁心。在Chrome的多線程模型下,同步和異步的編程模型區別就不復存在了,如果是這樣一個場景:A線程需要B線程做一些事情,然後回到A線程繼續做一些事情;在Chrome下你可以這樣來做:生成一個Task,放到B線程的隊列中,在該Task的Run方法最後,會生成另一個Task,這個Task會放回到A的線程隊列,由A來執行。如此 一來,同步異步,天下一統,都是Task傳來傳去,想不會,都難了。

Chrome的一種異步執行的解決方案

圖4 Chrome的一種異步執行的解決方案

4. Chrome多線程模型的優缺點

一直在說Chrome在規避鎖的問題,那到底鎖是哪裏不好,犯了何等滔天罪責,落得如此人見人嫌恨不得先殺而後快的境地。《代碼之美》的第二十四章“美麗的併發”中,Haskell設計人之一的Simon Peyton Jones總結了一下用鎖的困難之處,如下:

  1. 鎖少加了,導致兩個線程同時修改一個變量;
  2. 鎖多加了,輕則妨礙併發,重則導致死鎖;
  3. 鎖加錯了,由於鎖和需要鎖的數據之間的聯繫,只存在於程序員的大腦中,這種事情太容易發生了;
  4. 加鎖的順序錯了,維護鎖的順序是一件困難而又容易出錯的問題;
  5. 錯誤恢復;
  6. 忘記喚醒和錯誤的重試;
  7. 而最根本的缺陷,是鎖和條件變量不支持模塊化的編程。比如一個轉賬業務中,A賬戶扣了100元錢,B賬戶增加了100元,即使這兩個動作單獨用鎖保護維持其正確性,你也不能將兩個操作簡單的串在一起完成一個轉賬操作,你必須讓它們的鎖都暴露出來,重新設計一番。好好的兩個函數,愣是不能組在一起用,這就是鎖的最大悲哀;

通過這些缺點的描述,也就可以明白Chrome多線程模型的優點。它解決了鎖的最根本缺陷,即,支持模塊化的編程,你只需要維護對象和線程之間的職能關係即可,這個攤子,比之鎖的那個爛攤子,要簡化了太多。對於程序員來說,負擔一瞬間從泰山降成了鴻毛。

而Chrome多線程模型的一個主要難點,在於線程與數據關係的設計上,你需要良好的劃分各個線程的職責,如果有一個線程所管轄的數據,幾乎佔據了大半部分的Task,那麼它就會從多線程淪爲單線程,Task隊列的鎖也將成爲一個大大的瓶頸。

設計者的職責

一個底層結構設計是否成功,這個設計者是否稱職,我一直覺得是有一個很簡單的衡量標準的。你不需要看這個設計人用了多少NB的技術,你只需要關心,他的設計,是否給其他開發人員帶來了困難。一個NB的設計,是將所有困難都集中在底層搞定,把其他開發人員換成白癡都可以工作的那種;一個SB的設計,是自己弄了半天,只是爲了給其他開發人員一個長達250條的注意事項,然後很NB的說,你們按照這個手冊去開發,就不會有問題了。

從根本上來說,Chrome的線程模型解決的是併發中的用戶體驗問題而不是聯合工作的問題(參見我前面噴的“閒話併發”),它不是和Map/Reduce 那樣將關注點放在數據和執行步驟的拆分上,而是放在線程和數據的對應關係上,這是和瀏覽器的工作環境相匹配的。設計總是和所處的環境相互依賴的,畢竟,在客戶端,不會和服務器一樣,存在超規模的併發處理任務,而只是需要儘可能的改善用戶體驗,從這個角度來說,Chrome的多線程模型,至少看上去很美。

【二】Chrome的進程間通信

1. Chrome進程通信的基本模式

進程間通信,叫做IPC(Inter-Process Communication)。Chrome最主要有三類進程,一類是Browser主進程,我們一直尊稱它老人家爲老大;還有一類是各個Render進程,前面也提過了;另外還有一類一直沒說過,是Plugin進程,每一個插件,在Chrome中都是以進程的形式呈現,等到後面說插件的時候再提罷了。 Render進程和Plugin進程都與老大保持進程間的通信,Render進程與Plugin進程之間也有彼此聯繫的通路,唯獨是多個Render進程 或多個Plugin進程之間,沒有互相聯繫的途徑,全靠老大協調。

進程與進程間通信,需要仰仗操作系統的特性,能玩的花着實不多,在Chrome中,用到的就是有名的管道(Named Pipe),只不過,它用一個IPC::Channel類,封裝了具體的實現細節。Channel可以有兩種工作模式,一種是Client,一種是 Server,Server和Client分屬兩個進程,維繫一個共同的管道名,Server負責創建該管道,Client會嘗試連接該管道,然後雙方往各自管道緩衝區中讀寫數據(在Chrome中,用的是二進制流,異步IO…),完成通信。

管道名字的協商

在 Socket中,我們會事先約定好通信的端口,如果不按照這個端口進行訪問,走錯了門,會被直接亂棍打出門去的。與之類似,有名管道期望在兩個進程間遊走,就需要拿一個兩個進程都能接受的進門暗號,這個就是有名管道的名字。在Chrome中(windows下…),有名管道的名字格式都是:\.\pipe\chrome.ID。其中的ID,自然是要求獨一無二,比如:進程ID.實例地址.隨機數。通常,這個ID是由一個Process生成(往往是Browser Process),然後在創建另一個進程的時候,作爲命令行參數傳進去,從而完成名字的協商。

如果不瞭解並期待了解有關Windows下有名管道和信號量的知識,建議去看一些專業的書 籍,比如聖經級別的《Windows核心編程》和《深入解析Windows操作系統》,當然也可以去查看SDK,你需要了解的API可能包括:CreateNamedPipe, CreateFile, ConnectNamedPipe, WaitForMultipleObjects, WaitForSingleObject, SetEvent, 等等。

Channel中,有三個比較關鍵的角色,一個是Message::Sender,一個是Channel::Listener,最後一個是 MessageLoopForIO::Watcher。Channel本身派生自Sender和Watcher,身兼兩角,而Listener是一個抽象類,具體由Channel的使用者來實現。顧名思義,Sender就是發送消息的接口,Listener就是處理接收到消息的具體實現,但這個 Watcher是啥?如果你覺得Watcher這東西看上去很眼熟的話,我會激動的熱淚盈眶的,沒錯,在前面(第一部分第一小節…)說消息循環的時候,從 那個表中可以看到,IO線程(記住,在Chrome中,IO指的是網絡IO,_)的循環會處理註冊了的Watcher。其實Watcher很簡單,可以視爲一個信號量和一個帶有OnObjectSignaled方法對象的對,當消息循環檢測到信號量開啓,它就會調用相應的OnObjectSignaled方法。

Chrome的IPC處理流程圖

圖5 Chrome的IPC處理流程圖

一圖解千語,如上圖所示,整個Chrome最核心的IPC流程都在圖上了,期間,刨去了一些錯誤處理等邏輯,如果想看原汁原味的,可以自查Channel 類的實現。當有消息被Send到一個發送進程的Channel的時候,Channel會把它放在發送消息隊列中,如果此時還正在發送以前的消息(發送端被 阻塞…),則看一下阻塞是否解除(用一個等待0秒的信號量等待函數…),然後將消息隊列中的內容序列化並寫道管道中去。操作系統會維護異步模式下管道的這一組信號量,當消息從發送進程緩衝區寫到接收進程的緩衝區後,會激活接收端的信號量。當接收進程的消息循環,循到了檢查Watcher這一步,並發現有信號量激活了,就會調用該Watcher相應的OnObjectSignaled方法,通知接受進程的Channel,有消息來了!Channel會嘗試從管道中收字節,組消息,並調用Listener來解析該消息。

從上面的描述不難看出,Chrome的進程通信,最核心的特點,就是利用消息循環來檢查信號量,而不是直接讓管道阻塞在某信號量上。這樣就與其多線程模型緊密聯繫在了一起,用一種統一的模式來解決問題。並且,由於是消息循環統一檢查,線程不會隨便就被阻塞了,可以更好的處理各種其他工作,從理論上講,這是通過增加CPU工作時間,來換取更好的體驗,頗有資本家的派頭。

溫柔的消息循環

其實,Chrome的很多消息循環,也不是都那麼霸道,也是會被阻塞在某些信號量或者某種場景上的,畢竟客戶端不是它家的服務器,CPU不能被全部歸在它家名下。

比如IO線程,當沒有消息來到,又沒有信號量被激活的時候,就會被阻塞,具體實現可以去看MessagePumpForIO的WaitForWork方法。

不過這種阻塞是集中式的,可隨時修改策略的,比起Channel直接阻塞在信號量上,停工的時間更短。

2. 進程間的跨線程通信和同步通信

在Chrome中,任何底層的數據都是線程非安全的,Channel不是太上老君(抑或中國足球?…),它也沒有例外。在每一個進程中,只能有一個線程來負責操作Channel,這個線程叫做IO線程(名不符實真是一件悲涼的事情…)。其它線程要是企圖越俎代庖,是會出大亂子的。

但是有時候(其實是大部分時候…),我們需要從非IO線程與別的進程相通信,這該如何是好?如果,你有看過我前面寫的線程模型,你一定可以想到,做法很簡單,先將對Channel的操作放到Task中,將此Task放到IO線程隊列裏,讓IO線程來處理即可。當然,由於這種事情發生的太頻繁,每次都人肉做一次頗爲繁瑣,於是有一個代理類,叫做ChannelProxy,來幫助你完成這一切。

從接口上看,ChannelProxy的接口和Channel沒有大的區別(否則就不叫Proxy了…),你可以像用Channel一樣,用 ChannelProxy來Send你的消息,ChannelProxy會辛勤的幫你完成剩餘的封裝Task等工作。不僅如此,ChannelProxy還青出於藍勝於藍,在這個層面上做了更多的事情,比如:發送同步消息。

不過能發送同步消息的類不是ChannelProxy,而是它的子類,SyncChannel。在Channel那裏,所有的消息都是異步的(在 Windows中,也叫Overlapped…),其本身也不支持同步邏輯。爲了實現同步,SyncChannel並沒有另造輪子,而只是在 Channel的層面上加了一個等待操作。當ChannelProxy的Send操作返回後,SyncChannel會把自己阻塞在一組信號量上,等待回包,直到永遠或超時。從外表上看同步和異步沒有什麼區別,但在使用上還是要小心,在UI線程中使用同步消息,是容易被髮指的。

3. Chrome中的IPC消息格式

說了半天,還有一個大頭沒有提過,那就是消息包。如果說,多線程模式下,對數據的訪問開銷來自於鎖,那麼在多進程模式下,大部分的額外開銷都來自於進程間的消息拆裝和傳遞。不論怎麼樣的模式,只要進程不同,消息的打包,序列化,反序列化,組包,都是不可避免的工作。

在Chrome中,IPC之間的通信消息,都是派生自IPC::Message類的。對於消息而言,序列化和反序列化是必須要支持的,Message的基類Pickle,就是幹這個活的。Pickle提供了一組的接口,可以接受int,char,等等各種數據的輸入,但是在Pickle內部,所有的一切都 沒有區別,都轉化成了一坨二進制流。這個二進制流是32位齊位的,比如你只傳了一個bool,也是最少佔32位的,同時,Pickle的流是有自增邏輯的 (就是說它會先開一個Buffer,如果滿了的話,會加倍這個Buffer…),使其可以無限擴展。Pickle本身不維護任何二進制流邏輯上的信息,這個任務交到了上級處理(後面會有說到…),但Pickle會爲二進制流添加一個頭信息,這個裏面會存放流的長度,Message在繼承Pickle的時 候,擴展了這個頭的定義,完整的消息格式如下:

Chrome的IPC消息格式
圖6 Chrome的IPC消息格式

其中,黃色部分是包頭,定長96個bit,綠色部分是包體,二進制流,由payload_size指明長度。從大小上看這個包是很精簡的了,除了 routing位在消息不爲路由消息的時候會有所浪費。消息本身在有名管道中是按照二進制流進行傳輸的(有名管道可以傳輸兩種類型的字符流,分別是二進制流和消息流…),因此由payload_size + 96bits,就可以確定是否收了一個完整的包。

從邏輯上來看,IPC消息分成兩類,一類是路由消息(routed message),還有一類是控制消息(control message)。路由消息是私密的有目的地的,系統會依照路由信息將消息安全的傳遞到目的地,不容它人窺視;控制消息就是一個廣播消息,誰想聽等能夠聽得到。

消息的序列化

前不久讀了Google Protocol Buffers的源碼,是用在服務器端,用做內部機器通信協議的標準、代碼生成工具和框架。它主要的思想是揉合了key/value的內容到二進制中,幫助生成更爲靈活可靠的二進制協議。

在Chrome中,沒有使用這套東西,而是用到了純二進制流作爲消息序列化的方式。我想這 是由於應用場景不同使然。在服務端,我們更關心協議的穩定性,可擴展性,並且,涉及到的協議種類很多。但在一個Chrome中,消息的格式很統一,這方面沒有擴展性和靈活性的需求,而在序列化上,雖然key/value的方式很好很強大,但是在Chrome中需要的不是靈活性而是精簡性,因此寧可不用 Protocol Buffers造好的輪子,而是另立爐竈,花了好一把力氣提供了一套純二進制的消息機制。

4. 定義IPC消息

如果你寫過MFC程序,對MFC那裏面一大堆宏有所忌憚的話,那麼很不幸,在Chrome中的IPC消息定義中,你需要再吃一點苦頭了,甚至,更苦大仇深一些;如果你曾經領教過用模板的特化偏特化做Traits、用模板做函數重載、用編譯期的Tuple做變參數支持,之類機制的種種麻煩的話,那麼,同樣很遺憾,在Chrome中,你需要再感受一次。。。

不過,先讓我們忘記宏和模板,看人肉一個消息,到底需要哪些操作。一個標準的IPC消息定義應該是類似於這樣的:

class SomeMessage: public IPC::Message
{
  public:
    enum { ID = …; }
    SomeMessage(SomeType & data)
    : IPC::Message(MSG_ROUTING_CONTROL, ID, ToString(data))
    {…}
    …
};

大概意思是這樣的,你需要從Message(或者其他子類)派生出一個子類,該子類有一個獨一無二的ID值,該子類接受一個參數,你需要對這個參數進行序列化。兩個麻煩的地方看的很清楚,如果生成獨一無二的ID值?如何更方便的對任何參數可以自動的序列化?。

在Chrome中,解決這兩個問題的答案,就是宏 + 模板。Chrome爲每個消息安排了一種ID規格,用一個16bits的值來表示,高4位標識一個Channel,低12位標識一個消息的子id,也就是說,最多可以有16種Channel存在不同的進程之間,每一種Channel上可以定義4k的消息。目前,Chrome已經用掉了8種 Channel(如果A、B進程需要雙向通信,在Chrome中,這是兩種不同的Channel,需要定義不同的消息,也就是說,一種雙向的進程通信關係,需要耗費兩個Channel種類…),他們已經覺得,16bits的ID格式不夠用了,在將來的某一天,估計就被擴展成了32bits的。書歸正傳,Chrome是這麼來定義消息ID的,用一個枚舉類,讓它從高到低往下走,就像這樣:

enum SomeChannel_MsgType
{
  SomeChannelStart = 5 << 12,
  SomeChannelPreStart = (5 << 12) – 1,
  Msg1,
  Msg2,
  Msg3,
  …
  MsgN,
  SomeChannelEnd
};

這是一個類型爲5的Channel的消息ID聲明,由於指明瞭最開始的兩個值,所以後續枚舉的值會依次遞減,如此,只要維護Channel類型的唯一性, 就可以維護所有消息ID的唯一性了(當然,前提是不能超過消息上限…)。但是,定義一個ID還不夠,你還需要定義一個使用該消息ID的Message子類。這個步驟不但繁瑣,最重要的,是違反了DIY原則,爲了添加一個消息,你需要在兩個地方開工幹活,是可忍孰不可忍,於是Google祭出了宏這顆原子 彈,需要定義消息,格式如下:

IPC_BEGIN_MESSAGES(PluginProcess, 3)
IPC_MESSAGE_CONTROL2(PluginProcessMsg_CreateChannel,
int /* process_id */,
HANDLE /* renderer handle */)
IPC_MESSAGE_CONTROL1(PluginProcessMsg_ShutdownResponse,
bool /* ok to shutdown */)
IPC_MESSAGE_CONTROL1(PluginProcessMsg_PluginMessage,
std::vector<uint8> /* opaque data */)
IPC_MESSAGE_CONTROL0(PluginProcessMsg_BrowserShutdown)
IPC_END_MESSAGES(PluginProcess)

這是Chrome中,定義PluginProcess消息的宏,我挖過來放在這了,如果你想添加一條消息,只需要添加一條類似與 IPC_MESSAGE_CONTROL0東東即可,這說明它是一個控制消息,參數爲0個。你基本上可以這樣理解,IPC_BEGIN_MESSAGES 就相當於完成了一個枚舉開始的聲明,然後中間的每一條,都會在枚舉裏面增加一個ID,並聲明一個子類。這個一宏兩吃,直逼北京烤鴨兩吃的高超做法,可以參 看ipc_message_macros.h,或者看下面一宏兩吃的一個舉例。

多次展開宏的技巧

這是Chrome中用到的一個技巧,定義一次宏,展開多段代碼,我孤陋寡聞,第一次見,一個類似的例子,如下:

首先,定義一個macro.h,裏面放置宏的定義:

#undef SUPER_MACRO
#if defined(FIRST_TIME)
#undef FIRST_TIME
#define SUPER_MACRO(label, type) \

enum IDs { \
  label##__ID = 10 \
};

#elif defined(SECOND_TIME)
#undef SECOND_TIME

#define SUPER_MACRO(label, type) \
class TestClass \
{ \
  public: \
    enum {ID = label##__ID}; \
    TestClass(type value) : _value(value) {} \
    type _value; \
};
#endif

可以看到,這個頭文件是可重入的,每一次先undef掉之前的定義,然後判斷進行新的定義。然後,你可以創建一個use_macro.h文件,利用這個宏,定義具體內容:

#include “macros.h”
SUPER_MACRO(Test, int)

這個頭文件在利用宏的部分不需要放到ifundef…define…這樣的頭文件保護中,目的就是爲了可重入。在主函數中,你可以多次define + include,實現多次展開的目的:

#define FIRST_TIME
#include “use_macro.h”
#define SECOND_TIME
#include “use_macro.h”
#include <iostream>
int _tmain(int argc, _TCHAR* argv[])
{
  TestClass t(5);
  std::cout << TestClass::ID << std::endl;
  std::cout << t._value << std::endl;
  return 0;
}

這樣,你就成功的實現,一次定義,生成多段代碼了。

此外,當接收到消息後,你還需要處理消息。接收消息的函數,是 IPC::Channel::Listener子類的OnMessageReceived函數。在這個函數中,會放置一坨的宏,這一套宏,一定能讓你想起 MFC的Message Map機制(關於此消息機制原理更具體的介紹,可參考侯捷的深入淺出MFC一書。):

IPC_BEGIN_MESSAGE_MAP_EX(RenderProcessHost, msg, msg_is_ok)

IPC_MESSAGE_HANDLER(ViewHostMsg_PageContents, OnPageContents)
IPC_MESSAGE_HANDLER(ViewHostMsg_UpdatedCacheStats,
OnUpdatedCacheStats)
IPC_MESSAGE_UNHANDLED_ERROR()
IPC_END_MESSAGE_MAP_EX()

這個東西很簡單,展開後基本可以視爲一個Switch循環,判斷消息ID,然後將消息,傳遞給對應的函數。與MFC的Message Map比起來,做的事情少多了。

通過宏的手段,可以解決消息類聲明和消息的分發問題,但是自動的序列化還不能支持(所謂自動的序列化,就是不論你是什麼類型的參數,幾個參數,都可以直序列化,不需要另寫代碼…)。在C++這種語言中,所謂自動的序列化,自動的類型識別,自動的XXX,往往都是通過模板來實現的。這些所謂的自動化,其實就是通過事前的大量人肉勞作,和模板自動遞推來實現的,如果說.Net或Java中的自動序列化是過山軌道,這就是那挑夫的驕子,雖然最後都是兩腿不動到 了山頂,這底下費得力氣真是天壤之別啊。具體實現技巧,有興趣的看看侯捷的《STL源碼剖析》,或者是《C++新思維》,或者Chrome中的 ipc_message_utils.h,這要說清楚實在不是一兩句的事情。

總之通過宏和模板,你可以很簡單的聲明一個消息,這個消息可以傳入各式各樣的參數(這裏用到了誇張的修辭手法,其實,只要是模板實現的自動化,永遠都是有 限制的,在Chrome的模板實現中,參數數量不要超過5個,類型需要是基本類型、STL容器等,在不BT的場合,應該夠用了…),你可以調用 Channel、ChannelProxy、SyncChannel之類的Send方法,將消息發送給其他進程,並且,實現一個Listener類,用 Message Map來分發消息給對應的處理函數。如此,整個IPC體系搭建完成。

苦力的宏和模板

不論是宏還是模板,爲了實現這套機制,都需要寫大量的類似代碼,比如爲了支持0~N個參數的Control消息,你就需要寫N+1個類似的宏;爲了支持各種基礎數據結構的序列化,你就需要寫上十來個類似的Write函數和Traits。

之所以做如此苦力的活,都是爲了用這些東西的人能夠儘可能的簡單方便,符合DIY原則。規約到之前說的設計者的職責上來,這是一個典型的苦了我一個幸福千萬人的負責任的行爲。在Chrome中,如此的代碼隨處可見,光Tuple那一套拳法,我現在就看到了使了不下三次(我曾經做過一套,直接吐血…),如此兢兢業業,真是可歌可泣啊。

【三】 Chrome的進程模型

1. 基本的進程結構

Chrome是一個多進程的架構,不過所有的進程都會由老大,Browser進程來管理,走的是集中化管理的路子。在Browser進程中,有xxxProcessHost,每一個host,都對應着一個Process,比如RenderProcessHost對應着 RenderProcess,PluginProcessHost對應着PluginProcess,有多少個host的實例,就有多少個進程在運行。

這是一個比較典型的代理模式,Browser對Host的操作,都會被Host封裝成IPC消息,傳遞給對應的Process來處理,對於大部分上層的類,也就隔離了多進程細節。

2. Render進程

先不扯Plugin的進程,只考慮Render進程。前面說了,一個Process一個tab,只是廣告用語,實際上,每一個web頁面內容(包括在 tab中的和在彈出窗口中的…),在Chrome中,用RenderView表示一個web頁面,每一個RenderView可以寄宿在任一一個 RenderProcess中,它只是依託RenderProcess幫助它進行通信。每一個RenderProcess進程都可以有1到N個 RenderView實例。

Chrome支持不同的進程模型,可以一個tab一個進程,一個site instance一個進程等等。但基本模式都是一致的,當需要創建一個新的RenderView的時候,Chrome會嘗試進行選擇或者是創建進程。比如,在one site one process的模式下,如果存在此site,就會選擇一個已有的RenderProcessHost,讓它管理這個新的RenderView,否則,會創建一個RenderProcessHost(同時也就創建了一個Process),把RenderView交給它。

在默認的one site instance one process的模式中,Chrome會爲每個新的site instance創建一個進程(從一個頁面鏈開來的頁面,屬於同一個site instance),但,Render進程總數是有個上限的。這個上限,根據內存大小的不同而異,比如,在我的機器上(2G內存),最多可以容納20個 Render進程,當達到這個上限後,你再開新的網站,Chrome會隨機爲你選擇一個已有的進程,把這個網站對應的RenderView給扔進去。。。

每一次你新輸入一個站點信息,在默認模式下,都必然導致一個進程的誕生,很可能,伴隨着另一個進程的死亡(如果這個進程沒有其他承載的 RenderView的話,他就自然死亡了,RenderView的個數,就相當於這個進程的引用計數…)。比如,你打開一個新標籤頁的時候,系統爲你創 造了一個進程來承載這個新標籤頁,你輸入http://www.baidu.com/,於是新標籤頁進程死亡,承載http://www.baidu.com/的進程誕生。你用baidu搜索了一下,毫無疑問,你基本對它的搜索結果很失望,於是你重新輸入http://www.google.com.hk/, 老的承載baidu的進程死亡,承載google的進程被構建出來。這時候你想回退到之前baidu的搜索結果,樂呵樂呵的話,一個新的承載baidu的 進程被創造,之前Google的進程死亡。同樣,你再次點擊前進,又來到Google搜索結果的時候,一個新的進程有取代老的進程出現了。

以上現象,你都可以自己來檢驗,通過觀察about:memory頁面的信息,你可以瞭解整個過程(記得每做一步,需要刷新一下about:memory 頁面)。我唧唧歪歪說了半天,其實想表達的是,Chrome並沒有像我YY的一樣做啥進程池之類的特殊機制,而是簡單的履行有就創建、沒有就銷燬的策略。 我並不知道有沒有啥很有效的多進程模型,這方面一點都沒玩過,猜測Chrome之所以採取這樣的策略,是經過琢磨的,覺得進程生死的代價可以承受,比較可行。

3. 進程開銷控制算法

說開銷無外乎兩方面的內容,一爲時間,二則空間。Chrome沒有在進程創建和銷燬上做功夫,但是當進程運行起來後,還是做了一些工作的。

節約工作首先從CPU耗時上做起,優先級越高的進程中的線程,越容易被調度,從而耗費CPU時間,於是,當一個頁面不再直接面對用戶的時候,Chrome會將它的進程優先級切到Below Normal的級別,反之,則切回Normal級別。通過這個步驟,小節約了一把時間。

進程的優先級
在 windows中,進程是有優先級的,當然,這個優先級不是真實的調度優先級,而是該進程中,線程優先級計算的基準。在《Windows via C/C++》(也就是《windows核心編程》的第五版)中,有一張詳細的表,表述了線程優先級和進程優先級的具體對應關係,感覺設計的很不錯,在此就 不再贅述了,有興趣的自行動手翻書。

當然這只是一道開胃小菜,滿漢全席是控制進程的工作集大小,以達到降低進程實際內存消耗的目的(Chrome爲了體現它對內存的節約,用了“更爲精確”的 內存消耗計算方法…)。提到這一點,Chrome頗爲自豪,在文檔中,順着道把單進程的模式鄙視了一下,基本意思是:在多進程的模式下,各個頁面實際佔用 的內存數量,更容易被控制,而在單進程的模式下,幾乎是不能作出控制的,所以,很多時候,多進程模式耗費的內存,是會小於多線程模式的。這個說法靠不靠譜,大家心裏都有譜,就不多說了。

具體說來,Chrome對進程工作集的控制算法還是比較簡單的。首先,在進程啓動的時候,需要指明進程工作的內存環境,是高內存,低內存,還是中等內存, 默認模式下,是中等內存(我以爲Chrome會動態計算的,沒想到竟然是啓動時指定…)。在高內存模式,不存在對工作集的調整,使勁用就完事了;在低內存 的模式下,調整也很簡單,一旦一個進程不再有頁面面對觀衆了,嘗試釋放其所有工作集。相比來說,中等模式下,算法相對複雜一些,當一個進程從直接面對觀衆,淪落到切換到後臺的悲慘命運,其工作集會縮減,算法爲: TargetWorkingSetSize = (LastWorkingSet/2 + CurrentWorkingSet) /2;其中,TargetWorkingSetSize指的是預期降到的工作集大小,CurrentWorkingSet指的是進程當前的工作集(在 Chrome中,工作集的大小,包含私有的和可共享的兩部分內存,而不包含已經共享了的內存空間…),LastWorkingSet,等於上一次的 CurrentWorkingSet除以DampingFactor,默認的DampingFactor爲2。而反之,當一個進程從幕後走向臺前,它的工 作集會被放大爲 LastWorkingSet * DampingFactor * 2,瞭解過LastWorkingSet的含義,你已經知道,這就是將工作集放大兩倍的另類版寫法。

Chrome的Render進程工作集調整,除了發生在tab切換(或新頁面建立)的時候,還會發生在整個Chrome的idle事件觸發後。 Chrome有個計時器,統計Chrome空閒的時長,當時長超過30s後(此工作會反覆進行…),Chrome會做一系列工作,其中就包括,調整進程的 工作集。被調整的進程,不僅僅是Render進程,還包括Plugin進程和Browser進程,換句話描述,就是所有Chrome進程。

這個算法導致一個很悲涼的狀況,當你去蹲了個廁所回到電腦前,切換了一個Chrome頁面,你發現頁面一片慘白,一陣硬盤的騷動過後,好不容易恢復了原 貌。如果再切,相同的事情又會發生,孜孜不倦,直到你切過每一個進程。這個慘案發生的主要原因,就是由於所有Chrome進程的工作集都被釋放了,頁面的 重載和Render需要不少的一坨時間,這就大大影響了用戶感受,畢竟,總看到慘白的畫面,容易產生不好的情緒。強烈感覺這個不算一個很出色的策略,應該 有一個工作集切換的底限,或者是在Chrome從idle中被激活的時候,偷偷摸摸的統一擴大工作集,發幾個事件刺激一下,把該加載的東西加載起來。

整體感覺,Chrome對進程開銷的控制,並不像想象中的有非常精妙絕倫的策略在裏面,通過工作集這總手段並不算華麗,而且,如果想很好的工作的話,有一 個非常非常重要的前提,就是被切換的頁面,很少再被繼續瀏覽。個人覺得這個假設並不是十分可靠,這就使得在某些情況下,產生非常不好的用戶體驗,也許 Chrome需要進一步在這個地方琢磨點方法的。

本文Chrome源碼剖析、上,完。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章