Chromium多進程機制解析,chromium機制解析
關於Chromium多進程分析的文章很多了,這篇嘗試以淺顯的方式解釋Chromium多進程機制,解析IPC內部運作的基本機制。
Chromium如何保證多進程的效能
對於一個多進程應用,其核心要解決的是並發的問題.兩個面: 線程 和 IPC.
一個多進程互動程式和城市的交通管理是非常相似,我做個類比:
| 交通管理的問題 |
解決辦法 |
對應多進程應用的情況 |
| 車多 |
限購。不要有事沒事就買車。 |
事務多。 |
| 路窄 |
限行。沒能拓寬前,只能限制上路的汽車。 |
進程通訊負載有限。 |
| 臨時事故 |
分流。劃分不同功能的車道,避免相互間的幹擾。 |
任務等待導致的阻塞 |
| 缺少路況資訊 |
主動監聽,錯峰上路。 |
IPC不可用時嘗試使用會導致等待。 |
Chromium為解決這些問題(即C10K問題),使用了 非阻塞式,多線程,配合狀態監聽的解決方案! 主要運用了以下關鍵技術:
- Unix Domain Socket (POSIX下使用的IPC機制)
- libevent (輕型事件驅動的網路程式庫,用於監聽IPC中的連接埠(檔案描述符))
- ChannelProxy (為Channel提供安全執行緒的機制)
- 閉包 (線程的運作方式)
讓我們從頭說起!
先修路! 建立通道
寫一個單機下多進程應用的核心就是建立進程之間溝通的方式,可以稱為channel 或者 pipe。作業系統會提供這樣的基礎機制,包括:named pipe, shared memory, socket。
Chromium在POSIX下使用Unix Domain Socket來實現。Unix Domain Socket通過複用網路socket的標準介面,提供輕量級的穩定的本機socket通訊。Unix Domain的命名則是來自於使用socket時將domain參數定義為: PF_UNIX (Mac OS)或AF_UNIX, 來標識為單機的通訊使用。socket的建立不以IP地址為目標了,而是由檔案系統中的FD(檔案描述符)。
socket API原本是為網路通訊設計的,但後來在socket的架構上發展出一種IPC機制,就是UNIX Domain Socket。雖然網路socket也可用於同一台主機的進程間通訊(通過loopback地址127.0.0.1),但是UNIX Domain Socket用於IPC更有效率:不需要經過網路通訊協定棧,不需要打包拆包、計算校正和、維護序號和應答等,只是將應用程式層資料從一個進程拷貝到另一個進程。 UNIX域通訊端與TCP通訊端相比較,在同一台主機的傳輸速度前者是後者的兩倍。這是因為,IPC機制本質上是可靠的通訊,而網路通訊協定是為 不可靠的通訊設計的。UNIX Domain Socket也提供面向流和面向資料包兩種API介面,類似於TCP和UDP,但是面向訊息的UNIX Domain Socket也是可靠的,訊息既不會丟失也不會順序錯亂。
使用UNIX Domain Socket的過程和網路socket十分相似,也要先調用socket()建立一個socket檔案描述符,address family指定為AF_UNIX,type可以選擇SOCK_DGRAM或SOCK_STREAM,protocol參數仍然指定為0即可。
UNIX Domain Socket與網路socket編程最明顯的不同在於地址格式不同,用結構體sockaddr_un表示,網路編程的socket地址是IP地址加連接埠 號,而UNIX Domain Socket的地址是一個socket類型的檔案在檔案系統中的路徑。
Chromium在POSIX下直接使用socketpair() API建立了已經連通的匿名管道, 邏輯結構如下:
可以看過IPC機制裡區分了Server和Client, 其實通過socketpair()建立的匿名管道是全雙工系統,實質上並不區分Server/Client。
這個IPC建立的過程是在主進程完成的,需要使用其它的機制通知子進程。在Android Chrome下,通過傳遞FD列表完成這個操作。這個稍後再解釋 (可以尋找kPrimaryIPCChannel學習)。
當子進程知道Server端的socket FD後,就可以進行串連,發送hello message, 認證後就可以開始通訊了。
通訊連接埠的管家
當連接埠打通後,效率成為關鍵。通常而言,一般這時候會考慮為了及時處理收到訊息,要麼輪詢,要麼實現回調。但事情並不是這麼簡單。
雖說Unix Domain Socket不走網路棧已經提升不了效能。但還有負載的問題需要解決。使用回調機制是必須的,更重要的是面臨C10K問題:
C10K Problem
C10K 問題的最大特點是:設計不夠良好的程式,其效能和串連數及機器效能的關 系往往是非線性。舉個例子:如果沒有考慮過 C10K 問題,一個經典的基於 select 的程式能在舊伺服器上很好處理 1000 並發的輸送量,它在 2 倍效能新伺服器上往往處 理不了並發 2000 的輸送量。
Chromium使用了大名鼎鼎的第三方並髮網絡庫:libevent來完成這項工作 (另外還有ACE,自適應通訊環境)。 下面是它的功能介紹:
Libevent是一個輕量級的開源高效能網路程式庫.有幾個顯著的亮點:
a. 事件驅動(event-driven),
b. 高效能 輕量級,專註於網路,不如ACE那麼臃腫龐大
c. 註冊事件優先順序
基本的socket編程是阻塞/同步的,每個操作除非已經完成或者出錯才會返回,這樣對於每一個請求,要使用一個線程或者單獨的進程去處理,系統資源沒法支撐大量的請求(所謂c10k problem),例如記憶體:預設情況下每個線程需要佔用2~8M的棧空間。posix定義了可以使用非同步select系統調用,但是因為其採用了輪詢的方式來判斷某個fd是否變成active,效率不高[O(n)],串連數一多,也還是撐不住。於是各系統分別提出了基於非同步/callback的系統調用,例如Linux的epoll,BSD的kqueue,Windows的IOCP。由於在核心層面做了支援,所以可以用O(1)的效率尋找到active的fd。基本上,libevent就是對這些高效IO的封裝,提供統一的API,簡化開發。
簡而言之,就是:"不要等!只要好了,我就會通知你!",就是典型的好萊塢法則。這相當於在通訊連接埠設了一位大管家,提升IPC互動的能力。
Libevent的核心是應用瞭解決並發問題中常用的基於事件驅動的Reactor模式。簡單而言就是通過一個內部的迴圈,在事件觸發時啟動並進行響應,無事件時則掛起.它同樣需要注意事件回調的處理不能做太多事情,避免擁塞.在Chromium裡的代碼體現IPC Channel實現如下介面:
// Used with WatchFileDescriptor to asynchronously monitor the I/O readiness// of a file descriptor.class Watcher { public: // Called from MessageLoop::Run when an FD can be read from/written to // without blocking virtual void OnFileCanReadWithoutBlocking(int fd) = 0; virtual void OnFileCanWriteWithoutBlocking(int fd) = 0; protected: virtual ~Watcher() {}};
關於Reactor模式和另一類相似的模式的比對, 可以讀一下這篇文章。
專人接待! 排隊,排隊...
IPC通道已經建立好了,但有一大群的調用者。任何人都想在自己方便的時候進行IPC通訊,這樣就存在並發問題了。Channel本身只想做好通道的管理工作,一心對外。 Chromium為此引入了Channel Proxy。
Proxy從設計模式上來看,職能上就是服務上的秘書,安排訪客與Channel見面的時間和方式。同線程的可以入隊列等候,不同線程的,不好意思,出門右拐,再進來。總之Channel只在一個線程上做事,Channel Proxy負責將要處理的事務安排到指定的線程上。
bool ChannelProxy::Send(Message* message) { context_->ipc_task_runner()->PostTask( FROM_HERE, base::Bind(&ChannelProxy::Context::OnSendMessage, context_, base::Passed(scoped_ptr<Message>(message)))); return true;}
在Chromium中的線程只負責建立跑道,執行事務,它不會儲存特定任務的上下文資訊(有助於提高效能),也不關心執行的是什麼事務。執行什麼操作完全由上層的商務邏輯決定。在主幹道上,任務的處理時間是非常寶貴的,特定是在UI線程上。進程裡會指定Channel處理任務所在的線程,多線程情況以下線程任務的方式請求Channel發送及處理訊息。線上程上其核心就是閉包的實現.
讓線程更高效 - 裸奔的線程
提升應用程式的並發能力,兩個要點:
- 任務響應短,快.不要有耗時的事務。
- 盡量少使用鎖。
- 避免頻繁的環境切換。
前面提到的libevent中應用的Reactor模式也是要達到相同的目的. 第一點是應用程式層的一個約定,第二層就是線程機制要保障的.
傳統的線程在建立時就會指定一個入口,往往已經是一個具體功能的入口了,裡面會一個迴圈,對所要監測的事件,以及對應的處理,這個迴圈體本身是清楚,甚至可以做一些邏輯判斷工作.這些就是因為它掌握了當前任務的上下文資訊.
Chromium上的線程可以避免這類的環境切換, 線程本身不儲存任務的資訊,任務對其是透明的,線程只負責調用其執行操作,可以視為裸奔的狀態,沒有任何負擔.跑線上程上的任務以典型的Command模式實現。它必須解決兩個問題:
- 任務本身在一個線程運行,就可以由其自己儲存上下文資訊.
- 任務的參數(也是一種上下文),則可以通過閉包的方式也由其自己儲存.
閉包在C++有很多的嘗試,Chromium中特別說明其也參考了tr1::bind的設計.關於Chromium的線程實現這裡先不多做說明。
Chromium進程架構
從邏輯上來看,Chromium將UI所在的進程視為主進程,取名為Browser, 頁面渲染所在的進程以及其它業務進程,都是子進程。包括Renderer, GPU等。
主進程本身除了初始化自身,還要負責建立子進程,同時通知建立channel的資訊(檔案描述符)。概念性模型如下:
對於Chromium而言,Contents已經代表一個瀏覽器能力,在其上就是實現瀏覽器業務的Embedder了。但不同的Embedder或者在不同的平台進程的選擇可能不同,比如啟動一個瀏覽主進程的行為不同(比如在Android上就不需要啟動新進程了,直接初化BrowserMainRunner。而Linux則可以要以Service Process的形式運行主進程。),初始化SandBox的方式不同。Chromium與是將所有進程的啟動的操作集中起來供Embedder和主進程來啟動新進程 (Embedder負責啟動Browser進程, 主進程則再啟動新進程,並在ContentMainRunner中根據參數啟動不同的子進程。):
*在單進程模型下,主進程就不會直接啟動子進程了,而是通過CreateInProcessRendererThread()建立新的線程(InProcessRendererThread),同樣會傳入channel描述符。
進一步看主進程和Renderer進程,兩邊負責介面的,則是兩個兼有(繼承)IPC::Sender和IPC::Listener功能的類:RenderProcessHost和RenderThread。沒錯,就是RenderThread! RenderProcess也存在,只是一個進程的邏輯表示,只有一小部分的代碼。以單進程模式下調用InProcessRendererThread來初始化RenderThread為例,可以看到channel_id_是傳入到RenderThread中處理的。
void InProcessRendererThread::Init() { render_process_.reset(new RenderProcessImpl()); new RenderThreadImpl(channel_id_);}
專線與公用線路 - 訊息的分發
分發訊息時,分為廣播和專線兩種方式。在Chromium中一個頁面在不同線程,Browser和Renderer兩端以routed id為標識彼此。如果要說悄悄話,就指定一個routed id, 這類訊息稱為Routed Message,是專線。 另一類訊息,則是進行廣播,不區分身份,這類訊息為Control Message。
RenderView (繼承自RenderWidget)用於向IPC Channel註冊自己的代碼在RenderWidget::DoInit():
bool result = RenderThread::Get()->Send(create_widget_message);if (result) { RenderThread::Get()->AddRoute(routing_id_, this); ......}
另外在Browser和Render Thread初始化時,都會在IPC Channel上增加一組Filters,以便供其它功能使用。
參考資料
- Unix Domain Socket IPC
- libevent源碼深度剖析
- Adaptive Communication Environment
- Potential performance bottleneck in Linux TCP
- A Proposal to add a Polymorphic Function Object Wrapper to the Standard Library
WebKit2多進程機制的解析