Skip to content

Latest commit

 

History

History
149 lines (85 loc) · 17 KB

File metadata and controls

149 lines (85 loc) · 17 KB
title 进程间通信(IPC)详解:管道、消息队列、共享内存、Socket 与 Binder
description 进程间通信高频知识点总结,从进程地址空间隔离讲起,讲清管道、消息队列、共享内存、信号量、信号、Socket、Android Binder 和微内核 IPC 的设计取舍。
category 计算机基础
tag
操作系统
Linux
IPC
head
meta
name content
keywords
进程间通信,IPC,Linux IPC,管道,FIFO,消息队列,共享内存,信号量,信号,Socket,Unix Domain Socket,Android Binder,微内核 IPC,操作系统面试题

两个进程想交换一段数据,最直觉的想法是:A 进程把数据写到自己的内存里,然后 B 进程直接去读就行了。

不过,这在操作系统里行不通。每个进程都有独立的虚拟地址空间,A 进程里的 0x7f... 地址和 B 进程里的 0x7f... 地址并不是同一块内存。用户态进程之间不能随便互相摸内存,否则权限隔离也没法谈。

所以 IPC(Inter-Process Communication,进程间通信) 绕不开操作系统。

不要想得太复杂,我更习惯把 IPC 看成三件事:怎么传数据、怎么同步控制流、怎么做命名和权限检查。只记“管道、消息队列、共享内存”这些名字,很容易背完就忘。

进程地址空间隔离导致进程间通信需要借助内核提供的 IPC 机制

IPC 到底在解决什么?

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_maxmsgsize_max 等限制项。

所以消息队列适合传结构化小消息,比如任务通知、状态事件、控制命令。它不适合传大块图片、音视频帧或超大的序列化对象。Linux IPC 里的消息队列也不是 Kafka、RocketMQ 那种消息中间件,没有持久化日志、消费组和跨机器复制。

共享内存:少拷贝,但同步要自己负责

共享内存的思路很直接:让多个进程把同一块物理内存映射到各自的虚拟地址空间里。映射建立之后,A 进程写这块内存,B 进程就能读到更新。

Linux 上常见两类接口:System V 共享内存用 shmget()shmat()shmdt()shmctl();POSIX 共享内存用 shm_open() 创建对象,ftruncate() 设置大小,再用 mmap() 映射到进程地址空间。

共享内存让多个进程映射同一块物理内存,但仍需要信号量、futex 等同步机制配合

共享内存快在数据路径短。管道、消息队列、Socket 这类方式通常要把数据先交给内核,再由内核交给另一个进程;共享内存完成映射后,进程读写的是同一片物理页,数据本身不用每次都在用户态和内核态之间搬来搬去。日志采集、音视频处理、数据库缓存这类本机大块数据交换场景,才比较适合把它拿出来用。

不过,映射同一块内存只解决“能不能看见”的问题,不解决“什么时候能读”和“谁可以写”的问题。

以共享环形队列为例,生产者通常会写数据、更新 tail,消费者根据 headtail 判断有没有新数据。如果生产者还没把一条记录写完整,就提前更新了 tail,消费者可能马上读到一条半成品。这个问题不能靠共享内存自己解决,需要把写入顺序、可见性和唤醒机制一起设计好:简单一点可以用进程间互斥锁、POSIX 信号量;追求性能时可能会用 eventfdfutex、原子变量和内存屏障。

还有一个细节很容易踩坑:共享内存里别直接放进程内指针。同一块共享内存在 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:本机和跨机器都能用

Socket 不只用于网络通信,也能做本机 IPC。

如果两个进程在不同机器上,基本就得走 TCP/UDP 这类网络 Socket。如果两个进程在同一台机器上,可以用 Unix Domain Socket。它的地址族是 AF_UNIXAF_LOCAL,支持 SOCK_STREAMSOCK_DGRAMSOCK_SEQPACKET 等类型。

Unix Domain Socket 的接口接近网络 Socket,支持无亲缘关系进程通信;Linux 下既可以绑定文件系统路径,也可以使用 abstract namespace。它还可以借助 sendmsg() 的辅助数据和 SCM_RIGHTS 传文件描述符。对端身份这块,连接型 Unix Socket 常用 SO_PEERCRED 获取 pid、uid、gid;数据报场景也可以结合 SO_PASSCREDSCM_CREDENTIALS 让凭据随消息传过来。

如果问“管道和 Unix Domain Socket 怎么选”,可以这样答:父子进程之间的简单字节流,用管道就够;要双向通信、请求响应、传 fd、服务端监听,Unix Domain Socket 更合适;要跨机器,换 TCP/UDP 或更上层的 RPC 框架。

Android Binder:把 IPC 做成系统服务调用

Android Binder 通过 AIDL、Parcel、Binder 驱动和 Service Manager 将 IPC 封装成系统服务调用

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 或资源标识。

微内核为什么特别在意 IPC?

Linux 这种宏内核把文件系统、网络协议栈、驱动等大量能力放在内核里。微内核会把尽可能多的服务移到用户态进程,比如文件系统服务、驱动服务、网络服务。隔离性更好,但 IPC 会变得非常频繁。

应用读一个文件,在宏内核里可能主要是一次系统调用进内核;在微内核里,可能要和文件系统服务、块设备服务多次通信。IPC 慢一点,整个系统都跟着慢。

所以微内核论文和系统实现里,IPC 优化一直是重点。

Mach 的代表设计是 port。port 可以理解成受内核保护的消息队列和能力句柄:任务持有某个 port right,才可以向对应对象发送或接收消息。L4 家族则尽量把常见 IPC 做短:短消息用寄存器传参,同步 IPC 采用 rendezvous 风格,直接进程切换(direct process switch)避免某些路径绕一圈调度器。LRPC(Lightweight Remote Procedure Call)也在做同一件事:减少同机跨保护域调用里的线程、缓冲和调度开销。

常见 IPC 怎么选?

选型时别只问“哪个最快”。更好的问题是:数据有多大?需不需要消息边界?通信双方有没有亲缘关系?要不要双向请求响应?要不要跨机器?要不要权限校验和身份识别?

IPC 方式 数据形态 是否保留消息边界 是否适合大数据 是否跨机器 典型场景
匿名管道 字节流 不适合 父子进程、Shell 管道
FIFO 字节流 不适合 无亲缘关系进程简单通信
消息队列 消息 不适合 控制命令、状态事件
共享内存 共享区域 由应用定义 适合 大块本机数据交换
Unix Domain Socket 字节流、数据报、顺序包 取决于类型 中等 本机服务监听、传 fd
TCP/UDP Socket 字节流或数据报 取决于协议 取决于协议和实现 跨机器通信
Binder 事务、对象引用、fd 不适合直接传大对象 否,Android 本机 Android 系统服务调用

常见 IPC 方式在数据形态、消息边界、大数据传输和跨机器能力上的横向对比

父子进程之间传少量字节流,管道够用;无亲缘关系进程需要双向请求响应,Unix Domain Socket 更顺手;小型结构化事件可以用消息队列;大块数据优先考虑共享内存加同步通知;跨机器通信交给 TCP/UDP 或更上层的 RPC;Android 应用跨进程调用则通常走 Binder。

根据数据量、消息边界、跨机器和进程亲缘关系选择合适的 IPC 方式

面试里怎么答 IPC?

可以这样回答:进程默认不能直接访问彼此的用户态地址空间,所以 IPC 要么让内核代收代发数据,要么让内核创建一份可共享的对象或内存映射。

沿着这条线看,管道、FIFO、Socket 主要传字节流,消息边界通常要由应用协议处理;消息队列保留消息边界,适合较小的任务消息、状态变化和控制命令;共享内存把同一批物理页映射给多个进程,适合本机大块数据交换,但同步和内存布局要自己处理;信号偏事件通知,信号量、互斥锁、futex 这类机制更多是配合共享数据做同步。Android Binder 可以看作面向系统服务的本机 RPC/事务通道,常用于跨进程服务调用。

真正做选择时,不是看名字熟不熟,而是看数据量、消息边界、通信范围、双方关系和权限校验。比如父子进程串联命令,管道就够;本机服务要双向请求响应,还想传 fd,Unix Domain Socket 更合适;跨机器通信再考虑 TCP/UDP 或上层 RPC。

如果继续追问“共享内存为什么还需要信号量”,可以这样答:共享内存只是让两个进程看到同一块数据,不保证访问顺序。谁先写、谁后读、写到一半能不能读,都要靠信号量、进程间互斥锁、futexeventfd 这类机制约束。

如果追问“Binder 为什么不适合传大对象”,可以补上 Android 官方文档里的 1 MB 事务缓冲限制,并说明这个缓冲由进程内进行中的事务共享。Binder 更适合传方法参数、返回值、对象引用和 fd;大块数据应该拆分、分页,或用共享内存传递。

记 IPC 时不要把它背成一串名词,可以先问这几个问题:数据大不大?要不要保留消息边界?通信双方是不是都在本机?要不要双向请求响应?同步和权限谁来管?这些问题答完,方案基本也就出来了。