进程之间是隔开的------每个进程一张自己的页表,A 里写的 int x = 1,B 里根本看不到这个 x。这种隔离正是进程存在的意义,但也意味着两个进程想把数据递给对方,光靠自己读写内存是做不到的,必须绕一圈,借内核帮忙。
借道内核的方式有很多种,经典的六种是:管道、命名管道、消息队列、共享内存、信号量、Socket。它们解决的是同一件事,但各走各的路,代价和适用场景差很多。
管道 pipe
管道是最简单的一种:内核里开一块缓冲区,一头写、一头读。
c
int fd[2];
pipe(fd); // fd[0] 是读端,fd[1] 是写端
你肯定用过它,只是没注意:
shell
ls | grep txt
这条命令里,ls 的标准输出和 grep 的标准输入之间那个 |,就是 shell 帮两个进程建的一个管道。
它的特点很明确:
- 单向。数据只能从写端流到读端,想双向得开两个管道。
- 没有名字 。它只是两个文件描述符,别的进程拿不到,所以只能给有亲缘关系 的进程用(靠
fork把 fd 继承过去)。 - 字节流,没有边界 。写进去
hello和world,读端可能一次读成helloworld,粘在一起,怎么切分要自己约定。
写端写满缓冲区(默认 64KB)会阻塞,读端读空了也会阻塞。所有写端都关掉后,读端会读到 EOF;所有读端都关掉后,写端再写会收到 SIGPIPE。这几条规则,就是 shell 里管道流式处理的底层。
命名管道 FIFO
管道限制在亲缘进程,太窄。命名管道(FIFO)就是给它加一个文件名:
shell
mkfifo /tmp/myfifo
之后任何进程都能 open("/tmp/myfifo", ...),跟打开普通文件一样。它和匿名管道在读写语义上完全一样------还是单向、还是字节流、还是写满就阻塞------唯一的区别就是有了名字,不认识的两个进程也能对接 。mkfifo 出来的文件在磁盘上不占数据块,它只是个路标,真正的缓冲还是在内存里。
消息队列
管道是"一连串字节",消息队列是"一连串消息"。内核里维护一条链表,每个节点是一条有明确边界的消息。
c
msgget(...); // 拿到队列 id
msgsnd(qid, &msg, len, 0); // 发一条
msgrcv(qid, &msg, len, type, 0); // 收一条
比管道多出来的能力:
- 有边界。一条是一条,不会像管道那样粘成一坨,收的人不用自己拼。
- 可以按类型取 。
msgrcv的type参数能指定只收某一类消息,不一定是先进先出。 - 有生命周期。队列挂在内核上,发送进程退出了队列还在,直到显式删除。
代价是每发一条要拷贝两次(用户态→内核,内核→另一进程的用户态),量一大就慢。
共享内存
前面三种都是"内核帮你搬数据",共享内存干脆不搬:把同一块物理内存映射进两个进程的地址空间。
c
shmget(...); // 申请一块共享内存段
shmat(shmid, NULL, 0); // 映射到自己的地址空间,拿到指针
映射完成后,两个进程拿到的指针指向同一块物理页,一个写、另一个立刻能读到。没有拷贝,没有内核中转,是六种里最快的。
代价也来自这个"快":内核只负责把它们接上,之后就撒手了,不提供任何同步。两个进程同时写同一块内存,读到什么全看运气。所以共享内存几乎从不单独用,一定要配一个同步手段------典型搭配就是下一节的信号量。
换个角度看,共享内存提供的其实是"通信的场所",而不是"通信的机制"。交换数据靠它,协调谁先说谁后说,得靠信号量。
信号量
信号量不传数据,它管的是同步和互斥:谁可以进临界区、最多几个进程同时进。
它的语义是经典的 P/V 操作:
- P(减):把信号量减 1,如果已经是 0 就阻塞,等别人加回来。
- V(加):把信号量加 1,唤醒一个正在等的人。
初始化成 1,就是一把互斥锁(同一时刻只有一个进程能进);初始化成 N,就是"最多 N 个并发"的限流器。配共享内存用的时候,就是每个进程访问共享内存前先 P、访问完 V,保证不会互相踩。
它和信号(signal)名字像,干的完全是两回事:信号量是"等你把资源让出来",信号是"通知你出事了",后者只能传一个编号,信息量太小,通常不单独算一种 IPC。
Socket
上面五种都有一个共同的局限------都在同一台机器上。Socket 是唯一能跨主机的:
c
socket(AF_INET, SOCK_STREAM, 0); // 跨机器,走 TCP
socket(AF_UNIX, SOCK_STREAM, 0); // 本机,走文件路径
Socket 是全双工 的,两边可以同时发,而且天然支持多对多、跨网络。本机通信时用 AF_UNIX(UNIX 域套接字),它走的是文件系统路径,不经过 TCP/IP 协议栈,比 AF_INET 快不少,这也是现在很多软件把本地服务从 TCP 换成 UNIX 域套接字的原因。
代价是它最"重":要建连接、要管状态、走一遍协议栈。但换来的是通用性------别的五种都出不了这台机器,只有它行。CS162 Note5 的 IPC 一节里从 file 当通信通道的角度也铺过一遍,可以对照着看。
六种对比
| 方式 | 方向 | 有名 / 可跨进程 | 可跨机器 | 数据边界 | 拷贝次数 | 典型场景 |
|---|---|---|---|---|---|---|
| 管道 | 单向 | 无名,仅亲缘进程 | 否 | 字节流 | 内核缓冲,2 次 | shell 的 ` |
| 命名管道 | 单向 | 有文件名,任意进程 | 否 | 字节流 | 2 次 | 本机不相关进程对接 |
| 消息队列 | 单向 | 内核对象,任意进程 | 否 | 有消息边界 | 2 次 | 结构化小消息 |
| 共享内存 | 双向 | 内核对象,任意进程 | 否 | 无(裸内存) | 0 次 | 大数据量、高性能 |
| 信号量 | 无数据 | 内核对象,任意进程 | 否 | ------ | ------ | 同步 / 互斥 |
| Socket | 双向 | 有地址,任意进程 | 是 | 字节流(可加帧) | 视实现,通常 2 次 | 网络通信、本地服务 |
把这张表横着读一遍,取舍就清楚了:共享内存零拷贝最快,代价是没同步,得自己加信号量兜着;消息队列、管道慢一些,但内核把同步和边界都替你想好了;想出这台机器,就只有 Socket 这一个选择。
还有一点要注意,这六种说的是"经典 IPC 机制",实际写代码时在更上层还有别的选择------数据库、Redis、消息中间件,本质都是在 Socket 之上又包了一层。