编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • 性能优化实践

  • 程序编程原理

    • README
    • 序卷方法论

    • 数据的本质

    • 运行时模型

    • 并发的设计

    • 内存的真相

    • 交互和系统

      • README
      • 1.窗口核心设计思想
      • 2.视图加载渲染设计
      • 3.图形渲染管线原理
      • 4.手势事件设计灵魂
      • 5.消息机制设计思想
      • 6.跨进程通信设计
        • 00.一次App启动8秒事故说起
          • 0.1 双十一前的性能告警
          • 0.2 用 Systrace 看 IPC 风暴
          • 0.3 老司机的灵魂三问
          • 0.4 这次事故揭示了什么
          • 0.5 五个层层递进的追问
        • 01.为何需IPC:进程隔离代价
          • 1.1 1960 年代:单进程的悲剧
          • 1.2 进程隔离的代价
          • 1.3 IPC 设计的三个核心矛盾
          • 矛盾一:性能 vs 安全
          • 矛盾二:易用性 vs 灵活性
          • 矛盾三:吞吐 vs 延迟
        • 02.IPC 的范式分类
          • 2.1 共享内存派 vs 消息传递派
          • 2.2 同步 vs 异步
          • 2.3 数据流向:单向 vs 双向
        • 03.管道:最古老的 IPC
          • 3.1 匿名管道:Shell 的灵魂
          • 3.2 命名管道:进程间也能用
          • 3.3 管道内核实现:环形缓冲区
          • 3.4 管道的硬伤
        • 04.共享内存:性能之王
          • 4.1 共享内存的物理本质
          • 4.2 mmap:文件映射的妙用
          • 4.3 同步代价:信号量与互斥锁
          • 4.4 共享内存的真实陷阱
          • 陷阱一:忘记反初始化
          • 陷阱二:地址不一样,指针失效
          • 陷阱三:跨进程对象的析构
        • 05.Socket:万能但偏重的 IPC
          • 5.1 Unix Domain Socket:本机走socket
          • 5.2 网络 Socket:跨主机的代价
          • 5.3 Socket 的拷贝路径
        • 06.Binder:Android的天才设计
          • 6.1 Android为何不用现成IPC
          • 6.2 一次拷贝的秘密
          • 6.3 Binder驱动+SM+Proxy/Stub
          • 6.4 AIDL:让 IPC 像本地调用
          • 6.5 Binder边界:1MB限制来历
        • 07.Mach Port/XPC:苹果优雅方案
          • 7.1 Mach Port:内核对象+端口语义
          • 7.2 XPC:进程沙盒的协作之道
        • 08.信号 / 消息队列 / 信号量
          • 8.1 信号:异步通知的最小单位
          • 8.2 System V/POSIX消息队列
          • 8.3 信号量:同步而非通信
        • 09.分布式IPC:单机到集群
          • 9.1 RPC本质:远程调用似本地
          • 9.2 序列化抉择:JSON/XML/Protobuf
          • 9.3 网络异常处理:8 大谬误
        • 10.IPC 选型决策与设计哲学
          • 10.1 不可能三角的选型决策
          • 10.2 实战决策清单
          • 10.3 经典陷阱与回应
          • 陷阱一:循环里调 IPC(§0 事故)
          • 陷阱二:主线程同步 IPC
          • 陷阱三:以为"同进程通信"零成本
          • 陷阱四:Binder 1MB 限制
          • 陷阱五:网络 RPC 不处理超时
          • 10.4 总结:IPC的设计哲学
          • 三层认知阶梯
          • 与本卷其它章节的呼应
          • 设计哲学一句话
          • 延伸阅读
      • 7.数据加密和解密
  • 稳定性与可靠性

  • 工程化与运维

  • 方案设计思想

  • 专栏
  • 程序编程原理
  • 交互和系统
杨充
2026-05-14
目录

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
1
2
3
4
5
6
7
8
9

# 目录介绍

  • 00.一次App启动8秒事故说起
    • 0.1 双十一前的性能告警
    • 0.2 用 Systrace 看 IPC 风暴
    • 0.3 老司机的灵魂三问
    • 0.4 这次事故揭示了什么
    • 0.5 五个层层递进的追问
  • 01.为何需IPC:进程隔离代价
    • 1.1 1960 年代:单进程的悲剧
    • 1.2 进程隔离的代价
    • 1.3 IPC 设计的三个核心矛盾
  • 02.IPC 的范式分类
    • 2.1 共享内存派 vs 消息传递派
    • 2.2 同步 vs 异步
    • 2.3 数据流向:单向 vs 双向
  • 03.管道:最古老的 IPC
    • 3.1 匿名管道:Shell 的灵魂
    • 3.2 命名管道:进程间也能用
    • 3.3 管道内核实现:环形缓冲区
    • 3.4 管道的硬伤
  • 04.共享内存:性能之王
    • 4.1 共享内存的物理本质
    • 4.2 mmap:文件映射的妙用
    • 4.3 同步代价:信号量与互斥锁
    • 4.4 共享内存的真实陷阱
  • 05.Socket:万能但偏重的 IPC
    • 5.1 Unix Domain Socket:本机走socket
    • 5.2 网络 Socket:跨主机的代价
    • 5.3 Socket 的拷贝路径
  • 06.Binder:Android的天才设计
    • 6.1 Android为何不用现成IPC
    • 6.2 一次拷贝的秘密
    • 6.3 Binder驱动+SM+Proxy/Stub
    • 6.4 AIDL:让 IPC 像本地调用
    • 6.5 Binder边界:1MB限制来历
  • 07.Mach Port/XPC:苹果优雅方案
    • 7.1 Mach Port:内核对象+端口语义
    • 7.2 XPC:进程沙盒的协作之道
  • 08.信号 / 消息队列 / 信号量
    • 8.1 信号:异步通知的最小单位
    • 8.2 System V/POSIX消息队列
    • 8.3 信号量:同步而非通信
  • 09.分布式IPC:单机到集群
    • 9.1 RPC本质:远程调用似本地
    • 9.2 序列化抉择:JSON/XML/Protobuf
    • 9.3 网络异常处理:8 大谬误
  • 10.IPC 选型决策与设计哲学
    • 10.1 不可能三角的选型决策
    • 10.2 实战决策清单
    • 10.3 经典陷阱与回应
    • 10.4 总结:IPC的设计哲学

# 00.一次App启动8秒事故说起

# 0.1 双十一前的性能告警

某头部电商 App,2022 年 10 月底大促前压测,性能团队跑出一份报告:

冷启动时间:3.2 秒(基线)→ 8.1 秒(当前版本)★ 退化 153%
低端机(Android 8,2GB RAM):12.4 秒 → 用户大概率直接卸载
1
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 秒
1
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) {}
        }
    }
}
1
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 次。
1
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 外的循环"。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

问题 3:为什么这种问题在内部测试时没暴露?

工程师:因为开发机性能强,IPC 也快。
架构师:本质问题是——同步 IPC 是"性能埋雷"。
       一次调用 3ms 你完全感觉不到,但循环 312 次就 1 秒。
       开发机 CPU 快、上下文切换快、内存大、调度延迟低,
       同样的代码在低端机上能慢 10 倍。
       
       所以 IPC 性能必须看"调用次数 × 单次成本",
       而不是"看着挺快就行"。
1
2
3
4
5
6
7
8

# 0.4 这次事故揭示了什么

事故的本质,不是反作弊 SDK 写错了,而是研发对"IPC 成本"缺乏直觉:

我以为:
  pm.getPackageInfo() 就是一个普通的方法调用,写起来和 list.get(i) 没区别

实际:
  这一行代码背后是 ——
    用户态 → 内核态 → 进程切换 → system_server 处理 → 切换回来
  这是一次"跨越 OS 隔离边界"的旅行,
  比本地方法慢 1000-10000 倍
1
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)只跑一个程序:

┌─────────────────────────────────┐
│       整台机器的内存             │
│ ┌─────────────────────────────┐ │
│ │  程序代码 + 数据 + 栈 + 堆    │ │
│ │  全部混在一起                │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────┘
1
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  ┘  各自独立
1
2
3
4
5
6
7
8

A 要把数据传给 B,直接传地址没用——A 的 0x1000 在 B 那里指向另一块内存。必须通过内核做中介。

这就是 IPC 的根本结构:

进程 A ──→ 内核(IPC 机制)──→ 进程 B
        ↑                  ↑
     用户/内核 切换        用户/内核 切换
1
2
3

每一次 IPC 至少需要:

  1. 进程 A 陷入内核态(系统调用)
  2. 内核处理(拷贝/路由/调度)
  3. 进程 B 被唤醒,从内核态返回用户态
  4. 上下文切换 ×2(A 出 CPU,B 上 CPU)

这就是为什么 §0 事故的 312 次 IPC 累计 1.2 秒——每一次都要走这套流程。

# 1.3 IPC 设计的三个核心矛盾

所有 IPC 设计都在三个矛盾上做取舍:

# 矛盾一:性能 vs 安全

共享内存最快(无拷贝),但失去了进程隔离的安全性
消息传递最安全(数据复制,互不影响),但有拷贝开销
1
2

# 矛盾二:易用性 vs 灵活性

RPC 让远程调用像本地调用,但隐藏了网络异常和延迟
原始 Socket 完全暴露异常,但每次都要写很多 boilerplate
1
2

# 矛盾三:吞吐 vs 延迟

批量 IPC 吞吐高,但首条消息延迟高
单条 IPC 延迟低,但 QPS 上限低
1
2

§0 事故是同时踩了三个坑:用同步 IPC(高延迟)、单条调用(低吞吐)、看不到成本(易用性陷阱)。


# 02.IPC 的范式分类

# 2.1 共享内存派 vs 消息传递派

按"数据是否经过拷贝",IPC 分两大派系:

flowchart LR
    subgraph 共享内存派[共享内存派]
        SA[进程 A] -.同一块物理内存.- SB[进程 B]
    end
    subgraph 消息传递派[消息传递派]
        MA[进程 A] -->|拷贝| K[内核] -->|拷贝| MB[进程 B]
    end
1
2
3
4
5
6
7
派系 代表 优点 缺点
共享内存 shm / mmap / Binder mmap 区 零或一次拷贝,性能最高 需要锁,复杂;安全弱
消息传递 管道 / Socket / 消息队列 解耦,安全 多次拷贝,性能差

精妙的中间派——Binder:用 mmap 实现"内核到用户态零拷贝",但接口看起来是消息传递。用最简单的接口,达到最优的性能——这就是工程艺术。

# 2.2 同步 vs 异步

同步 IPC:发起者阻塞等待结果(如 Binder.transact / RPC)
异步 IPC:发起者发完就走,结果通过回调/Future 拿(如管道写)
1
2

§0 事故的 312 次 IPC 是同步的——主线程必须等每次返回。如果改成异步(如发到 Handler):

// 异步改造
executor.submit(() -> {
    for (String pkg : KNOWN_RISKY_PACKAGES) {
        // ... IPC 调用,但不在主线程
    }
    return result;
}).thenAccept(result -> handleResult(result));
1
2
3
4
5
6
7

主线程立刻返回,IPC 在后台慢慢做——这是降低 IPC 影响的标准手段。

# 2.3 数据流向:单向 vs 双向

单向(信号、广播):     A ──→ B
双向半双工(管道):     A ←→ B(一次只能一方说)
双向全双工(Socket):   A ⇄ B(双方同时说)
1
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
       ↑           ↑                  ↑
       管道        管道                管道
1
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);
}
1
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);
1
2
3
4
5
6
7
8
9

通过文件系统里的一个特殊文件作为"会面点"。它就是 Linux 一切皆文件哲学的体现——IPC 的端点也是文件。

# 3.3 管道内核实现:环形缓冲区

管道在内核里是一个固定大小的环形缓冲区(Linux 默认 64KB):

                    ┌─────────────────┐
   read fd  ←──────│   环形缓冲区     │←────── write fd
                    │  容量 64KB       │
                    │  read_pos        │
                    │  write_pos       │
                    └─────────────────┘
1
2
3
4
5
6

写满了怎么办? 写阻塞——直到读端腾出空间。 读空了怎么办? 读阻塞——直到写端写入。

这就是管道的天然背压:消费者跟不上生产者,生产者会自动减速。

# 3.4 管道的硬伤

管道虽然简单优雅,但限制不少:

  1. 半双工:一根管道只能单向流,要双向得开两根
  2. 字节流(无消息边界):写 "AAA" 和 "BBB",读端可能一次读到 "AAABBB",分不清
  3. 缓冲区固定:超大数据要分块发送
  4. 本机限制:只能本机用,跨机要用 Socket

字节流问题是个隐藏雷——业务往往要在管道之上自己加"消息边界协议"(比如先写长度再写内容),变成 TCP 一样的麻烦。


# 04.共享内存:性能之王

# 4.1 共享内存的物理本质

共享内存是性能最高的 IPC——因为根本没"通信",两个进程在看同一块物理内存:

   进程 A 虚拟地址空间          进程 B 虚拟地址空间
   ┌─────────────────┐          ┌─────────────────┐
   │  ...            │          │  ...            │
   │  [shm 区域]      │──┐    ┌─│  [shm 区域]      │
   │  虚拟地址 0x5000 │  │    │ │  虚拟地址 0x8000 │
   │  ...            │  │    │ │  ...            │
   └─────────────────┘  │    │ └─────────────────┘
                        ▼    ▼
                  ┌──────────────────┐
                  │   同一块物理内存   │
                  │   物理地址 0x100M │
                  └──────────────────┘
1
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"
1
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
1
2
3

必须配合信号量或互斥锁做同步:

sem_t* sem = sem_open("/mysem", O_CREAT, 0666, 1);   // 初值 1

// 临界区
sem_wait(sem);
shared->counter++;
sem_post(sem);
1
2
3
4
5
6

这就是共享内存的工程现实:性能极高,但同步要程序员自己背。一旦写错,竞态条件极难调试。

# 4.4 共享内存的真实陷阱

# 陷阱一:忘记反初始化

shm_open 创建的对象在内核里持久存在——
进程退出不会自动清理
下次启动会发现 "已经存在" 错误
1
2
3

要在退出时调 shm_unlink("/myshm") 显式清理,否则机器重启前都不会消失。

# 陷阱二:地址不一样,指针失效

struct Node {
    Node* next;   // ★ 这是个指针
    int value;
};

// 进程 A 写入:
shared->next = malloc(...);   // A 的虚拟地址,对 B 无效

// 进程 B 读取:
Node* p = shared->next;       // 拿到 A 的地址,访问就是 segfault
1
2
3
4
5
6
7
8
9
10

修法:共享内存里只能存"偏移量"或"基于共享内存基址的相对指针",不能存绝对地址。

# 陷阱三:跨进程对象的析构

shared->str = string("hello");   // string 内部有指向堆的指针
                                 // 那个堆在 A 进程,B 看不到
1
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);
1
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(取决于网络)
1
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 (读)
1
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
1
2
3
4
5
6
7
8
9

实现原理:

1. 进程 B 启动时,调 ProcessState 把一块虚拟内存(默认 1MB)和内核空间做 mmap 映射
   → B 的用户态地址空间和内核空间共享这 1MB
   
2. 进程 A 调用 Binder.transact:
   A 用户态数据 ─① 拷贝→ 内核(同时也是 B 的用户态映射区)
                                          ↓
                                          B 的用户态直接看到,无需再拷贝
1
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
1
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()
   → 返回结果再走一遍同样的流程
1
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);
}
1
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);   // 像本地调用一样
1
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
1

这是 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 权限分离)
1
2
3
flowchart LR
    A[进程 A] -->|send 权限| P[端口对象<br/>内核消息队列]
    B[进程 B] -->|receive 权限| P
1
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];
1
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);
}
1
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);
1
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++
1
2
3
4
5
6
7
8

典型用法:和共享内存配合做互斥(§4.3 见过)。


# 09.分布式IPC:单机到集群

# 9.1 RPC本质:远程调用似本地

RPC(Remote Procedure Call)是 IPC 的"分布式版本"——核心抽象是把"在另一台机器上跑代码"包装成方法调用:

client.getUserInfo(123)   // 看起来像本地
       ↓
       序列化参数
       ↓
       网络传输
       ↓
       服务端反序列化
       ↓
       真正执行 getUserInfo(123)
       ↓
       序列化结果
       ↓
       网络回传
       ↓
       客户端反序列化
       ↓
       返回值
1
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 大谬误,到今天仍每条都在坑人:

  1. 网络是可靠的 —— 错,会丢包、断连
  2. 延迟是 0 —— 错,至少几十毫秒
  3. 带宽无限 —— 错,要省着用
  4. 网络是安全的 —— 错,要加密、认证
  5. 拓扑不变 —— 错,IP 会变、节点会下线
  6. 只有一个管理员 —— 错,跨团队跨公司
  7. 传输成本是 0 —— 错,云带宽很贵
  8. 网络是同质的 —— 错,多种协议、多种 ISP

RPC 框架要处理的本质问题就是这 8 条:超时、重试、熔断、限流、降级、全链路追踪、幂等性——每一项都是博士论文级别的复杂度。


# 10.IPC 选型决策与设计哲学

# 10.1 不可能三角的选型决策

        带宽 / 吞吐
          /    \
         /      \
        /  IPC   \
       /  设计三角\
      /          \
     /            \
   延迟 ────────── 安全
1
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
1
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)
1
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
}
1
2
3

修法:批量化。ipc.processBatch(items) 一次搞定。

# 陷阱二:主线程同步 IPC

String data = pm.getPackageInfo(...);   // 主线程阻塞
1

修法:异步化。CompletableFuture.supplyAsync(...) 或 Coroutine。

# 陷阱三:以为"同进程通信"零成本

class MyService : Service() {
    override fun onBind(intent: Intent): IBinder {
        return MyBinder()   // ★ 即便 Service 在同一进程,也是 IBinder 调用
    }
}
1
2
3
4
5

真相:local Binder 在同进程内是直接 Java 调用,零成本——但你不能假设 service 永远在同进程。配置改成 android:process=":remote" 立刻变跨进程。

# 陷阱四:Binder 1MB 限制

intent.putExtra("data", largeBitmap)   // 100MB 的 bitmap
startActivity(intent)                  // ★ 崩 TransactionTooLargeException
1
2

修法:传 URI(content://),让接收端通过 ContentResolver 自己读;或用 ParcelFileDescriptor 传文件描述符。

# 陷阱五:网络 RPC 不处理超时

String result = remoteService.call();   // 默认无限等
1

修法:所有 RPC 必须有超时;超时后要决定重试 / 降级 / 失败。

# 10.4 总结:IPC的设计哲学

# 三层认知阶梯

阶段 思维 表现
初级 "调用一个方法而已" 写出 §0 那种 312 次循环代码
中级 "知道 IPC 慢,但不知道怎么慢" 能改 bug,但选型靠经验
高级 "按数据量、频率、安全度选最合适的 IPC,并能解释为什么" 架构师

# 与本卷其它章节的呼应

40.窗口核心设计思想     ─→ WindowManager.addView 是 Binder 调用,事故触发点
44.消息机制设计思想     ─→ Handler 是单进程版的 IPC(线程间通信)
03.第3卷-并发之道       ─→ 共享内存的同步是并发问题的延伸
04.第4卷-内存的真相     ─→ mmap 是 IPC 和虚拟内存的交集
07.类的加载核心原理     ─→ 跨进程类的对应靠序列化解决
1
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/、Android frameworks/native/libs/binder/
  • 工具:strace(跟踪 IPC 系统调用)、Systrace(Binder 可视化)、bpftrace(内核态跟踪)
上次更新: 2026/06/07, 10:26:12
5.消息机制设计思想
7.数据加密和解密

← 5.消息机制设计思想 7.数据加密和解密→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式