IV · C++ 与 Linux

进程间通信:管道、共享内存、信号和 socket

两个进程的地址空间是隔开的,同一个虚拟地址在两边指向不同的物理页,所以传数据只有两条路:让内核转交(管道、FIFO、消息队列、Unix 域套接字、TCP 回环),或者把同一批物理页映射进两个进程(共享内存、MAP_SHARED 映射文件)。前一条每条消息付两次拷贝、两次系统调用,换来内核替你排队、阻塞、唤醒;后一条不拷贝也不进内核,同步和通知要自己做。最后讲只传通知的信号和 eventfd,以及怎么选。

本页目录

两个进程要交换数据,最直接的想法是把一个指针传过去。可这个指针到了另一个进程里没有意义:每个进程有自己的页表,同一个虚拟地址在两个进程里指向不同的物理页,甚至根本没有映射。所以进程之间传数据,只能找一个两边都碰得到的地方:要么是内核,要么是两个页表都映射进来的同一批物理页。Linux 上各种进程间通信(IPC)的方式,都是这两条路之一;另有信号和 eventfd 两种,只传一个通知,不传数据。

1. 地址空间隔开了,数据要找个两边都碰得到的地方

1.1 地址空间隔离(address space isolation):同一个地址,各指各的页

fork 出来的子进程和父进程,全局变量的地址一模一样,可子进程改了,父进程看不到:

#include <cstdio>
#include <sys/wait.h>
#include <unistd.h>

int x = 0;

int main() {
    if (fork() == 0) { x = 1; _exit(0); }    // 子进程改的是自己那一份
    wait(nullptr);
    std::printf("%p %d\n", (void*)&x, x);    // 地址和子进程里一样,值还是 0
}

fork 之后,两个进程的页表一开始指向同一批物理页,并且都标成只读。子进程写 x 时触发缺页,内核给它复制一页、改它的页表,从这一刻起两边的 &x 就落在了不同的物理页上(写时复制,COW)。地址相同不代表是同一块内存:虚拟地址要经过各自的页表才落到物理页上,而页表每个进程一份。这就是进程地图上“虚拟地址 → 页表 → 物理内存”那条线。

1.2 两条路:让内核转交,或者共用物理页

既然用户态的内存互相看不见,数据要么交给内核、由内核转给对方,要么让两个进程的页表指向同一批物理页:

管道:让内核转交进程 Abuf:要发的数据进程 Bbuf:收到的数据用户态内核态管道缓冲区内核内存里的环,默认 64 KiB① write:拷进内核② read:拷出来每条消息:2 次 CPU 拷贝,2 次系统调用内核替你排队、阻塞、唤醒共享内存:共用物理页进程 A 的地址空间栈堆、代码共享段(地址 A)进程 B 的地址空间栈共享段(地址 B)其他映射物理内存:/dev/shm/demo 的那几页A 的页表B 的页表建好以后:0 次拷贝,0 次系统调用没人替你排队、阻塞、唤醒

左边这条路,写的一方调 write,内核把数据从它的 buf 拷进内核里的一块缓冲区;读的一方调 read,内核再拷进它的 buf。每条消息两次 CPU 拷贝、两次系统调用,读的一方要是睡着在等,还要加上一次唤醒的几 µs。可内核在中间也替你干了不少活:缓冲区空了让读的一方睡、满了让写的一方睡、来了数据把人叫醒、一端关了告诉另一端。

右边这条路,内核只在开头帮忙把同一批物理页映射进两个进程,之后读写就是普通的访存,不拷贝也不进内核。代价是内核不在中间了:数据写好了没人通知对方,两边同时写也没人拦着。内核转交省心,但每条消息都要付两次拷贝和两次系统调用;共享内存不付这笔钱,同步和通知要自己做。

2. 让内核转交:管道和 socket

2.1 管道(pipe):内核里一块先进先出的缓冲区

pipe 在内核里建一块缓冲区,返回两个 fd:fd[0] 只能读,fd[1] 只能写。它本身只在一个进程里,要靠 fork 才能连起两个进程:子进程复制了父进程的 fd 表,两边的 fd 指向内核里同一个管道。

#include <cstdio>
#include <sys/wait.h>
#include <unistd.h>

int main() {
    int fd[2];
    pipe(fd);                                // fd[0] 读端,fd[1] 写端
    if (fork() == 0) {                       // 子进程:只写
        close(fd[0]);
        const char msg[] = "hello from child";
        write(fd[1], msg, sizeof msg - 1);   // 拷进内核的管道缓冲区
        close(fd[1]);
        _exit(0);
    }
    close(fd[1]);                            // 父进程:只读,先关掉自己的写端(2.2)
    char buf[64];
    ssize_t n;
    while ((n = read(fd[0], buf, sizeof buf)) > 0)   // 从内核拷出来
        std::fwrite(buf, 1, n, stdout);
    close(fd[0]);
    wait(nullptr);
}

shell 里的 ls | wc -l 就是这么接起来的:shell 建一个管道,fork 两次,把一个子进程的标准输出、另一个的标准输入分别换成管道的两端,再各自 exec。

管道是字节流,没有消息边界:写的一方连写两次各 10 字节,读的一方可能一次读到 20 字节,也可能分三次读到。要按条收发,得自己在数据里加长度前缀之类的分隔。只有一条保证:一次写入不超过 PIPE_BUF(4096 字节)时是原子的,几个进程同时往一个管道写短消息,内容不会互相穿插。

缓冲区默认 16 页,也就是 64 KiB,可以用 fcntl(fd, F_SETPIPE_SZ, n) 调大,上限看 /proc/sys/fs/pipe-max-size(默认 1 MiB)。ulimit -a 里那行 pipe size(8 × 512 字节 = 4 KiB)说的是 PIPE_BUF,即原子写入的上限,不是缓冲区容量。这块缓冲区和 socket 缓冲区、epoll 的红黑树一样,都是内核内存,不算在进程的 RSS 里,在 IO 地图上属于中间那层内核。

2.2 管道的四种边界:空、满、写端全关、读端全关

内核替你管的那些事,落到管道上就是这四条规矩:

管道缓冲区:先写进来的先被读走写端 fd[1]读端 fd[0]已写、还没被读走的数据默认 16 页 = 64 KiB;读走一页,那一页就空出来给写用管道空,写端还开着read 阻塞,等有人写进来管道满了write 阻塞,等有人读走、腾出地方写端全关了read 返回 0:这就是 EOF,读完了读端全关了write 收到 SIGPIPE;忽略它就返回 EPIPE

前两条是普通的等待:读的一方等数据,写的一方等空间,内核用等待队列把睡着的一方挂起来,另一方读写之后叫醒它,和 epoll 那篇讲的等待队列是同一套机制。把 fd 设成 O_NONBLOCK,这两种情况就不睡,返回 EAGAIN,这时管道也能交给 epoll 去等。

后两条看的是“还有没有人拿着另一端”。内核给读端和写端各记一个计数:所有进程里指向写端的 fd 都关掉了,读的一方读完剩下的数据后 read 返回 0,这就是 EOF;指向读端的 fd 全关了,再写就没有意义,内核给写的一方发 SIGPIPE,默认动作是直接杀掉进程。

这就是上面代码里那两个 close 的原因。fork 之后父进程手里也有一个写端,它要是不关,写端的计数永远不为 0,子进程写完退出了,父进程的 read 也等不到 EOF,会一直阻塞下去。管道的 EOF 看的是所有进程里的写端都关了没有,所以 fork 之后每个进程都要关掉自己用不着的那一端。往一个已经关掉的 socket 写也会收到 SIGPIPE,所以服务器程序一般在启动时 signal(SIGPIPE, SIG_IGN),或者 send 时带 MSG_NOSIGNAL,改成检查 EPIPE 错误。

2.3 有名管道(FIFO)和消息队列:给没有亲缘关系的进程用

普通管道没有名字,只能靠 fork 继承 fd,所以只能连父子、兄弟这类有共同祖先的进程。mkfifo("/tmp/myfifo", 0600) 在文件系统里建一个名字,两个毫不相干的进程各自 open 这个路径,一个读一个写,就连上了同一个管道。数据仍然只经过内核里的缓冲区,从不落盘,文件系统里那个名字只是一个会面地点。打开时有一点不同:只读打开会阻塞到有人以写方式打开为止,反过来也一样,因为一端没人时读写都没有意义。

POSIX 消息队列(mq_open、mq_send、mq_receive)也走内核,也是两次拷贝,区别是它保留消息边界,一次 mq_receive 取走一条完整消息,还能按优先级排队。它的默认限制比较小(/proc/sys/fs/mqueue 下,默认一个队列最多 10 条、每条最多 8 KiB),现在用得不多,需要消息边界时多半直接用下一节的 Unix 域套接字。

2.4 Unix 域套接字(Unix domain socket):本机上的 socket,不走协议栈

socket(AF_UNIX, ...) 用的是和网络 socket 一样的接口(bind、listen、connect、send、recv),地址换成一个文件路径。数据还是两次拷贝:发送方的数据拷进一个内核缓冲区,直接挂到接收方 socket 的接收队列上,接收方再拷出来。中间没有 TCP/IP 协议栈:不分段、不算校验和、不等 ACK、没有拥塞控制,所以比本机 TCP 便宜。它也比管道多几样东西:一条连接两个方向都能读写;SOCK_DGRAM 和 SOCK_SEQPACKET 保留消息边界;SO_PEERCRED 能查到对端进程的 pid、uid,可以拿来做权限检查。本机的 PostgreSQL、Docker(/var/run/docker.sock)都用它让客户端连进来。

它独有的一样本事是传 fd(SCM_RIGHTS):

#include <cstring>
#include <sys/socket.h>

// 把 fd 通过 Unix 域套接字 sock 交给对端进程
void send_fd(int sock, int fd) {
    char dummy = 'x';
    iovec iov{&dummy, 1};                       // 至少要带 1 字节的普通数据
    alignas(cmsghdr) char ctl[CMSG_SPACE(sizeof(int))];
    msghdr msg{};
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    msg.msg_control = ctl;
    msg.msg_controllen = sizeof ctl;
    cmsghdr* c = CMSG_FIRSTHDR(&msg);
    c->cmsg_level = SOL_SOCKET;
    c->cmsg_type = SCM_RIGHTS;                  // 这条消息里带着 fd
    c->cmsg_len = CMSG_LEN(sizeof(int));
    std::memcpy(CMSG_DATA(c), &fd, sizeof(int));
    sendmsg(sock, &msg, 0);
}

传过去的不是那个整数:fd 只是发送方 fd 表里的一个下标,换个进程就没有意义。内核做的是在接收方的 fd 表里新开一格,指向发送方那个 fd 背后同一个打开的文件,对方拿到的编号多半不一样,可文件读写位置是共用的。这和 1.1 的道理一样:能跨进程共享的只能是内核里的对象,用户态的地址和 fd 编号都只在自己的进程里有意义。后面 4.3 节的 eventfd、3.1 节共享内存的 fd,都可以这样交给一个不相干的进程。

2.5 TCP 回环(loopback):多走一遍协议栈,换来能跨机器

连 127.0.0.1 的 TCP 也能做本机通信。数据照样是两次拷贝,但中间要完整走一遍协议栈:发送方的 TCP 分段、加 IP 头,经过回环设备 lo 交给接收方向,再由软中断走一遍 IP、TCP 的收包处理,还要回 ACK。这些都是 Unix 域套接字省掉的 CPU 开销,所以本机通信时延迟和 CPU 占用通常都比它高一截。

换来的是同一份代码不用改就能跨机器:今天两个服务在一台机器上,明天拆到两台,只要把地址从 127.0.0.1 换成对方的 IP。在 IO 地图上,回环走的就是“发一个包”和“收一个包”两条路径,只是跳过了网卡那一段。

3. 共用物理页:共享内存

3.1 POSIX 共享内存:shm_open 加 mmap

共享内存的做法是让两个进程映射同一个内存对象。POSIX 的接口是 shm_open,它在 /dev/shm(一个放在内存里的 tmpfs 文件系统)下建一个对象,返回 fd,再用 mmap 以 MAP_SHARED 映射进来:

// shared.h:两个进程都 include
#include <atomic>
#include <cstdint>

struct Shared {
    std::atomic<uint64_t> seq;   // 写完数据再加 1,读的一方看到它变了才去读
    char data[4096];
};
static_assert(std::atomic<uint64_t>::is_always_lock_free);   // 带锁的 atomic 不能跨进程用(3.2)
// 写的一方
#include <cstring>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include "shared.h"

int main() {
    int fd = shm_open("/demo", O_CREAT | O_RDWR, 0600);   // 建出 /dev/shm/demo
    ftruncate(fd, sizeof(Shared));                          // 新建的对象长度是 0,先定长度
    auto* s = static_cast<Shared*>(mmap(nullptr, sizeof(Shared),
                     PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0));
    close(fd);                                              // 映射建好以后 fd 就可以关了
    std::strcpy(s->data, "hello");                          // 普通的写内存,不进内核
    s->seq.fetch_add(1, std::memory_order_release);         // 先写数据,再发布
}
// 读的一方
#include <cstdio>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include "shared.h"

int main() {
    int fd = shm_open("/demo", O_RDWR, 0);
    auto* s = static_cast<Shared*>(mmap(nullptr, sizeof(Shared),
                     PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0));
    close(fd);
    while (s->seq.load(std::memory_order_acquire) == 0) { }   // 没人叫醒你,只能自己看(3.2)
    std::puts(s->data);
    shm_unlink("/demo");                                       // 删掉名字,最后一个映射解除后内存才释放
}

两个进程各自 mmap 以后,共享段出现在各自进程地图的 mmap 区里,/proc/<pid>/maps 里能看到一行 /dev/shm/demo。每个进程第一次碰到某一页时会缺页,内核把这个对象的那一页填进自己的页表;之后两个页表指向同一批物理页,写的一方 strcpy 进去的字节,读的一方直接就能读到。

有一点容易踩坑:同一个共享段在两个进程里的起始地址多半不一样,就像图里的地址 A 和地址 B。所以共享内存里不能存指针,一个进程存进去的指针,在另一个进程里指向的是别的东西。要表示“指向共享段里的某处”,存相对段首的偏移量。同理,std::string、std::vector 这类自己在堆上分配内存的类型也不能直接放进去,它们的数据在写的那个进程的堆上,对方看不见。共享内存里只能放不含指针的平坦数据,位置用偏移量表示。

shm_open 建的对象不随进程退出而消失:它挂在 /dev/shm 下,要等有人 shm_unlink 删掉名字、而且所有映射都解除了,内存才回收,否则一直占到重启。较老的 System V 接口(shmget、shmat)道理相同,用一个整数 key 而不是路径找到同一段内存,用 ipcs -m 查看、ipcrm 删除。

3.2 同步要自己做:原子变量、进程间的锁、futex

内核不在中间了,管道替你做的那几件事都要自己补上。第一件是“写完了”:读的一方得知道数据已经完整写进去了,才能去读。上面的代码用的是一个原子计数器配 release、acquire 内存序:写的一方先写数据,再用 release 加计数;读的一方用 acquire 读到新计数后,保证能看到计数之前写进去的数据。这和线程之间的写法完全一样,因为两个进程跑在同一台机器的 CPU 上,看到的是同一套缓存一致性和内存序。唯一的额外要求是这个原子变量必须是无锁的(lock-free):有些平台上过大的 std::atomic<T> 内部是用一把放在进程自己内存里的锁实现的,另一个进程看不到那把锁。

第二件是互斥:两个进程要改同一份结构,得有一把两边都看得见的锁。std::mutex 做不到,要用 pthread 的锁并标成进程间共享,锁本身放在共享内存里:

#include <pthread.h>

struct SharedWithLock {
    pthread_mutex_t mu;
    int counter;
};

void init(SharedWithLock* s) {                     // 只由建共享段的那个进程调一次
    pthread_mutexattr_t a;
    pthread_mutexattr_init(&a);
    pthread_mutexattr_setpshared(&a, PTHREAD_PROCESS_SHARED);   // 允许别的进程用这把锁
    pthread_mutexattr_setrobust(&a, PTHREAD_MUTEX_ROBUST);      // 持锁的进程死了,下一个加锁的会收到 EOWNERDEAD
    pthread_mutex_init(&s->mu, &a);
    pthread_mutexattr_destroy(&a);
}

ROBUST 是进程之间才需要操心的事:线程崩了整个进程一起死,可一个进程拿着锁被杀掉,另一个进程还活着,没有这个属性它就会永远等下去。设了以后,下一个 pthread_mutex_lock 返回 EOWNERDEAD,它检查、修好数据,再调 pthread_mutex_consistent 把锁恢复成正常状态。

第三件是“没数据时去睡,来了数据被叫醒”。上面读的一方是空转着等,CPU 一直在烧。要睡下去,就得有一个内核能看到的等待点:进程间共享的条件变量(pthread_condattr_setpshared)、POSIX 信号量(sem_init 第二个参数传 1)、4.3 节的 eventfd,底下都是 futex(fast userspace mutex)。futex 让内核在共享内存里的一个 32 位整数上挂等待队列:没竞争时只在用户态做原子操作,不进内核;真要等时才用系统调用睡下去。共享内存把拷贝省了,可每次需要等待和唤醒时,照样要进内核。所以只有每条消息不必都叫醒对方时(批量处理、对方一直在忙),共享内存的好处才完整拿得到。一个写、多个读的共享内存环形队列怎么写,在共享内存里的 SPMC 广播环里讲过。

3.3 映射同一个文件:MAP_SHARED 和 MAP_PRIVATE

不用 shm_open,两个进程 mmap 同一个普通文件,也能共享内存。MAP_SHARED 时,两个进程映射的都是这个文件在页缓存里的那些页,一边写了,另一边马上读得到,因为是同一个物理页;数据之后由内核的回写线程写回磁盘,不是写一下就同步一下,要确定落盘调 msync。这条路在 IO 地图上就是“映射一个文件”那条路径,只是映射它的有两个进程。

MAP_PRIVATE 正相反:一开始也映射页缓存里的页,但谁一写就触发写时复制,复制出来的那一页只属于自己,别的进程看不到,也永远不会写回文件。所以 MAP_PRIVATE 不能用来通信,它的用途是加载程序:可执行文件和 .so 的数据段都是这样映射的,每个进程改的是自己那一份。

有亲缘关系的进程还有一种不需要文件的写法:fork 之前 mmap(nullptr, n, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0)。这段映射标成了共享,fork 以后父子进程指向同一批物理页,不做写时复制。映射文件时要注意长度:访问超出文件末尾的那部分页,进程会收到 SIGBUS,所以新建的文件要先 ftruncate 到需要的长度。

4. 只传一个通知:信号和 eventfd

4.1 信号(signal):内核在目标进程里置一位

kill(pid, SIGUSR1) 并不会马上打断对方。内核只是在目标进程的“待处理信号”位图里把这一位置 1,kill 就返回了;对方真正处理,要等它下一次从内核回到用户态的时候:

用户态内核态① 进程 Akill(B, SIGUSR1)② 内核在 B 的 pending 里把 SIGUSR1 置 1kill 到这就返回③ B 回用户态之前查到 pending 有一位在 B 的栈上压一个帧让它先跳去 handler④ 进程 B跑 handler,跑完调 sigreturn,回原来的代码接着跑② 到 ③ 隔多久不确定:B 下一次从内核回用户态时才处理,系统调用返回、被中断打断后返回都算。标准信号只是一位:处理之前又来一次,只算一次;B 正睡在 read 里的话会被叫醒,read 返回 EINTR。

这张图说明了信号能带的东西有多少:一个编号。标准信号(1–31 号)在内核里就是一位,处理之前同一个信号又来几次,也只算一次,所以不能拿它来计数。实时信号(SIGRTMIN 到 SIGRTMAX)会排队,用 sigqueue 发还能带一个整数,可也就这么多。信号是一个通知,不是一条数据通道。

它适合的是“发生了某件事”这一类:Ctrl+C 是 SIGINT,kill 命令默认发 SIGTERM 请进程退出,子进程退出时父进程收到 SIGCHLD,访问非法地址时内核给进程发 SIGSEGV,前面管道读端关了的 SIGPIPE 也是。SIGKILL 和 SIGSTOP 不能被捕获、也不能忽略,内核直接执行,这是保证管理员总能停掉一个进程的底线。

4.2 信号处理函数:只能做异步信号安全的事

从上图第 ③ 步能看出处理函数危险在哪:它插在 B 原来代码的任意一条指令之后执行。如果 B 那时正在 malloc 里、刚拿到分配器的锁,处理函数里再调 malloc(或者内部会调它的 printf),就会等一把被自己拿着的锁,进程卡死;不卡死的话,也可能看到分配器改了一半的数据结构。所以处理函数里只能调一小批“异步信号安全”(async-signal-safe)的函数,man 7 signal-safety 列着,write、_exit 在里面,printf、malloc、new 都不在。

常见写法是处理函数里只置一个标志,真正的事放回主循环去做:

#include <csignal>

volatile std::sig_atomic_t stop = 0;
void on_term(int) { stop = 1; }               // 只置一个标志,别的都不做

int main() {
    struct sigaction sa{};
    sa.sa_handler = on_term;
    sigaction(SIGTERM, &sa, nullptr);
    while (!stop) { /* 干活 */ }
    // 回到这里再做清理:printf、free 都可以放心调
}

更彻底的办法是不让信号打断你,把它变成一个可读的 fd。signalfd 就是干这个的:

#include <csignal>
#include <sys/signalfd.h>

sigset_t m;
sigemptyset(&m);
sigaddset(&m, SIGTERM);
sigaddset(&m, SIGCHLD);
sigprocmask(SIG_BLOCK, &m, nullptr);       // 先挡住,不让它们按老办法打断你
int sfd = signalfd(-1, &m, SFD_CLOEXEC);   // 这两个信号来了,sfd 就变成可读
// 把 sfd 交给 epoll;可读时 read 出 signalfd_siginfo,里面有信号编号和发送者的 pid

挡住的信号不会被丢掉,而是一直挂在待处理里,signalfd 把它们变成可以 read 的数据。这样信号就和 socket、管道一样,在事件循环里按顺序处理,不再有“插在任意一条指令之后”的问题。多线程程序要在创建线程之前就挡住,新线程会继承这个屏蔽字;否则信号可能投给某个没挡住它的线程,按老办法打断它。没有 signalfd 的系统上,老办法是 self-pipe:处理函数里往一个管道写 1 字节(write 是异步信号安全的),事件循环监听管道的读端。

4.3 eventfd:一个能被 epoll 等的计数器

只想告诉另一个进程“有事了”,不需要信号那么重的打断,也不需要管道那块 64 KiB 的缓冲区。eventfd 在内核里只有一个 8 字节的计数器:

#include <cstdint>
#include <sys/eventfd.h>
#include <unistd.h>

int efd = eventfd(0, EFD_CLOEXEC);   // 内核里一个 8 字节的计数器,初值 0
uint64_t one = 1;
write(efd, &one, 8);                 // 计数加 1,等在它上面的人被叫醒
uint64_t n;
read(efd, &n, 8);                    // 读出累计值并清零;计数是 0 时 read 阻塞

write 往计数上加,read 取出累计值并清零(建的时候带 EFD_SEMAPHORE,就改成每次 read 只减 1)。几次 write 合并成一次唤醒,正好适合“有新东西了,你去看看”这种通知。它是一个 fd,所以能交给 epoll,也能靠 fork 继承或者用 2.4 节的 SCM_RIGHTS 传给别的进程。

它最常见的搭档是共享内存:数据放在共享段里,零拷贝;写的一方写完,往 eventfd 里写一次,读的一方在 epoll 里等这个 fd,被叫醒后去共享段里取。这样既不用空转,也不用为每个字节付拷贝的钱,付的只是每次唤醒的那两次系统调用。

5. 怎么选

把上面几种放在一张表里,按“数据走不走内核”看:

方式数据怎么走每条消息的 CPU 拷贝消息边界同步要自己做能跨机器能交给 epoll
管道、FIFO内核缓冲区转交2 次没有(字节流)否否能
POSIX 消息队列内核转交2 次有否否能(Linux 上它是 fd)
Unix 域套接字内核转交,不走协议栈2 次stream 没有,dgram 有否否能
TCP 回环内核转交,走完整个协议栈2 次没有否能能
共享内存、MAP_SHARED两个页表指向同一批物理页0 次自己定是否不能,要配一个通知
信号只传一个编号不传数据——否用 signalfd 才能
eventfd只传一个计数不传数据——否能

表里的“能交给 epoll”决定了它能不能放进一个事件循环和别的连接一起等;“同步要自己做”那一列是共享内存省下拷贝要付的代价。

落到具体场景:父子进程之间单向传一串字节,像 shell 的管道,用 pipe。本机两个不相干的服务之间要请求和应答、要双向、可能还要传 fd,用 Unix 域套接字。以后可能拆到两台机器上,直接用 TCP,本机时多花的那点 CPU 换来不改代码。数据量大、来回频繁,拷贝已经成了开销,用共享内存,再配 eventfd、futex 或者进程间的条件变量做通知。只是要告诉对方“发生了某件事”,用 eventfd;要处理的是外面发来的终止请求、子进程退出这类事件,那就是信号,最好用 signalfd 收进事件循环。

回头看,进程间通信只有一个问题:数据要不要经过内核。经过内核,内核替你排队、阻塞、唤醒,每条消息付两次拷贝;不经过内核,拷贝省了,排队、阻塞和唤醒就要自己做。