6.跨进程通信设计
# 45.跨进程通信设计
📍 本篇位置:第 5 卷 · 系统交互 · 第 6 篇(卷收官 / 全书收官) 🎯 核心矛盾:进程隔离要安全 vs 协作要通信 —— OS 用"墙"隔开进程,IPC 在墙上凿"门" 🧭 设计灵魂:所有 IPC 都在三件事上做取舍——带宽 / 延迟 / 安全;从管道到 Binder,每一种 IPC 都是某个具体场景的最优解 🌐 跨语言覆盖:Linux(管道 / 共享内存 / Socket / 信号 / 消息队列) · Android(Binder / Messenger / AIDL / ContentProvider) · iOS(XPC / Mach Port / Distributed Objects) · Windows(LPC / Named Pipe / COM) · 网络分布式(gRPC / Thrift / Protobuf) 🔗 延伸阅读:← 44.消息机制设计思想 · ← 40.窗口核心设计思想 · ← 03.第3卷-并发之道
flowchart LR
A[根本矛盾<br/>进程隔离 vs 数据共享] --> B1[共享内存派<br/>性能优先<br/>需要锁]
A --> B2[消息传递派<br/>解耦优先<br/>有拷贝]
B1 --> C1[shm / mmap<br/>零拷贝]
B2 --> C2[管道 / Socket<br/>双拷贝]
B2 --> C3[Binder<br/>一次拷贝]
C1 & C2 & C3 --> D[设计天花板<br/>带宽 / 延迟 / 安全<br/>三角不可能]
style A fill:#f8d7da
style D fill:#fff3cd
2
3
4
5
6
7
8
9
# 目录介绍
- 00.一次App启动8秒事故说起
- 01.为何需IPC:进程隔离代价
- 02.IPC 的范式分类
- 03.管道:最古老的 IPC
- 04.共享内存:性能之王
- 05.Socket:万能但偏重的 IPC
- 06.Binder:Android的天才设计
- 07.Mach Port/XPC:苹果优雅方案
- 08.信号 / 消息队列 / 信号量
- 09.分布式IPC:单机到集群
- 10.IPC 选型决策与设计哲学
# 00.一次App启动8秒事故说起
# 0.1 双十一前的性能告警
某头部电商 App,2022 年 10 月底大促前压测,性能团队跑出一份报告:
冷启动时间:3.2 秒(基线)→ 8.1 秒(当前版本)★ 退化 153%
低端机(Android 8,2GB RAM):12.4 秒 → 用户大概率直接卸载
2
研发组长第一反应是"是不是新版本加了太多 SDK"——查了启动注册表,确实加了 5 个新 SDK。但即便全关掉,启动时间也只回到 6 秒——还有 2.8 秒"凭空消失"了。
# 0.2 用 Systrace 看 IPC 风暴
性能工程师抓了一份 Systrace 跟踪,时间轴上看到一片密密麻麻的红条:
启动期间 0~8 秒,主线程上:
Binder Transaction × 1247 次
累计 Binder IPC 阻塞时长:4.3 秒 ★ 占总启动时间 53%
排在前列的 Binder 调用:
PackageManager.getPackageInfo() × 312 次 耗时 1.2 秒
ContentResolver.query(settings) × 89 次 耗时 0.6 秒
AccountManager.getAccounts() × 45 次 耗时 0.4 秒
WindowManager.addView() × 23 次 耗时 0.3 秒
其它(位置、网络状态等) × 778 次 耗时 1.8 秒
2
3
4
5
6
7
8
9
10
4.3 秒的 IPC 阻塞——主线程不是在干活,是在排队等待 system_server 进程的回复。
工程师追到 getPackageInfo 312 次的来源,发现是某个新加的反作弊 SDK:
// 反作弊 SDK 内部代码
class AntiCheatSDK {
void checkInstalled() {
for (String pkg : KNOWN_RISKY_PACKAGES) { // 数组里有 312 个包名
try {
PackageManager pm = ctx.getPackageManager();
PackageInfo info = pm.getPackageInfo(pkg, 0); // ★ 每次都是一次跨进程
if (info != null) reportRiskyApp(pkg);
} catch (NameNotFoundException ignored) {}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
312 次 Binder IPC,每次 3-5ms,累计 1.2 秒——主线程被这一个循环卡住。
# 0.3 老司机的灵魂三问
问题 1:为什么一次 getPackageInfo 要 3-5ms?
工程师:跨进程嘛,肯定慢一点。
架构师:3-5ms 是什么概念?CPU 时钟 1GHz,3ms 能执行 300 万条指令——
但实际它只是在 system_server 里查一个 HashMap,本身只要几微秒。
那剩下的时间花在哪?
工程师:……Binder?
架构师:对。一次 Binder 调用要:
1. 用户态 → 内核态切换(2 次:发起方陷入内核 + 内核唤醒目标方)
2. 数据从用户空间拷贝到 Binder 驱动的共享内存
3. 目标进程从内核态切换到用户态处理
4. 回复时再走一遍同样的流程
5. 上下文切换 + 调度延迟
仅"调度+上下文切换"就 1-2ms,加上数据序列化、查询本身、反序列化,
3-5ms 已经是"很优秀"的成绩了。
真正的杀手是:你要做这件事 312 次。
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
问题 2:为什么不能批量?
工程师:API 就是这样啊,PackageManager 没提供批量接口。
架构师:API 没提供,是不是就只能挨个调?
你可以调用一次 getInstalledPackages() 拿全量,再在内存里做检查:
// 改造后
List<PackageInfo> all = pm.getInstalledPackages(0); // 1 次 IPC
Set<String> installed = all.stream()
.map(p -> p.packageName)
.collect(toSet());
for (String pkg : KNOWN_RISKY_PACKAGES) {
if (installed.contains(pkg)) reportRiskyApp(pkg); // 内存查询,0 IPC
}
312 次 → 1 次。
规则就一条:永远把"循环里的 IPC"变成"IPC 外的循环"。
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
问题 3:为什么这种问题在内部测试时没暴露?
工程师:因为开发机性能强,IPC 也快。
架构师:本质问题是——同步 IPC 是"性能埋雷"。
一次调用 3ms 你完全感觉不到,但循环 312 次就 1 秒。
开发机 CPU 快、上下文切换快、内存大、调度延迟低,
同样的代码在低端机上能慢 10 倍。
所以 IPC 性能必须看"调用次数 × 单次成本",
而不是"看着挺快就行"。
2
3
4
5
6
7
8
# 0.4 这次事故揭示了什么
事故的本质,不是反作弊 SDK 写错了,而是研发对"IPC 成本"缺乏直觉:
我以为:
pm.getPackageInfo() 就是一个普通的方法调用,写起来和 list.get(i) 没区别
实际:
这一行代码背后是 ——
用户态 → 内核态 → 进程切换 → system_server 处理 → 切换回来
这是一次"跨越 OS 隔离边界"的旅行,
比本地方法慢 1000-10000 倍
2
3
4
5
6
7
8
| 视角 | 你以为的 | 实际发生的 |
|---|---|---|
| 写代码 | "调用一个方法" | 跨进程跳板 + 内核态切换 + 数据序列化 |
| 性能 | "应该很快" | 比本地调用慢 3-4 个数量级 |
| 测试 | "开发机能跑就行" | 低端机的 IPC 延迟可能 10 倍于开发机 |
| 同步 | "等等就好" | 主线程卡住,掉帧 / ANR |
整个 IPC 设计的核心矛盾就藏在这里:
IPC 让"分布在不同进程的代码"看起来像本地调用,但你不能假装它真的是本地调用。每一次跨越进程边界,都要付出至少 1000 倍于本地调用的代价——而你在写代码时几乎看不到这个代价。
# 0.5 五个层层递进的追问
带着这次事故,整篇文章在回答下面五个递进问题:
| 追问 | 答案章节 |
|---|---|
| 为什么 OS 要隔离进程?这种隔离的代价是什么? | §01 |
| 跨进程通信都有哪些方式?怎么分类? | §02 |
| 经典 Unix IPC(管道/共享内存/Socket)各自适合什么? | §03 / §04 / §05 |
| Android 的 Binder 凭什么能做到一次拷贝? | §06 |
| 选型时怎么权衡带宽、延迟、安全? | §10 |
带着这次 8 秒启动的伤痛,正式进入 IPC 的世界——你将看到,所有抽象的"管道、共享内存、Binder、Mach Port"原理,最终都能落到这次事故的根因图上。
# 01.为何需IPC:进程隔离代价
# 1.1 1960 年代:单进程的悲剧
要真正理解 IPC 的价值,最好回到没有"进程"概念的时代看看。
1960 年代早期的批处理系统(IBM 7094、CTSS)只跑一个程序:
┌─────────────────────────────────┐
│ 整台机器的内存 │
│ ┌─────────────────────────────┐ │
│ │ 程序代码 + 数据 + 栈 + 堆 │ │
│ │ 全部混在一起 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
2
3
4
5
6
7
后果是灾难性的:
- 一个程序崩了,整台机器重启 —— 一个数组越界写到了 OS 的内存
- 没办法跑多个任务 —— 第二个程序写到第一个程序的数据上
- 没有安全可言 —— 任何代码都能读取任何内存
1965 年 Multics 提出"进程"概念:每个程序在自己的虚拟地址空间里跑,互相看不见。这就是现代 OS 的基石——但带来了一个新问题:进程之间怎么协作?
# 1.2 进程隔离的代价
进程隔离的本质是:虚拟地址空间互相不可见。
进程 A 看到的内存: 进程 B 看到的内存:
0x0000_0000 0x0000_0000
↓ ↓
0xFFFF_FFFF 0xFFFF_FFFF
A.addr 0x1000 ─→ 物理 0x4000 ┐
│ 互相看不到对方
B.addr 0x1000 ─→ 物理 0x9000 ┘ 各自独立
2
3
4
5
6
7
8
A 要把数据传给 B,直接传地址没用——A 的 0x1000 在 B 那里指向另一块内存。必须通过内核做中介。
这就是 IPC 的根本结构:
进程 A ──→ 内核(IPC 机制)──→ 进程 B
↑ ↑
用户/内核 切换 用户/内核 切换
2
3
每一次 IPC 至少需要:
- 进程 A 陷入内核态(系统调用)
- 内核处理(拷贝/路由/调度)
- 进程 B 被唤醒,从内核态返回用户态
- 上下文切换 ×2(A 出 CPU,B 上 CPU)
这就是为什么 §0 事故的 312 次 IPC 累计 1.2 秒——每一次都要走这套流程。
# 1.3 IPC 设计的三个核心矛盾
所有 IPC 设计都在三个矛盾上做取舍:
# 矛盾一:性能 vs 安全
共享内存最快(无拷贝),但失去了进程隔离的安全性
消息传递最安全(数据复制,互不影响),但有拷贝开销
2
# 矛盾二:易用性 vs 灵活性
RPC 让远程调用像本地调用,但隐藏了网络异常和延迟
原始 Socket 完全暴露异常,但每次都要写很多 boilerplate
2
# 矛盾三:吞吐 vs 延迟
批量 IPC 吞吐高,但首条消息延迟高
单条 IPC 延迟低,但 QPS 上限低
2
§0 事故是同时踩了三个坑:用同步 IPC(高延迟)、单条调用(低吞吐)、看不到成本(易用性陷阱)。
# 02.IPC 的范式分类
# 2.1 共享内存派 vs 消息传递派
按"数据是否经过拷贝",IPC 分两大派系:
flowchart LR
subgraph 共享内存派[共享内存派]
SA[进程 A] -.同一块物理内存.- SB[进程 B]
end
subgraph 消息传递派[消息传递派]
MA[进程 A] -->|拷贝| K[内核] -->|拷贝| MB[进程 B]
end
2
3
4
5
6
7
| 派系 | 代表 | 优点 | 缺点 |
|---|---|---|---|
| 共享内存 | shm / mmap / Binder mmap 区 | 零或一次拷贝,性能最高 | 需要锁,复杂;安全弱 |
| 消息传递 | 管道 / Socket / 消息队列 | 解耦,安全 | 多次拷贝,性能差 |
精妙的中间派——Binder:用 mmap 实现"内核到用户态零拷贝",但接口看起来是消息传递。用最简单的接口,达到最优的性能——这就是工程艺术。
# 2.2 同步 vs 异步
同步 IPC:发起者阻塞等待结果(如 Binder.transact / RPC)
异步 IPC:发起者发完就走,结果通过回调/Future 拿(如管道写)
2
§0 事故的 312 次 IPC 是同步的——主线程必须等每次返回。如果改成异步(如发到 Handler):
// 异步改造
executor.submit(() -> {
for (String pkg : KNOWN_RISKY_PACKAGES) {
// ... IPC 调用,但不在主线程
}
return result;
}).thenAccept(result -> handleResult(result));
2
3
4
5
6
7
主线程立刻返回,IPC 在后台慢慢做——这是降低 IPC 影响的标准手段。
# 2.3 数据流向:单向 vs 双向
单向(信号、广播): A ──→ B
双向半双工(管道): A ←→ B(一次只能一方说)
双向全双工(Socket): A ⇄ B(双方同时说)
2
3
不同流向决定了不同的 API 设计——比如管道要写 pipe(int fd[2]) 创建两个端点,Socket 要 socketpair()。
# 03.管道:最古老的 IPC
# 3.1 匿名管道:Shell 的灵魂
1973 年 Doug McIlroy 在贝尔实验室发明了管道。Unix 一句话哲学的"组合小工具",靠的就是管道:
ps aux | grep nginx | awk '{print $2}' | xargs kill -9
↑ ↑ ↑
管道 管道 管道
2
3
每个 | 创建一个匿名管道,把前一个进程的 stdout 接到下一个进程的 stdin。一行命令完成"找进程→过滤→提取 PID→杀进程"四步——这就是 Unix 的伟大。
C 代码实现:
int fd[2];
pipe(fd); // fd[0] 读端,fd[1] 写端
if (fork() == 0) {
// 子进程:读端
close(fd[1]);
char buf[1024];
read(fd[0], buf, 1024);
// ...
} else {
// 父进程:写端
close(fd[0]);
write(fd[1], "Hello", 5);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
# 3.2 命名管道:进程间也能用
匿名管道有个限制:只能在有亲缘关系的进程间用(fork 出来的父子进程)。两个无关进程怎么通信?命名管道(FIFO):
mkfifo("/tmp/mypipe", 0666);
// 进程 A
int fd = open("/tmp/mypipe", O_WRONLY);
write(fd, "Hello", 5);
// 进程 B(完全无关)
int fd = open("/tmp/mypipe", O_RDONLY);
read(fd, buf, 1024);
2
3
4
5
6
7
8
9
通过文件系统里的一个特殊文件作为"会面点"。它就是 Linux 一切皆文件哲学的体现——IPC 的端点也是文件。
# 3.3 管道内核实现:环形缓冲区
管道在内核里是一个固定大小的环形缓冲区(Linux 默认 64KB):
┌─────────────────┐
read fd ←──────│ 环形缓冲区 │←────── write fd
│ 容量 64KB │
│ read_pos │
│ write_pos │
└─────────────────┘
2
3
4
5
6
写满了怎么办? 写阻塞——直到读端腾出空间。 读空了怎么办? 读阻塞——直到写端写入。
这就是管道的天然背压:消费者跟不上生产者,生产者会自动减速。
# 3.4 管道的硬伤
管道虽然简单优雅,但限制不少:
- 半双工:一根管道只能单向流,要双向得开两根
- 字节流(无消息边界):写
"AAA"和"BBB",读端可能一次读到"AAABBB",分不清 - 缓冲区固定:超大数据要分块发送
- 本机限制:只能本机用,跨机要用 Socket
字节流问题是个隐藏雷——业务往往要在管道之上自己加"消息边界协议"(比如先写长度再写内容),变成 TCP 一样的麻烦。
# 04.共享内存:性能之王
# 4.1 共享内存的物理本质
共享内存是性能最高的 IPC——因为根本没"通信",两个进程在看同一块物理内存:
进程 A 虚拟地址空间 进程 B 虚拟地址空间
┌─────────────────┐ ┌─────────────────┐
│ ... │ │ ... │
│ [shm 区域] │──┐ ┌─│ [shm 区域] │
│ 虚拟地址 0x5000 │ │ │ │ 虚拟地址 0x8000 │
│ ... │ │ │ │ ... │
└─────────────────┘ │ │ └─────────────────┘
▼ ▼
┌──────────────────┐
│ 同一块物理内存 │
│ 物理地址 0x100M │
└──────────────────┘
2
3
4
5
6
7
8
9
10
11
12
关键洞察:A 写入"hello"到自己的虚拟地址 0x5000,就是直接写到物理 0x100M——B 通过自己的虚拟地址 0x8000 也指向同一物理内存,立刻就能看到"hello"。
没有内核拷贝,没有上下文切换,速度就是 RAM 访问速度——纳秒级。
# 4.2 mmap:文件映射的妙用
Linux 创建共享内存最常用的方式是 mmap:
// 进程 A
int fd = shm_open("/myshm", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096);
void* p = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
strcpy(p, "Hello from A");
// 进程 B
int fd = shm_open("/myshm", O_RDWR, 0666);
void* p = mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
printf("%s\n", (char*)p); // "Hello from A"
2
3
4
5
6
7
8
9
10
mmap 的精妙处:把"打开文件"的语义复用为"映射内存"——用熟悉的文件描述符接口,做完全不一样的事。
MAP_SHARED 标志告诉内核"这是共享映射"——多个进程映射同一个文件,内核做的是虚拟地址→同一物理页的映射,自动同步。
# 4.3 同步代价:信号量与互斥锁
共享内存有个致命问题——没有任何同步。两个进程同时写同一块内存就是数据竞争:
// 进程 A 和进程 B 都跑这段代码
shared->counter++; // 不是原子的!
// 可能 A 读了 0,B 也读了 0,各自 +1 写回,最终是 1 而不是 2
2
3
必须配合信号量或互斥锁做同步:
sem_t* sem = sem_open("/mysem", O_CREAT, 0666, 1); // 初值 1
// 临界区
sem_wait(sem);
shared->counter++;
sem_post(sem);
2
3
4
5
6
这就是共享内存的工程现实:性能极高,但同步要程序员自己背。一旦写错,竞态条件极难调试。
# 4.4 共享内存的真实陷阱
# 陷阱一:忘记反初始化
shm_open 创建的对象在内核里持久存在——
进程退出不会自动清理
下次启动会发现 "已经存在" 错误
2
3
要在退出时调 shm_unlink("/myshm") 显式清理,否则机器重启前都不会消失。
# 陷阱二:地址不一样,指针失效
struct Node {
Node* next; // ★ 这是个指针
int value;
};
// 进程 A 写入:
shared->next = malloc(...); // A 的虚拟地址,对 B 无效
// 进程 B 读取:
Node* p = shared->next; // 拿到 A 的地址,访问就是 segfault
2
3
4
5
6
7
8
9
10
修法:共享内存里只能存"偏移量"或"基于共享内存基址的相对指针",不能存绝对地址。
# 陷阱三:跨进程对象的析构
shared->str = string("hello"); // string 内部有指向堆的指针
// 那个堆在 A 进程,B 看不到
2
修法:共享内存里只放"扁平数据"(POD),不放有指针的对象。或者用 boost::interprocess 提供的特殊容器。
# 05.Socket:万能但偏重的 IPC
# 5.1 Unix Domain Socket:本机走socket
Socket 不只是网络通信——本机进程间通信也能用 Unix Domain Socket(UDS):
// 服务端
int srv = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = { .sun_family = AF_UNIX };
strcpy(addr.sun_path, "/tmp/myapp.sock");
bind(srv, (struct sockaddr*)&addr, sizeof(addr));
listen(srv, 5);
int conn = accept(srv, NULL, NULL);
read(conn, buf, sizeof(buf));
// 客户端
int sock = socket(AF_UNIX, SOCK_STREAM, 0);
connect(sock, (struct sockaddr*)&addr, sizeof(addr));
write(sock, "hello", 5);
2
3
4
5
6
7
8
9
10
11
12
13
UDS 的优势:
- API 和 TCP socket 完全一样,便于切换(本地 → 网络)
- 性能远高于 TCP socket(不走网络栈)
- 支持传递文件描述符(
SCM_RIGHTS)—— Android Binder 也用这个传 fd
Docker、X11、Nginx-PHP 之间的通信都是 UDS——是工程界最广泛使用的本机 IPC 之一。
# 5.2 网络 Socket:跨主机的代价
跨主机就要用 TCP/UDP socket——但代价显著:
本机 UDS: ~5 μs/次(仅内核拷贝)
本机 TCP: ~30 μs/次(走 TCP 栈,但没出网卡)
跨主机 TCP: ~500 μs - 数 ms(取决于网络)
2
3
为什么本机 TCP 也比 UDS 慢 6 倍? 因为 TCP socket 走完整的 TCP 协议栈:分片、checksum、滑动窗口、ACK——这些都是为不可靠网络设计的,在本机上是浪费。
# 5.3 Socket 的拷贝路径
普通 Socket 的数据拷贝路径:
进程 A 用户态 buf ─① 拷贝到内核 socket buf (写)
内核 socket buf ─② 拷贝到进程 B 内核 socket buf (路由)
进程 B 内核 buf ─③ 拷贝到用户态 buf (读)
2
3
3 次拷贝——这是 Socket 性能不如共享内存的根本原因。
sendfile() 系统调用可以省一次拷贝(用于文件 → socket 的特殊场景),现代内核还有 splice()、zero-copy networking 等优化,但都需要特殊用法。
# 06.Binder:Android的天才设计
# 6.1 Android为何不用现成IPC
2007 年 Android 团队设计 IPC 时,面对的现实是:
- Linux 已有 5+ 种 IPC(管道、Socket、shm、信号、消息队列)
- 每一种都不完美:管道单向、Socket 慢、shm 不安全、信号载荷小
Android 的需求很特殊:
- 极高频:每个 App 启动都要和 system_server 频繁通信(§0 事故就是这个场景)
- 强类型:要能传 Java 对象,不能只传字节
- 安全:要能识别调用方身份(UID/PID)防伪造
- 性能:要支持百 KB 级数据高效传递
现成 IPC 都不能完全满足——所以 Google 基于一个开源项目 OpenBinder 做了魔改,诞生了 Android Binder。
# 6.2 一次拷贝的秘密
Binder 最核心的创新是 mmap 让通信只需一次拷贝:
flowchart LR
subgraph 普通IPC[普通 IPC:2 次拷贝]
A1[进程 A 用户态] -->|拷贝| K1[内核 buf]
K1 -->|拷贝| B1[进程 B 用户态]
end
subgraph Binder[Binder:1 次拷贝]
A2[进程 A 用户态] -->|拷贝| K2[内核 + B 用户态<br/>共享 mmap]
end
2
3
4
5
6
7
8
9
实现原理:
1. 进程 B 启动时,调 ProcessState 把一块虚拟内存(默认 1MB)和内核空间做 mmap 映射
→ B 的用户态地址空间和内核空间共享这 1MB
2. 进程 A 调用 Binder.transact:
A 用户态数据 ─① 拷贝→ 内核(同时也是 B 的用户态映射区)
↓
B 的用户态直接看到,无需再拷贝
2
3
4
5
6
7
§0 事故里的每次 PackageManager 调用,就走这条 1 次拷贝路径——已经是 IPC 中性能最优的方案之一。但即便如此,3-5ms 的成本依然存在(来自上下文切换、调度),在 312 次循环里被放大成灾难。
# 6.3 Binder驱动+SM+Proxy/Stub
Binder 的整体架构:
flowchart TB
subgraph User[用户态]
C[Client App] -->|调用| P[BinderProxy<br/>Stub]
S[Server Service] -->|实现| ST[Binder Stub]
end
subgraph Kernel[内核态]
BD[Binder Driver]
end
subgraph SM[Service Manager]
SMP[服务名 → handle]
end
P -->|ioctl| BD
BD -->|路由| ST
P -.查询.-> SM
S -.注册.-> SM
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
四个关键角色:
| 角色 | 职责 |
|---|---|
| Binder Driver | 内核里的核心,做内存映射 + 进程间路由 |
| Service Manager | "电话簿"——按服务名找到 Binder handle |
| Proxy(客户端代理) | 把 Java 调用打包成数据包,通过 Binder 发出 |
| Stub(服务端骨架) | 收到数据包后解包,调实际方法,返回结果 |
调用流程:
1. App 通过 Context.getSystemService("package") 拿到 PackageManager
→ 实际拿到的是 PackageManagerProxy(客户端代理)
2. App 调 pm.getPackageInfo("com.x", 0)
→ Proxy 把方法名+参数打包成 Parcel
→ 通过 ioctl(BINDER_WRITE_READ) 发给 Binder Driver
3. Binder Driver 路由到 system_server 进程
→ mmap 区直接可见,无需第二次拷贝
4. system_server 的 Stub 解包,调 PackageManagerService.getPackageInfo()
→ 返回结果再走一遍同样的流程
2
3
4
5
6
7
8
9
10
11
12
# 6.4 AIDL:让 IPC 像本地调用
写 Binder 客户端/服务端的 Proxy / Stub 极其繁琐——AIDL(Android Interface Definition Language)是个"代码生成器":
// IMyService.aidl
interface IMyService {
int add(int a, int b);
String hello(String name);
}
2
3
4
5
AIDL 编译器自动生成:
IMyService.java接口IMyService.Stub服务端骨架(你只要继承它实现方法)IMyService.Stub.Proxy客户端代理(自动打包/解包参数)
业务代码:
// 服务端
class MyService extends Service {
private final IBinder binder = new IMyService.Stub() {
@Override
public int add(int a, int b) { return a + b; }
@Override
public String hello(String name) { return "Hi " + name; }
};
@Override
public IBinder onBind(Intent i) { return binder; }
}
// 客户端
IMyService svc = IMyService.Stub.asInterface(binder);
int result = svc.add(1, 2); // 像本地调用一样
2
3
4
5
6
7
8
9
10
11
12
13
14
15
AIDL 的本质是把"序列化 / 跨进程路由 / 反序列化"都隐藏掉——这就是高质量 RPC 框架的核心价值:让程序员心智上忽略"远程"。
但这种隐藏也是 §0 事故的根源——pm.getPackageInfo 看起来像本地调用,让程序员忘记它实际是 IPC。抽象不会消除成本,只会隐藏成本。
# 6.5 Binder边界:1MB限制来历
Binder 的 mmap 区默认 1MB - 8KB(实际可用约 1MB,预留 8KB 给头部),整个进程所有 Binder 调用共用。
传 > 1MB 的数据:抛 TransactionTooLargeException
这是 Android 上传大数据的硬限制。常见踩坑:
- Activity onSaveInstanceState 超 1MB → 崩溃
- ContentProvider 传巨量数据 → 用 ParcelFileDescriptor 改走文件
- 传图:用 Ashmem(匿名共享内存)+ ParcelFileDescriptor
为什么是 1MB? 早期 Android 设备内存只有几十 MB,每个进程的 Binder 区不能太奢侈;1MB 是性能 / 内存 / 实用性的平衡点。这个值现在依然是默认——历史包袱在工程里很难甩掉。
# 07.Mach Port/XPC:苹果优雅方案
# 7.1 Mach Port:内核对象+端口语义
苹果的 IPC 基础是 Mach Port(来自卡内基梅隆 Mach 内核):
端口(Port)= 内核里的一个消息队列
进程持有"端口名"(句柄)来发/收消息
端口有权限控制(send / receive 权限分离)
2
3
flowchart LR
A[进程 A] -->|send 权限| P[端口对象<br/>内核消息队列]
B[进程 B] -->|receive 权限| P
2
3
每个 Mach Task(进程)有自己的 port name space——A 看到的端口名 5 和 B 看到的端口名 5 不一定是同一个端口。这种"端口名空间"设计比 Linux 的全局 fd 更安全。
# 7.2 XPC:进程沙盒的协作之道
iOS App Sandbox 严格隔离应用,但 App 经常要和"系统服务"或"扩展"通信——XPC 是上层 IPC:
NSXPCConnection* conn = [[NSXPCConnection alloc]
initWithServiceName:@"com.example.MyService"];
conn.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(MyProto)];
[conn resume];
id<MyProto> proxy = [conn remoteObjectProxy];
[proxy doSomething];
2
3
4
5
6
7
XPC 的设计哲学:让进程通信像 Objective-C 消息一样自然,但底层走 Mach Port。
特殊价值:XPC Service 是隔离的进程——主进程崩了 XPC Service 不崩,反之亦然。Safari 把每个 Tab 跑成 XPC 子进程,单个网页崩溃不影响整个浏览器。
# 08.信号 / 消息队列 / 信号量
# 8.1 信号:异步通知的最小单位
信号是 Unix 最古老的 IPC 之一——一个进程给另一个进程发个"事件":
kill(target_pid, SIGTERM); // 发信号
// 接收方
signal(SIGTERM, handler);
void handler(int sig) {
cleanup();
exit(0);
}
2
3
4
5
6
7
8
特点:
- 载荷极小(就一个数字)
- 异步:发了就走,不等响应
- 进程必须在用户态才能处理(被信号打断)
- 不可靠:同一信号短时间内多次发送可能合并成一次
典型用法:
kill -9 pid:发 SIGKILL- Ctrl+C:发 SIGINT
- 进程崩溃前清理资源(捕获 SIGSEGV)
信号是"消息",不是"通信"——载荷只有一个 int,要传数据得另想办法。
# 8.2 System V/POSIX消息队列
消息队列是有"消息边界"的管道:
// POSIX 消息队列
mqd_t mq = mq_open("/myq", O_CREAT|O_WRONLY, 0666, NULL);
mq_send(mq, "hello", 5, 0);
// 接收
char buf[1024];
mq_receive(mq, buf, 1024, NULL);
2
3
4
5
6
7
优点:消息有边界(不是字节流),可设优先级,内核持久化(重启后还在)。 缺点:性能不如管道,载荷有上限(默认 8KB)。
实际工业里用得不多——比管道复杂,比 Socket 不灵活,处于"夹缝"中。
# 8.3 信号量:同步而非通信
严格说信号量不是 IPC——它不传数据,只做同步:
sem_t* sem = sem_open("/mysem", O_CREAT, 0666, 0);
// 进程 A:等待
sem_wait(sem); // 阻塞,直到 sem > 0
do_work();
// 进程 B:唤醒
sem_post(sem); // sem++
2
3
4
5
6
7
8
典型用法:和共享内存配合做互斥(§4.3 见过)。
# 09.分布式IPC:单机到集群
# 9.1 RPC本质:远程调用似本地
RPC(Remote Procedure Call)是 IPC 的"分布式版本"——核心抽象是把"在另一台机器上跑代码"包装成方法调用:
client.getUserInfo(123) // 看起来像本地
↓
序列化参数
↓
网络传输
↓
服务端反序列化
↓
真正执行 getUserInfo(123)
↓
序列化结果
↓
网络回传
↓
客户端反序列化
↓
返回值
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
抽象的代价是"本地调用"和"RPC 调用"看起来一样,但成本天差地别——和 §0 事故的 Binder 同源问题。
# 9.2 序列化抉择:JSON/XML/Protobuf
| 序列化 | 大小 | 性能 | 可读 | 跨语言 |
|---|---|---|---|---|
| JSON | 大 | 慢 | 好 | 好 |
| XML | 巨大 | 慢 | 好 | 好 |
| Protobuf | 小 (~10x JSON) | 快 (~5x JSON) | 不好 | 极好 |
| MessagePack | 中 | 中 | 不好 | 中 |
| FlatBuffers | 小 | 极快(零拷贝) | 不好 | 中 |
选型决策:
- Web API:JSON(生态、可读、调试方便)
- 内部服务(gRPC):Protobuf(性能、强类型、版本兼容)
- 游戏 / 实时系统:FlatBuffers(零拷贝读取)
# 9.3 网络异常处理:8 大谬误
L Peter Deutsch 1994 年总结了分布式系统的 8 大谬误,到今天仍每条都在坑人:
- 网络是可靠的 —— 错,会丢包、断连
- 延迟是 0 —— 错,至少几十毫秒
- 带宽无限 —— 错,要省着用
- 网络是安全的 —— 错,要加密、认证
- 拓扑不变 —— 错,IP 会变、节点会下线
- 只有一个管理员 —— 错,跨团队跨公司
- 传输成本是 0 —— 错,云带宽很贵
- 网络是同质的 —— 错,多种协议、多种 ISP
RPC 框架要处理的本质问题就是这 8 条:超时、重试、熔断、限流、降级、全链路追踪、幂等性——每一项都是博士论文级别的复杂度。
# 10.IPC 选型决策与设计哲学
# 10.1 不可能三角的选型决策
带宽 / 吞吐
/ \
/ \
/ IPC \
/ 设计三角\
/ \
/ \
延迟 ────────── 安全
2
3
4
5
6
7
8
| 顶点 | 优化手段 |
|---|---|
| 带宽 | 共享内存、Binder mmap、零拷贝 |
| 延迟 | 异步化、批量、本机优先(UDS > TCP) |
| 安全 | 强类型 RPC、认证、隔离(XPC Service) |
flowchart TD
A[需要 IPC?] --> B{是否同主机?}
B -->|否| C[网络 RPC<br/>gRPC / Thrift]
B -->|是| D{数据量?}
D -->|大 + 高频| E[共享内存 + 同步原语]
D -->|中等结构化| F{平台?}
D -->|小 + 简单| G[管道 / UDS]
F -->|Android| H[Binder / AIDL]
F -->|iOS| I[XPC]
F -->|通用 Linux| J[D-Bus / UDS]
style E fill:#d4edda
style H fill:#d1ecf1
style I fill:#d1ecf1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 10.2 实战决策清单
问 1:性能瓶颈在哪?
├─ IPC 调用次数 → 减少调用(批量、合并)★ §0 事故的修法
├─ 单次拷贝大 → 换共享内存 / 零拷贝
└─ 上下文切换多 → 异步化,主线程不等
问 2:需要多大数据?
├─ < 1KB → 任何 IPC 都行
├─ 1KB - 1MB → Binder / Socket / 管道
├─ 1MB - 100MB → 共享内存 / Ashmem
└─ > 100MB → 文件 + ParcelFileDescriptor 传 fd
问 3:需要双向通信吗?
├─ 单向通知 → 信号 / 广播 / 单向 Socket
├─ 双向请求-响应 → Binder / RPC
└─ 双向流式 → 全双工 Socket / gRPC stream
问 4:跨语言/跨平台吗?
├─ 同语言同平台 → 平台原生(Binder / XPC)
├─ 跨语言同主机 → UDS + Protobuf
└─ 跨语言跨主机 → gRPC / Thrift
问 5:性能是不是关键?
├─ 极致延迟(< 1μs) → 共享内存 + lock-free
├─ 一般业务 → 平台默认 IPC
└─ 不太关心 → 用最简单的(管道、HTTP)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 10.3 经典陷阱与回应
# 陷阱一:循环里调 IPC(§0 事故)
for (item : items) {
ipc.process(item); // ★ 每次 IPC 3-5ms
}
2
3
修法:批量化。ipc.processBatch(items) 一次搞定。
# 陷阱二:主线程同步 IPC
String data = pm.getPackageInfo(...); // 主线程阻塞
修法:异步化。CompletableFuture.supplyAsync(...) 或 Coroutine。
# 陷阱三:以为"同进程通信"零成本
class MyService : Service() {
override fun onBind(intent: Intent): IBinder {
return MyBinder() // ★ 即便 Service 在同一进程,也是 IBinder 调用
}
}
2
3
4
5
真相:local Binder 在同进程内是直接 Java 调用,零成本——但你不能假设 service 永远在同进程。配置改成 android:process=":remote" 立刻变跨进程。
# 陷阱四:Binder 1MB 限制
intent.putExtra("data", largeBitmap) // 100MB 的 bitmap
startActivity(intent) // ★ 崩 TransactionTooLargeException
2
修法:传 URI(content://),让接收端通过 ContentResolver 自己读;或用 ParcelFileDescriptor 传文件描述符。
# 陷阱五:网络 RPC 不处理超时
String result = remoteService.call(); // 默认无限等
修法:所有 RPC 必须有超时;超时后要决定重试 / 降级 / 失败。
# 10.4 总结:IPC的设计哲学
# 三层认知阶梯
| 阶段 | 思维 | 表现 |
|---|---|---|
| 初级 | "调用一个方法而已" | 写出 §0 那种 312 次循环代码 |
| 中级 | "知道 IPC 慢,但不知道怎么慢" | 能改 bug,但选型靠经验 |
| 高级 | "按数据量、频率、安全度选最合适的 IPC,并能解释为什么" | 架构师 |
# 与本卷其它章节的呼应
40.窗口核心设计思想 ─→ WindowManager.addView 是 Binder 调用,事故触发点
44.消息机制设计思想 ─→ Handler 是单进程版的 IPC(线程间通信)
03.第3卷-并发之道 ─→ 共享内存的同步是并发问题的延伸
04.第4卷-内存的真相 ─→ mmap 是 IPC 和虚拟内存的交集
07.类的加载核心原理 ─→ 跨进程类的对应靠序列化解决
2
3
4
5
# 设计哲学一句话
IPC 是程序员和 OS 之间的契约——OS 用进程隔离换来安全和稳定,程序员用 IPC 在隔离上凿门换协作。每一种 IPC 都是某种"凿门方式"——管道凿了一条字节流的细缝,共享内存凿了一扇大门但没装锁,Binder 凿了一扇带身份证检查的智能门。
§0 事故的本质是把"凿门"当成了"开门"——以为跨进程调用和本地调用一样轻松。当你能"看见"每次 IPC 背后的内核切换和拷贝时,你才真正驾驭了 IPC。
好的 IPC 设计,让程序员"忘记"它是 IPC(开发体验);好的程序员,永远"记得"它是 IPC(性能直觉)。这个矛盾贯穿了从 1973 年管道到 2024 年 io_uring 的所有 IPC 演进——它就是这个领域的灵魂。
# 延伸阅读
- 论文:The UNIX Time-Sharing System (Ritchie & Thompson, 1974)
- 书籍:《UNIX 网络编程·卷 2:进程间通信》(W. Richard Stevens)
- 文档:Android Binder 设计与实现 (opens new window)
- 源码:Linux
ipc/、Androidframeworks/native/libs/binder/ - 工具:strace(跟踪 IPC 系统调用)、Systrace(Binder 可视化)、
bpftrace(内核态跟踪)