中級Android開發應該瞭解的Binder原理

Dovar_66發表於2019-03-14

一、基礎概念

Linux的程式空間是相互隔離的。

Linux將記憶體空間在邏輯上劃分為核心空間與使用者空間。Linux 作業系統和驅動程式執行在核心空間,應用程式執行在使用者空間,為了保證核心安全,它們是隔離的。核心空間可以訪問所有記憶體空間,而使用者空間不能訪問核心空間。

使用者程式只能通過系統呼叫陷入核心態,從而訪問核心空間。系統呼叫主要通過 copy_to_user() 和 copy_from_user() 實現,copy_to_user() 用於將資料從核心空間拷貝到使用者空間,copy_from_user() 用於將資料從使用者空間拷貝到核心空間。

二、Binder解析

Binder是Android上的一種程式間通訊機制,它基於Client-Server模式實現,由BinderDriver、ServiceManager、Client和Server四個模組組成。Binder相較於Socket等傳統IPC方式的優勢:

安全性好:為傳送方新增UID/PID身份資訊
效能更佳:傳輸過程只要一次資料拷貝,而Socket、管道等傳統IPC手段都至少需要兩次資料拷貝
複製程式碼

Binder四大模組:

  • Binder Driver位於核心空間中,主要負責Binder通訊的建立,以及其在程式間的傳遞和Binder引用計數管理/資料包的傳輸等。而Client與Server之間的跨程式通訊則統一通過Binder Driver處理轉發。

  • 對於Client來說,只需要知道自己要使用的Binder的名字,然後通過0號引用去訪問ServerManager獲取目標Binder的引用,得到引用後就可以像普通方法那樣呼叫Binder實體的方法。

  • Server在生成一個Binder實體的同時會為其繫結一個別名並將別名傳遞給Binder Driver,Binder Driver接收後如果發現是新增的Binder,那麼就會為其在核心空間中建立相應的Binder實體節點,然後Binder Driver將該節點的引用傳遞給ServerManager,ServerManager收到後再將該Binder的別名和引用插入到一張資料表中,這跟DNS中儲存的域名到IP地址的對映原理類似。

  • ServerManager也是個標準的Server,並且在Android中約定其在Binder通訊的過程中唯一標識永遠是0,也就是前面提到的0號引用。

Android系統啟動過程中SystemServer會向BinderDriver註冊ServiceManager,BinderDriver自動為ServiceManager建立Binder實體。所有在這之後啟動的應用程式都會持有這個Binder的控制程式碼,為0號引用,即所有使用者程式的0號引用都指向該Binder。ActivityManagerService、PackageManagerService等系統服務都是通過Binder機制與應用進行雙向通訊。

傳統的IPC方式:

* 傳送方先將準備好的資料存放在快取區中
* 然後通過系統呼叫進入核心中,核心服務程式在核心空間分配記憶體,將資料從傳送方快取區複製到核心快取區中。
* 接收方讀資料時也要提供一塊快取區,核心將資料從核心快取區拷貝到接收方提供的快取區中。
* 這種儲存-轉發機制有兩個缺陷:
* 首先是效率低下,需要做兩次拷貝:使用者空間->核心空間->使用者空間。Linux使用copy_from_user()和copy_to_user()實現這兩個跨空間拷貝,在此過程中如果使用了高階記憶體(high memory),這種拷貝需要臨時建立/取消頁面對映,造成效能損失。
* 其次是接收資料的快取要由接收方提供,可接收方不知道到底要多大的快取才夠用,只能開闢儘量大的空間或先呼叫API接收訊息頭獲得訊息體大小,再開闢適當的空間接收訊息體。兩種做法都有不足,不是浪費空間就是浪費時間。
複製程式碼

顯然,Linux上的跨程式通訊需要核心空間做支援。傳統的跨程式通訊方式有Socket、訊號量、管道、記憶體共享等,他們都屬於Linux核心,但Android上的Binder並不屬於Linux核心,那麼Binder如何實現IPC呢?答案是 Loadable Kernel Module檢視wiki, Android利用Linux的動態核心可載入模組機制(Loadable Kernel Module,LKM),建立Binder Driver掛載為動態核心,然後通過Binder Driver以mmap的方式將核心空間與接收方的使用者空間進行記憶體對映,於是只需要從傳送方的使用者空間拷貝資料到核心空間中,就實現了一次資料拷貝完成程式間通訊。使用mmap建立核心空間跟使用者空間的對映後,同一份實體記憶體,既可以在使用者空間用虛擬地址訪問,也可以在核心空間用虛擬地址訪問。所以mmap的本質是讓使用者空間中的一塊虛擬地址與核心空間中的一塊虛擬地址指向同一塊實體地址。

Android應用在程式啟動之初會建立一個單例的ProcessState物件,其建構函式執行時會同時完成binder mmap,為程式分配一塊記憶體,專門用於Binder通訊。

匿名Binder

在ServiceManager中註冊過的Binder都叫實名Binder。當Client與Server通過實名Binder建立好Binder連線後,Server還可以通過這個連線將新的Binder實體封裝進資料包傳遞給Client,這個被傳遞的就叫做匿名Binder,匿名Binder依然會在Binder Driver中生成實體節點,但不會在ServiceManager中註冊。

匿名Binder為通訊雙方建立起一條私密通道,只要Server沒有把匿名Binder發給別的程式,別的程式就無法通過窮舉或猜測等任何方式獲得該Binder的引用,向該Binder傳送請求。

Binder執行緒(參考資料)

Binder通訊實際上是位於不同程式中的執行緒之間的通訊。假如程式S是Server端,提供Binder實體,執行緒T1從Client程式C1中通過Binder的引用向程式S傳送請求。S為了處理這個請求需要啟動執行緒T2,而此時執行緒T1處於接收返回資料的等待狀態。T2處理完請求就會將處理結果返回給T1,T1被喚醒得到處理結果。在這過程中,T2彷彿T1在程式S中的代理,代表T1執行遠端任務,而給T1的感覺就是象穿越到S中執行一段程式碼又回到了C1。為了使這種穿越更加真實,驅動會將T1的一些屬性賦給T2,特別是T1的優先順序nice,這樣T2會使用和T1類似的時間完成任務。很多資料會用‘執行緒遷移’來形容這種現象,容易讓人產生誤解。一來執行緒根本不可能在程式之間跳來跳去,二來T2除了和T1優先順序一樣,其它沒有相同之處,包括身份,開啟檔案,棧大小,訊號處理,私有資料等。

對於Server程式S,可能會有許多Client同時發起請求,為了提高效率往往開闢執行緒池併發處理收到的請求。怎樣使用執行緒池實現併發處理呢?這和具體的IPC機制有關。拿socket舉例,Server端的socket設定為偵聽模式,有一個專門的執行緒使用該socket偵聽來自Client的連線請求,即阻塞在accept()上。這個socket就象一隻會生蛋的雞,一旦收到來自Client的請求就會生一個蛋 – 建立新socket並從accept()返回。偵聽執行緒從執行緒池中啟動一個工作執行緒並將剛下的蛋交給該執行緒。後續業務處理就由該執行緒完成並通過這個單與Client實現互動。

可是對於Binder來說,既沒有偵聽模式也不會下蛋,怎樣管理執行緒池呢?一種簡單的做法是,不管三七二十一,先建立一堆執行緒,每個執行緒都用BINDER_WRITE_READ命令讀Binder。這些執行緒會阻塞在驅動為該Binder設定的等待佇列上,一旦有來自Client的資料驅動會從佇列中喚醒一個執行緒來處理。這樣做簡單直觀,省去了執行緒池,但一開始就建立一堆執行緒有點浪費資源。於是Binder協議引入了專門命令或訊息幫助使用者管理執行緒池,包括:

· INDER_SET_MAX_THREADS
· BC_REGISTER_LOOP
· BC_ENTER_LOOP
· BC_EXIT_LOOP
· BR_SPAWN_LOOPER
複製程式碼

首先要管理執行緒池就要知道池子有多大,應用程式通過INDER_SET_MAX_THREADS告訴驅動最多可以建立幾個執行緒。以後每個執行緒在建立,進入主迴圈,退出主迴圈時都要分別使用BC_REGISTER_LOOP,BC_ENTER_LOOP,BC_EXIT_LOOP告知驅動,以便驅動收集和記錄當前執行緒池的狀態。每當驅動接收完資料包返回讀Binder的執行緒時,都要檢查一下是不是已經沒有閒置執行緒了。如果是,而且執行緒總數不會超出執行緒池最大執行緒數,就會在當前讀出的資料包後面再追加一條BR_SPAWN_LOOPER訊息,告訴使用者執行緒即將不夠用了,請再啟動一些,否則下一個請求可能不能及時響應。新執行緒一啟動又會通過BC_xxx_LOOP告知驅動更新狀態。這樣只要執行緒沒有耗盡,總是有空閒執行緒在等待佇列中隨時待命,及時處理請求。

關於工作執行緒的啟動,Binder驅動還做了一點小小的優化。當程式P1的執行緒T1向程式P2傳送請求時,驅動會先檢視一下執行緒T1是否也正在處理來自P2某個執行緒請求但尚未完成(沒有傳送回覆)。這種情況通常發生在兩個程式都有Binder實體並互相對發時請求時。假如驅動在程式P2中發現了這樣的執行緒,比如說T2,就會要求T2來處理T1的這次請求。因為T2既然向T1傳送了請求尚未得到返回包,說明T2肯定(或將會)阻塞在讀取返回包的狀態。這時候可以讓T2順便做點事情,總比等在那裡閒著好。而且如果T2不是執行緒池中的執行緒還可以為執行緒池分擔部分工作,減少執行緒池使用率。

三、mmap擴充套件

Linux的三種IO方式:標準IO、直接IO、mmap。

標準IO

應用程式平時使用的read()、write()都屬於標準IO,在發起讀寫操作後其實是往核心空間的頁快取讀寫資料。對於寫操作,系統預設是延遲寫入機制,頁快取的資料會由核心在合適的時機寫入磁碟。

* 使用者發起 write 操作
* 作業系統查詢頁快取
    a.若未命中,則產生缺頁異常,然後建立頁快取,將使用者傳入的內容寫入頁快取
    b.若命中,則直接將使用者傳入的內容寫入頁快取
* 使用者 write 呼叫完成
* 頁被修改後成為髒頁,作業系統有兩種機制將髒頁寫回磁碟
    a.使用者手動呼叫 fsync()
    b.由 pdflush 程式定時將髒頁寫回磁碟
複製程式碼

可以看出write過程中有兩次資料拷貝,第一次是從記憶體空間寫入核心空間,第二次是核心將頁快取資料寫入磁碟。

知識擴充套件:

相對於機械硬碟,SSD 儲存還有一個“寫入放大”的問題。這個問題主要和 SSD 儲存的物理結構有關。
當 SSD 被全部寫過一遍之後,再寫入的資料是不可以直接更新,只可以通過覆蓋重寫,在覆蓋之前需要先擦除資料。
但寫入的最小單位是 Page,擦除的最小單位是 Block,而 Block 遠大於 Page,所以在寫入新資料時就需要先把 Block 上的資料讀出來和要寫入的資料合併在一起,再把 Block 擦除,最後把讀出來的資料重新寫入到儲存上,這樣導致實際寫入的資料可能遠遠大於最開始需要寫入的資料。
複製程式碼

直接IO

應用程式直接讀寫磁碟。Android並沒有提供直接IO的JAVA API。

mmap

mmap是作業系統中一種記憶體對映的方法。

記憶體對映:就是將使用者空間的一塊記憶體區域對映到核心空間。對映關係建立後,使用者對這塊記憶體區域的修改可以直接反應到核心空間;反之核心空間對這段區域的修改也能直接反應到使用者空間。

mmap通常用在有物理介質的檔案系統上。使用mmap可以把檔案對映到程式的地址空間,實現磁碟地址與程式虛擬空間地址的對應關係。

優點:
    * 減少系統呼叫。只需要一次mmap()的系統呼叫,建立對映關係後就可以像操作記憶體一樣。
    * 減少資料拷貝次數。mmap()只需要一次資料拷貝。
缺點:
    * 需要佔用更多的記憶體。
複製程式碼

Java中提供的記憶體對映實現:MappedByteBuffer

相關文章