1. 程式人生 > >Chrome原始碼剖析【二】

Chrome原始碼剖析【二】

 【二】Chrome的程序間通訊

1. Chrome程序通訊的基本模式

程序間通訊,叫做IPC(Inter-Process Communication),在Chrome不多的文件中,有一篇就是介紹這個的,在這裡。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方法。。。
圖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的時候,擴充套件了這個頭的定義,完整的訊息格式如下:
圖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機制:
    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那一套拳法,我現在就看到了使了不下三次(我曾經做過一套,直接吐血...),如此兢兢業業,真是可歌可泣啊。。。