| title | 进程间通信(IPC)详解:管道、消息队列、共享内存、Socket 与 Binder | |||||||
|---|---|---|---|---|---|---|---|---|
| description | 进程间通信高频知识点总结,从进程地址空间隔离讲起,讲清管道、消息队列、共享内存、信号量、信号、Socket、Android Binder 和微内核 IPC 的设计取舍。 | |||||||
| category | 计算机基础 | |||||||
| tag |
|
|||||||
| head |
|
两个进程想交换一段数据,最直觉的想法是:A 进程把数据写到自己的内存里,然后 B 进程直接去读就行了。
不过,这在操作系统里行不通。每个进程都有独立的虚拟地址空间,A 进程里的 0x7f... 地址和 B 进程里的 0x7f... 地址并不是同一块内存。用户态进程之间不能随便互相摸内存,否则权限隔离也没法谈。
所以 IPC(Inter-Process Communication,进程间通信) 绕不开操作系统。
不要想得太复杂,我更习惯把 IPC 看成三件事:怎么传数据、怎么同步控制流、怎么做命名和权限检查。只记“管道、消息队列、共享内存”这些名字,很容易背完就忘。
IPC 先解决数据怎么过去。 管道和字节流 Socket 传连续字节,消息边界由应用约定;消息队列、数据报 Socket、Binder 事务传一条条消息,天然有边界;共享内存让多个进程映射同一块物理内存,映射完成后读写共享区域不需要每次陷入内核。
它还要解决同步。 共享内存只解决“看见同一份数据”,不解决“谁先写、谁后读”。多个进程同时改同一个环形队列,如果没有互斥锁、信号量、futex 或条件变量,数据很快就会乱。
命名和权限也不能少。 匿名管道靠 fork 后继承文件描述符建立关系;FIFO 靠文件系统路径;System V IPC 靠 key 和内核对象 ID;Unix Domain Socket 可以绑定路径,也可以用 Linux 的 abstract namespace;Android Binder 借助 Service Manager 把服务名映射到 Binder 引用。
管道(pipe)是最容易遇到的 IPC。Shell 里的 ps aux | grep java,中间那个 | 就是把前一个进程的标准输出接到后一个进程的标准输入。
Linux 里调用 pipe() 会得到两个文件描述符:一个读端,一个写端。父进程创建管道后再 fork(),子进程会继承这些文件描述符,于是父子进程就能靠同一条管道传数据。匿名管道没有名字,通常用于有亲缘关系的进程之间。
管道是单向字节流。POSIX 只要求它单向,双向通信通常建两条管道;它不理解消息边界,写端写了 3 次,读端不一定也读 3 次;缓冲区在内核里,写满后阻塞写会睡眠,非阻塞写可能返回 EAGAIN;它也不是普通文件,不能用 lseek() 随机定位。
Linux 上还有一个容易被问到的数字:PIPE_BUF 是 4096 字节。对管道或 FIFO 来说,单次 write() 不超过 PIPE_BUF 时,内核保证这次写入不会和其他写者的数据交错;阻塞模式下可能等待缓冲区空间,非阻塞模式下如果空间不足会返回 EAGAIN。超过 PIPE_BUF 的写入可能被拆分,也可能和其他写者的数据交错。这个保证不代表管道有消息边界。
命名管道(FIFO)用 mkfifo 在文件系统里创建一个特殊文件,两个无亲缘关系的进程按路径打开它就能通信。FIFO 有路径名,但数据并不是写进磁盘;路径只是命名入口,真正的数据仍在内核缓冲区里。
管道适合命令行工具串联、父子进程传少量数据。它不适合复杂协议和大对象传输。真要在字节流上做长度前缀、校验和、序列号,很多场景下不如换 Socket 或消息队列。
消息队列把数据拆成一条条消息存到内核对象里。发送方调用 msgsnd() 或 mq_send() 把消息放进队列,接收方调用 msgrcv() 或 mq_receive() 取出来。System V 消息队列和 POSIX 消息队列接口不同,但都属于“内核持有队列,进程按消息读写”的方案。
和管道相比,消息队列最直接的好处是消息有边界。System V 消息队列支持按消息类型接收;POSIX 消息队列支持优先级,Linux 上 sysconf(_SC_MQ_PRIO_MAX) 常见返回值是 32768,而 POSIX 标准只要求至少支持 0 到 31 这个范围。
代价也很直接:发送时,应用缓冲区的数据被拷进内核队列;接收时,再从内核队列拷回接收进程的用户缓冲区。队列本身也受内核参数限制,比如 POSIX 消息队列有 /proc/sys/fs/mqueue/msg_max、msgsize_max 等限制项。
所以消息队列适合传结构化小消息,比如任务通知、状态事件、控制命令。它不适合传大块图片、音视频帧或超大的序列化对象。Linux IPC 里的消息队列也不是 Kafka、RocketMQ 那种消息中间件,没有持久化日志、消费组和跨机器复制。
共享内存的思路很直接:让多个进程把同一块物理内存映射到各自的虚拟地址空间里。映射建立之后,A 进程写这块内存,B 进程就能读到更新。
Linux 上常见两类接口:System V 共享内存用 shmget()、shmat()、shmdt()、shmctl();POSIX 共享内存用 shm_open() 创建对象,ftruncate() 设置大小,再用 mmap() 映射到进程地址空间。
共享内存快在数据路径短。管道、消息队列、Socket 这类方式通常要把数据先交给内核,再由内核交给另一个进程;共享内存完成映射后,进程读写的是同一片物理页,数据本身不用每次都在用户态和内核态之间搬来搬去。日志采集、音视频处理、数据库缓存这类本机大块数据交换场景,才比较适合把它拿出来用。
不过,映射同一块内存只解决“能不能看见”的问题,不解决“什么时候能读”和“谁可以写”的问题。
以共享环形队列为例,生产者通常会写数据、更新 tail,消费者根据 head 和 tail 判断有没有新数据。如果生产者还没把一条记录写完整,就提前更新了 tail,消费者可能马上读到一条半成品。这个问题不能靠共享内存自己解决,需要把写入顺序、可见性和唤醒机制一起设计好:简单一点可以用进程间互斥锁、POSIX 信号量;追求性能时可能会用 eventfd、futex、原子变量和内存屏障。
还有一个细节很容易踩坑:共享内存里别直接放进程内指针。同一块共享内存在 A 进程里可能映射到 0x7000...,在 B 进程里可能映射到 0x5000...,A 写进去的地址,B 拿到后大概率没有意义。工程里更常见的写法是保存偏移量、数组下标,或者一开始就约定好固定布局结构。
所以共享内存不能只看拷贝次数。整体性能还会受缓存一致性、锁竞争、内存屏障、唤醒机制和数据布局影响。它适合数据量大、双方都在本机、并且愿意认真处理同步和内存布局的场景;如果只是传几个状态字段或一条控制命令,消息队列、管道、Unix Domain Socket 反而更省心。
信号量(semaphore)经常和共享内存一起出现,但它不负责传业务数据。它更像一个计数器,用来控制有多少进程可以进入某段临界区,或者通知对方“现在有数据可读”。POSIX 信号量可以是命名的,也可以是未命名的;sem_post() 会把计数加一,sem_wait() 会尝试把计数减一,计数为 0 时调用方会阻塞等待。
信号(signal)更像异步事件通知:SIGINT 表示终端中断,SIGTERM 表示请求进程退出,SIGCHLD 表示子进程状态变化。Linux 支持标准信号和实时信号。信号能携带的信息少,处理函数也受 async-signal-safe 限制,生产代码里常让 signal handler 只修改 volatile sig_atomic_t 标志位,或者通过 async-signal-safe 的 write(2) 往 self-pipe 写一个字节、往 eventfd 写一个 uint64_t 计数值,再由主循环统一处理。
Socket 不只用于网络通信,也能做本机 IPC。
如果两个进程在不同机器上,基本就得走 TCP/UDP 这类网络 Socket。如果两个进程在同一台机器上,可以用 Unix Domain Socket。它的地址族是 AF_UNIX 或 AF_LOCAL,支持 SOCK_STREAM、SOCK_DGRAM、SOCK_SEQPACKET 等类型。
Unix Domain Socket 的接口接近网络 Socket,支持无亲缘关系进程通信;Linux 下既可以绑定文件系统路径,也可以使用 abstract namespace。它还可以借助 sendmsg() 的辅助数据和 SCM_RIGHTS 传文件描述符。对端身份这块,连接型 Unix Socket 常用 SO_PEERCRED 获取 pid、uid、gid;数据报场景也可以结合 SO_PASSCRED 和 SCM_CREDENTIALS 让凭据随消息传过来。
如果问“管道和 Unix Domain Socket 怎么选”,可以这样答:父子进程之间的简单字节流,用管道就够;要双向通信、请求响应、传 fd、服务端监听,Unix Domain Socket 更合适;要跨机器,换 TCP/UDP 或更上层的 RPC 框架。
Android 里最典型的 IPC 是 Binder。应用调用系统服务、不同进程里的 Service 交互、AIDL 生成的远程接口,底层都离不开它。
Binder 有几个设计点值得单独看。AIDL 让客户端和服务端约定接口,Android 工具链生成参数编解码和代理代码;客户端像在调本地方法,实际会把参数打包成 Parcel,交给 Binder 驱动完成跨进程事务。系统里的 Service Manager 会向 Binder 驱动注册为 context manager,负责维护服务名到 Binder 引用的映射。
Binder 事务里可以携带 Binder 对象、handle、fd 等特殊对象。fd 传递让 Binder 可以和共享内存配合:Binder 传控制消息和句柄,大块数据放到共享内存里。Android 官方 AIDL 文档也提醒过:远程调用会从平台维护的 Binder 线程池分发进服务进程,服务实现必须考虑线程安全。
Binder 也不是拿来塞大对象的通道。Android 的 TransactionTooLargeException 文档写得很明确:Binder transaction buffer 当前是 1 MB,并且由进程内正在进行的事务共享。这个异常本身也是启发式判断:客户端无法准确知道失败发生在请求发送阶段,还是响应返回阶段。更稳的做法是让 Binder 传小请求、分页结果、fd 或资源标识。
Linux 这种宏内核把文件系统、网络协议栈、驱动等大量能力放在内核里。微内核会把尽可能多的服务移到用户态进程,比如文件系统服务、驱动服务、网络服务。隔离性更好,但 IPC 会变得非常频繁。
应用读一个文件,在宏内核里可能主要是一次系统调用进内核;在微内核里,可能要和文件系统服务、块设备服务多次通信。IPC 慢一点,整个系统都跟着慢。
所以微内核论文和系统实现里,IPC 优化一直是重点。
Mach 的代表设计是 port。port 可以理解成受内核保护的消息队列和能力句柄:任务持有某个 port right,才可以向对应对象发送或接收消息。L4 家族则尽量把常见 IPC 做短:短消息用寄存器传参,同步 IPC 采用 rendezvous 风格,直接进程切换(direct process switch)避免某些路径绕一圈调度器。LRPC(Lightweight Remote Procedure Call)也在做同一件事:减少同机跨保护域调用里的线程、缓冲和调度开销。
选型时别只问“哪个最快”。更好的问题是:数据有多大?需不需要消息边界?通信双方有没有亲缘关系?要不要双向请求响应?要不要跨机器?要不要权限校验和身份识别?
| IPC 方式 | 数据形态 | 是否保留消息边界 | 是否适合大数据 | 是否跨机器 | 典型场景 |
|---|---|---|---|---|---|
| 匿名管道 | 字节流 | 否 | 不适合 | 否 | 父子进程、Shell 管道 |
| FIFO | 字节流 | 否 | 不适合 | 否 | 无亲缘关系进程简单通信 |
| 消息队列 | 消息 | 是 | 不适合 | 否 | 控制命令、状态事件 |
| 共享内存 | 共享区域 | 由应用定义 | 适合 | 否 | 大块本机数据交换 |
| Unix Domain Socket | 字节流、数据报、顺序包 | 取决于类型 | 中等 | 否 | 本机服务监听、传 fd |
| TCP/UDP Socket | 字节流或数据报 | 取决于协议 | 取决于协议和实现 | 是 | 跨机器通信 |
| Binder | 事务、对象引用、fd | 是 | 不适合直接传大对象 | 否,Android 本机 | Android 系统服务调用 |
父子进程之间传少量字节流,管道够用;无亲缘关系进程需要双向请求响应,Unix Domain Socket 更顺手;小型结构化事件可以用消息队列;大块数据优先考虑共享内存加同步通知;跨机器通信交给 TCP/UDP 或更上层的 RPC;Android 应用跨进程调用则通常走 Binder。
可以这样回答:进程默认不能直接访问彼此的用户态地址空间,所以 IPC 要么让内核代收代发数据,要么让内核创建一份可共享的对象或内存映射。
沿着这条线看,管道、FIFO、Socket 主要传字节流,消息边界通常要由应用协议处理;消息队列保留消息边界,适合较小的任务消息、状态变化和控制命令;共享内存把同一批物理页映射给多个进程,适合本机大块数据交换,但同步和内存布局要自己处理;信号偏事件通知,信号量、互斥锁、futex 这类机制更多是配合共享数据做同步。Android Binder 可以看作面向系统服务的本机 RPC/事务通道,常用于跨进程服务调用。
真正做选择时,不是看名字熟不熟,而是看数据量、消息边界、通信范围、双方关系和权限校验。比如父子进程串联命令,管道就够;本机服务要双向请求响应,还想传 fd,Unix Domain Socket 更合适;跨机器通信再考虑 TCP/UDP 或上层 RPC。
如果继续追问“共享内存为什么还需要信号量”,可以这样答:共享内存只是让两个进程看到同一块数据,不保证访问顺序。谁先写、谁后读、写到一半能不能读,都要靠信号量、进程间互斥锁、futex、eventfd 这类机制约束。
如果追问“Binder 为什么不适合传大对象”,可以补上 Android 官方文档里的 1 MB 事务缓冲限制,并说明这个缓冲由进程内进行中的事务共享。Binder 更适合传方法参数、返回值、对象引用和 fd;大块数据应该拆分、分页,或用共享内存传递。
记 IPC 时不要把它背成一串名词,可以先问这几个问题:数据大不大?要不要保留消息边界?通信双方是不是都在本机?要不要双向请求响应?同步和权限谁来管?这些问题答完,方案基本也就出来了。






