關於進程間通信我們是再熟悉不過了,有時面試也經常被問到你瞭解 IPC 嗎?我們一般都會答 AIDL ,Binder 驅動,共享內存?如果要我們再說詳細點呢?或者說說共享內存的具體實現?這裏推薦一篇羅昇陽的博客 《Android進程間通信(IPC)機制Binder簡要介紹和學習計劃》。本文是基於 linux 進程間通信來寫的,我們都知道 Android 是基於 linux 內核,因此瞭解了 linux 進程間通信也就基本瞭解了 Android 底層進程間通信。去年初來深圳去騰訊面試,被問到了知道進程間通信嗎?我說 binder 驅動,還有嗎?我說 Socket ,還有嗎?我說其他的就不瞭解了。最後面試的結果也是不出所料,GG。
首先來了解一下進程間通信的本質是什麼。在 Android 開發者需要知道的 Linux 知識 一文中提到,一個完整的進程在 32 位系統上的虛擬內存分佈爲: 0-3G 是用戶空間,3-4G 是內核空間。操作系統在映射開闢物理內存時,每個進程的用戶空間會映射到不同區域,每個進程的內核空間會映射到同一區域(可以簡單的這麼理解)。因此如果兩個進程間需要傳遞數據是不能直接訪問的,要交換數據必須通過內核,在內核中開闢一塊緩衝區,進程 A 把數據拷貝到內存緩衝區,進程 B 再從內核緩衝區把數據讀走,這種機制稱爲進程間通信(IPC,InterProcess Communication),因此進程間通信得要藉助內核空間。
在 linux 中常見的進程間通信方式有:文件,管道,信號,信號量,共享內存,消息隊列,套接字,命名管道,隨着 linux 的發展到目前最最常見的有:
- 管道(使用最簡單)
- 信號(開銷最小)
- 共享映射區(無血緣關係)
- 本地套接字(低速穩定)
對於一個 Android 開發者來說,最最最常見的就只剩共享映射區了,像我們最熟悉的 Binder 驅動,騰訊開源的 MMKV, 自己實現高性能的日誌庫等等,都是基於共享映射區也就是我們所說的共享內存。因此本文我們着重來分析共享映射區,其他的內容就一筆帶過了,如果大家實在感興趣,可以自行查閱資料。
1. 管道
我們通常所說的管道一般是指無名管道,是 IPC 中最古老的一種形式。1. 數據不能自己寫,自己讀;2. 管道中數據不可反覆讀,一旦讀走,管道中不再存在;3. 採用半雙工通信方式,數據只能單方向上流動;4. 只能在帶有血緣關係的進程間通信;5. 管道可以看成是一種特殊的文件,對於它的讀寫也可以使用普通的 read、write 等函數,但是它不是普通的文件,並不屬於其他任何文件系統,並且只存在於內存中。
#include<stdio.h>
#include<unistd.h>
int main()
{
int fd[2]; // 兩個文件描述符
pid_t pid;
char buff[20];
if(pipe(fd) < 0) // 創建管道
printf("Create Pipe Error!\n");
if((pid = fork()) < 0) // 創建子進程
printf("Fork Error!\n");
else if(pid > 0) // 父進程
{
close(fd[0]); // 關閉讀端
write(fd[1], "hello pipe\n", 11);
}
else
{
close(fd[1]); // 關閉寫端
read(fd[0], buff, 20);
printf("%s", buff);
}
return 0;
}
2. 信號
信號 (signal) 機制是 Linux 系統中最爲古老的進程間通信機制,信號不能攜帶大量的數據信息,一般在滿足特定場景時纔會觸發信號。信號啥時會產生?
- 按鍵產生,ctrl+c,ctrl+z
- 系統調用產生,kill,raise,abort
- 軟件條件產生,alarm
- 硬件異常產生,非法訪問內存,除0,內存對齊出錯
- 命令產生,kill
信號出現時怎麼處理?
- 忽略此信號,但有兩種信號決不能被忽略,它們是: SIGKILL\SIGSTOP。 這是因爲這兩種信號向超級用戶提供了一種終止或停止進程的方法。
- 執行系統默認動作,對大多數信號的系統默認動作是終止該進程。
- 執行用戶希望的動作,通知內核在某種信號發生時,調用一個用戶函數。在用戶函數中,執行用戶希望的處理。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>
int main(int argc, char* argv[]){
pid_t pid = fork();
if(pid < 0){
printf("fork error!\n");
}else if(pid > 0){
while(1){
printf("I am parent!\n");
sleep(1);
}
}else if(pid == 0){
sleep(5);
kill(getppid(), SIGKILL);
}
return 0;
}
上面是一個非常簡單的小例子,大家不妨看一下 Process.killProcess() 的源碼。
3. 共享映射區
有關於共享內存的實現方式,大家可以參考一下這篇文章《JNI 基礎 - Android 共享內存的序列化過程》 ,這裏我們主要來講講 mmap 這個函數作用與實現原理,在 Android 的 binder 驅動中,在騰訊開源的 MMKV庫中,在一些高性能的日誌庫中,凡是關於共享映射區的地方都會有它的存在。先來看下函數的原型:
void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offsize);
參數 start:指向欲映射的內存起始地址,通常設爲 NULL,代表讓系統自動選定地址,映射成功後返回該地址。
參數 length:代表將文件中多大的部分映射到內存。
參數 prot:映射區域的保護方式。可以爲以下幾種方式的組合:
- PROT_EXEC 頁內容可以被執行
- PROT_READ 頁內容可以被讀取
- PROT_WRITE 頁可以被寫入
- PROT_NONE 頁不可訪問
參數 flags:指定映射對象的類型,映射選項和映射頁是否可以共享。可以爲以下幾種方式的組合:
- AP_FIXED 使用指定的映射起始地址,如果由start和len參數指定的內存區重疊於現存的映射空間,重疊部分將會被丟棄。如果指定的起始地址不可用,操作將會失敗。並且起始地址必須落在頁的邊界上。
- MAP_SHARED 與其它所有映射這個對象的進程共享映射空間。對共享區的寫入,相當於輸出到文件。直到msync()或者munmap()被調用,文件實際上不會被更新。
- MAP_PRIVATE 建立一個寫入時拷貝的私有映射。內存區域的寫入不會影響到原文件。這個標誌和以上標誌是互斥的,只能使用其中一個。
- MAP_NORESERVE 不要爲這個映射保留交換空間。當交換空間被保留,對映射區修改的可能會得到保證。當交換空間不被保留,同時內存不足,對映射區的修改會引起段違例信號。
- MAP_ANONYMOUS 匿名映射,映射區不與任何文件關聯。
- 等等不常用的
參數 fd:文件句柄 fd。如果 MAP_ANONYMOUS 被設定,爲了兼容問題,其值應爲-1。
參數 offset:被映射對象內容的偏移位置(起點)。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <pthread.h>
struct person{
char name[24];
int age;
};
void sys_err(const char *str){
perror(str);
exit(0);
}
int main(int argc, char *argv[]){
int fd;
struct person stu = {"Darren", 25};
struct person *p;
fd = open("test_map", O_RDWR|O_CREAT|O_TRUNC, 0644);
if(fd == -1)
sys_err("open error");
ftruncate(fd, sizeof(stu));
p = (person*)mmap(NULL, sizeof(stu), PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
if(p == MAP_FAILED)
sys_err("mmap error");
while(1){
memcpy(p, &stu, sizeof(stu));
stu.age++;
sleep(1);
}
munmap(p, sizeof(stu));
close(fd);
return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <pthread.h>
struct person{
char name[24];
int age;
};
void sys_err(const char *str){
perror(str);
exit(0);
}
int main(int argc, char *argv[]){
int fd;
struct person *p;
fd = open("test_map", O_RDONLY);
if(fd == -1)
sys_err("open error");
p = (person*)mmap(NULL, sizeof(person), PROT_READ, MAP_SHARED, fd, 0);
if(p == MAP_FAILED)
sys_err("mmap error");
while(1){
printf("name = %s, age = %d\n", p->name, p->age);
sleep(2);
}
munmap(p, sizeof(person));
close(fd);
return 0;
}
關於其實現的原理,最好的方式自然是看源碼,但這裏我們主要來聊聊 Android binder 中 mmap 的作用及原理(一次內存拷貝),關於 mmap 的源碼大家可以自行閱讀(不難的),具體的位置在
android/platform/bionic/libc/bionic/mmap.cpp
Android 應用在進程啓動之初會創建一個單例的 ProcessState 對象,其構造函數執行時會同時完成 binder 的 mmap,爲進程分配一塊內存,專門用於 Binder 通信,如下。
ProcessState::ProcessState(const char *driver)
: mDriverName(String8(driver))
, mDriverFD(open_driver(driver))
...
{
if (mDriverFD >= 0) {
// mmap the binder, providing a chunk of virtual address space to receive transactions.
mVMStart = mmap(0, BINDER_VM_SIZE, PROT_READ, MAP_PRIVATE | MAP_NORESERVE, mDriverFD, 0);
...
}
}
第一個參數是分配地址,爲0意味着讓系統自動分配,先在用戶空間找到一塊合適的虛擬內存,之後,在內核空間也找到一塊合適的虛擬內存,修改兩個控件的頁表,使得兩者映射到同一塊物理內存。
Linux 的內存分用戶空間跟內核空間,同時頁表也分兩類,用戶空間頁表跟內核空間頁表,每個進程有一個用戶空間頁表,但是系統只有一個內核空間頁表。而 Binder mmap 的關鍵是:也更新用戶空間對應的頁表的同時也同步映射內核頁表,讓兩個頁表都指向同一塊地址,這樣一來,數據只需要從 A 進程的用戶空間,直接拷貝拷貝到 B 所對應的內核空間,而 B 多對應的內核空間在 B 進程的用戶空間也有相應的映射,這樣就無需從內核拷貝到用戶空間了。
static int binder_mmap(struct file *filp, struct vm_area_struct *vma)
{
int ret;
...
if ((vma->vm_end - vma->vm_start) > SZ_4M)
vma->vm_end = vma->vm_start + SZ_4M;
...
// 在內核空間找合適的虛擬內存塊
area = get_vm_area(vma->vm_end - vma->vm_start, VM_IOREMAP);
proc->buffer = area->addr;
// 記錄用戶空間虛擬地址跟內核空間虛擬地址的差值
proc->user_buffer_offset = vma->vm_start - (uintptr_t)proc->buffer;
...
proc->pages = kzalloc(sizeof(proc->pages[0]) * ((vma->vm_end - vma->vm_start) / PAGE_SIZE), GFP_KERNEL);
// 分配page,並更新用戶空間及內核空間對應的頁表
ret = binder_update_page_range(proc, 1, proc->buffer, proc->buffer + PAGE_SIZE, vma);
...
return ret;
}
static int binder_update_page_range(struct binder_proc *proc, int allocate,
void *start, void *end,
struct vm_area_struct *vma)
{
...
// 一頁頁分配
for (page_addr = start; page_addr < end; page_addr += PAGE_SIZE) {
int ret;
struct page **page_array_ptr;
// 分配一頁
page = &proc->pages[(page_addr - proc->buffer) / PAGE_SIZE];
*page = alloc_page(GFP_KERNEL | __GFP_HIGHMEM | __GFP_ZERO);
...
// 修改頁表,讓物理空間映射到內核空間
ret = map_vm_area(&tmp_area, PAGE_KERNEL, &page_array_ptr);
..
// 根據之前記錄過差值,計算用戶空間對應的虛擬地址
user_page_addr =
(uintptr_t)page_addr + proc->user_buffer_offset;
// 修改頁表,讓物理空間映射到用戶空間
ret = vm_insert_page(vma, user_page_addr, page[0]);
}
...
return -ENOMEM;
}
上面的代碼可以看到,binder 一次拷貝的關鍵是,完成內存的時候,同時完成了內核空間跟用戶空間的映射,也就是說,同一份物理內存,既可以在用戶空間用虛擬地址訪問,也可以在內核空間用虛擬地址訪問。
視頻鏈接:https://pan.baidu.com/s/17kmg5JUhFrlNIvhKRaNv4Q
視頻密碼:a4pw