进程间通信IPC
# 06.进程间通信IPC
嵌入式 Qt 应用很少有单进程架构——UI 进程 + 后台服务进程 + 守护进程 构成了典型的进程树。本节覆盖 pipe/FIFO/SysV IPC/Unix Domain Socket/mmap 五种 IPC 机制,给出嵌入式场景的选型矩阵。
# 目录介绍
- 6.1 案例引入
- 6.2 管道 pipe 与 FIFO
- 6.3 System V IPC
- 6.4 Unix Domain Socket
- 6.5 mmap 共享内存
- 6.6 嵌入式 IPC 选型矩阵
- 6.7 关键结论与速查表
# 6.1 案例引入
# 6.1.1 UI 进程和后台服务通信延迟超过 50ms
某智能家居中控,QML UI 进程需要实时读取后台 Zigbee 网关服务的设备状态。最初使用 TCP localhost socket,发现状态变化的平均延迟 5-15ms,偶发 50ms+。切换到 Unix Domain Socket + SCM_RIGHTS 传递共享内存 fd 后,稳定在 0.5ms 以内。
同一台设备上的跨进程通信,为什么 TCP 比 Unix Domain Socket 慢 30 倍? 答案在内核的网络协议栈 vs 本地文件系统路径上。
TCP localhost 的完整数据路径:
UI 进程 内核 后台进程
│ │ │
│ send(buf, 128) │ │
├────────────────────→│ │
│ │ ① TCP 分段 + 封包 │
│ │ ② IP 路由(lo 接口) │
│ │ ③ 网络协议栈(netfilter/iptables hook,即使 localhost 也要过)
│ │ ④ TCP 校验和 + 序列号管理 │
│ │ ⑤ sk_buff 分配 + 拷贝(至少 2 次)
│ │ ⑥ 拥塞控制 / 流量控制检查 │
│ │ │
│ │ recv() ←────┤
│ │←───────────────────────────────┤
Unix Domain Socket 的数据路径:
UI 进程 内核 后台进程
│ │ │
│ send(buf, 128) │ │
├────────────────────→│ │
│ │ ① 直接把 sk_buff 挂到对端接收队列
│ │ ② 唤醒对端(阻塞在 recv 的进程)
│ │ │
│ │ recv() ←────┤
│ │←───────────────────────────────┤
核心差异:UDS 跳过整个 TCP/IP 协议栈——不封包、不校验、不路由、不拥塞控制。数据在内核内存中直接传递(零拷贝)。
# 6.1.2 核心问题
- 管道 vs 消息队列 vs 共享内存——各自适用什么场景?
- System V IPC 资源泄露如何预防(
ipcs/ipcrm不是长久之计)? - 如何在多进程之间安全地共享一块 mmap 内存?
SCM_RIGHTS传递文件描述符的机制是什么?能否用这种方法实现"不经过磁盘文件的 mmap 共享"?
# 6.2 管道 pipe 与 FIFO
# 6.2.1 匿名管道的局限性
匿名管道(pipe)——最古老的 IPC,单向、只限父子进程:
int fd[2];
if (pipe(fd) < 0) { perror("pipe"); exit(1); }
pid_t pid = fork();
if (pid == 0) {
close(fd[0]); // 子:只写
write(fd[1], "hello", 5);
close(fd[1]);
} else {
close(fd[1]); // 父:只读
char buf[16];
read(fd[0], buf, sizeof(buf));
printf("received: %.5s\n", buf);
close(fd[0]);
}
pipe 的五大局限:
| 局限 | 说明 | 后果 |
|---|---|---|
| 单向 | 只能一端写一端读 | 双向需两个 pipe |
| 仅限父子 | pipe() 返回的 fd 只能由 fork 继承 | 无亲缘进程无法用 |
| 无名字 | 无文件系统实体 | 不能用 open 打开 |
| 无消息边界 | 字节流,无边界 | 需自定协议分帧 |
| 缓冲区固定 | 默认 16 页(64 KB) | 写超缓冲则阻塞 |
pipe2——原子设置 O_NONBLOCK 和 O_CLOEXEC:
int fd[2];
pipe2(fd, O_NONBLOCK | O_CLOEXEC); // 一次 syscall 设两个 flag
pipe() 在 Shell 中的经典应用:
# ls 的输出通过管道给 wc,wc 必须等 ls 完成才收到 EOF
ls -l / | wc -l
# 内核实现:
# 1. shell 调 pipe() 创建一对 fd
# 2. shell fork,子进程 dup2(fd[1], STDOUT),exec ls
# 3. shell fork,子进程 dup2(fd[0], STDIN),exec wc
# 4. shell close 两个 fd
# 6.2.2 命名管道 FIFO 的创建与使用
FIFO 是有名字的管道——创建文件系统节点,任意两个进程都可以用 open 连接:
// 进程 A:创建 FIFO 并写入
mkfifo("/tmp/myfifo", 0666);
int fd = open("/tmp/myfifo", O_WRONLY);
write(fd, "hello from A", 12);
close(fd);
// 进程 B:打开 FIFO 并读取(可完全独立运行,无需亲缘关系)
int fd = open("/tmp/myfifo", O_RDONLY);
char buf[128];
int n = read(fd, buf, sizeof(buf));
printf("B got: %.*s\n", n, buf);
close(fd);
FIFO 的 open 阻塞规则:
| 打开模式 | 行为 |
|---|---|
O_RDONLY | 阻塞,直到另一个进程以写方式打开(O_WRONLY / O_RDWR) |
O_WRONLY | 阻塞,直到另一个进程以读方式打开 |
O_RDWR | 不阻塞 |
O_RDONLY \| O_NONBLOCK | 立即返回(即使无写者) |
O_WRONLY \| O_NONBLOCK | 失败返回 -1,errno = ENXIO(无读者) |
典型模式——FIFO 作为服务端的多客户端入口:
// 服务端:为每个客户端创建独立 FIFO 回复
#define REQUEST_FIFO "/tmp/server_req"
#define RESPONSE_FIFO "/tmp/client_%d_resp"
// 服务端:循环读请求,为每个客户写回复
int req_fd = open(REQUEST_FIFO, O_RDONLY);
while (1) {
struct request req;
read(req_fd, &req, sizeof(req));
char resp_path[64];
snprintf(resp_path, sizeof(resp_path), RESPONSE_FIFO, req.client_id);
int resp_fd = open(resp_path, O_WRONLY);
write(resp_fd, &response, sizeof(response));
close(resp_fd);
}
# 6.2.3 管道缓冲区大小与写阻塞
管道容量由内核参数决定:
# Linux 默认管道大小(从 2.6.11 起可改)
cat /proc/sys/fs/pipe-max-size # 最大可设值(通常 1 MB)
fcntl F_SETPIPE_SZ 动态修改单管道容量:
int pipe_sz = fcntl(fd[1], F_GETPIPE_SZ); // 读取当前大小(默认 64 KB)
fcntl(fd[1], F_SETPIPE_SZ, 1024 * 1024); // 设为 1 MB(须 root 或有 CAP_SYS_RESOURCE)
写阻塞的三个触发条件:
| 条件 | 行为 | 示例 |
|---|---|---|
write 数据 ≤ PIPE_BUF(4096)且管道有空间 | 原子写入,不阻塞 | 小写入不交叉 |
write 数据 > PIPE_BUF 且管道空间不够 | 阻塞直到有足够空间 | 大写入可能阻塞 |
设置了 O_NONBLOCK | 立即返回 -1,errno = EAGAIN | 非阻塞模式 |
PIPE_BUF 的原子性保证——小于等于 PIPE_BUF 的写入不会被其他写入者的数据交叉。这是多进程同时写 FIFO 的基础:
// 多个进程同时向同一 FIFO 写日志——PIPE_BUF 保证每行完整
// 进程 A: write(fd, "[A] log message\n", 15); // < PIPE_BUF → 原子
// 进程 B: write(fd, "[B] log message\n", 15); // 同上
// FIFO 内容: "[A] log message\n[B] log message\n" 或相反顺序
// 不会出现: "[A] log [B] log message\nmessage\n"
# 6.3 System V IPC
# 6.3.1 消息队列 msgget/msgsnd/msgrcv
System V 消息队列是内核中的链表结构——每个消息有类型(mtype),接收方可按类型选择接收:
#include <sys/msg.h>
struct my_msg {
long mtype; // 必须:消息类型(> 0)
char text[256]; // 消息体
};
// 创建/获取消息队列
key_t key = ftok("/tmp/msq_key", 'A'); // 生成 IPC 键
int msqid = msgget(key, IPC_CREAT | 0666);
// 发送
struct my_msg msg = {.mtype = 1};
strcpy(msg.text, "hello");
msgsnd(msqid, &msg, strlen(msg.text) + 1, 0);
// 接收(按类型接收)
struct my_msg rcv;
ssize_t n = msgrcv(msqid, &rcv, sizeof(rcv.text), 1, 0); // 只收 mtype=1
printf("got: %s\n", rcv.text);
msgrcv 的类型匹配规则:
msgtyp 值 | 行为 |
|---|---|
0 | 接收队列中第一条消息(无视类型) |
> 0 | 接收第一条指定类型的消息 |
< 0 | 接收队列中类型 ≤ |msgtyp| 的最小类型消息 |
消息队列 vs FIFO:
| 特性 | FIFO | SysV 消息队列 |
|---|---|---|
| 消息边界 | 无(字节流) | 有(天然按消息分界) |
| 优先级/类型 | 无 | 有(mtype 多路分发) |
| 持久性 | 进程退出自动清理 | 内核级持久(如不显式删除,重启才清) |
| 容量 | 管道缓冲(64 KB) | 可配(msg_qbytes) |
# 6.3.2 共享内存 shmget/shmat/shmdt
共享内存是速度最快的 IPC——数据不经过内核中转,双方直接读写同一物理页:
#include <sys/shm.h>
// 创建共享内存段(4 KB)
key_t key = ftok("/tmp/shm_key", 'B');
int shmid = shmget(key, 4096, IPC_CREAT | 0666);
// 附加到进程地址空间
char *shared = (char *)shmat(shmid, NULL, 0);
// 进程 A 写入
strcpy(shared, "shared data");
shared[0] = 'X'; // 直接改写——无系统调用!
// 进程 B 读取(另一进程,同一 shmid)
printf("B sees: %s\n", shared); // "Xhared data"
// 解除附加
shmdt(shared);
// 标记删除(最后一个 detach 的进程真正释放)
shmctl(shmid, IPC_RMID, NULL);
共享内存为什么最快?
管道: 进程A → write (syscall) → 内核缓冲区 → read (syscall) → 进程B
消息队列: 进程A → msgsnd (syscall) → 内核链表 → msgrcv (syscall) → 进程B
共享内存: 进程A → 直接写 → 同一物理页 ← 直接读 ← 进程B
无 syscall!无拷贝!
共享内存的致命缺陷——无同步机制。进程 A 写了一半被进程 B 读到的是不完整数据。需要信号量(semaphore)或 futex 配合使用。
# 6.3.3 信号量 semget/semop 同步保护
SysV 信号量不是简单的 int——是一个"信号量集合",每个集合可含多个信号量:
#include <sys/sem.h>
// 创建含 1 个信号量的集合(初值 = 1 → 二元信号量/互斥锁)
key_t key = ftok("/tmp/sem_key", 'C');
int semid = semget(key, 1, IPC_CREAT | 0666);
semctl(semid, 0, SETVAL, 1); // 第 0 个信号量初值为 1
// P 操作(wait / down)——减 1,如果结果 < 0 则阻塞
struct sembuf op = {.sem_num = 0, .sem_op = -1, .sem_flg = 0};
semop(semid, &op, 1); // wait
// 临界区
shared->counter++;
// V 操作(post / up)——加 1,如有等待者唤醒之
op.sem_op = 1;
semop(semid, &op, 1); // post
// 删除集合
semctl(semid, 0, IPC_RMID);
sembuf 的 sem_op 三种取值:
sem_op | 操作 | 条件 |
|---|---|---|
> 0 | 加 sem_op 到当前值(V 操作) | 不阻塞 |
< 0 | 当前值 + sem_op ≥ 0 才通过;否则阻塞(P 操作) | 阻塞直到满足 |
= 0 | 等待信号量值为 0 | 阻塞直到值为 0 |
sem_flg 标志:
| 标志 | 含义 |
|---|---|
0 | 不满足条件则阻塞 |
IPC_NOWAIT | 不满足立即返回 EAGAIN |
SEM_UNDO | 进程退出时自动撤销操作(防止死锁) |
SEM_UNDO 的防死锁价值——如果进程在持锁期间崩溃,内核在清理进程资源时自动做逆向操作(+1 恢复信号量),防止其他进程永久阻塞。
# 6.3.4 System V IPC 的残留清理陷阱
System V IPC 资源是内核级持久对象——不随进程退出而自动释放(共享内存在有 attach 者时不释放,消息队列和信号量集永不自动释放)。
典型事故:
进程 A 创建消息队列,msgsnd 一条消息,异常崩溃
→ 消息队列仍在内核中 → msgrcv 的人永远等不到下一条
→ 后续重启的进程 A 又创建一个新的消息队列
→ 旧队列永远残留 → 占用内核内存
→ 长期运行后 → ipcs 显示 500 条废弃消息队列
查看和清理:
# 查看所有 SysV IPC 资源
ipcs # 全部
ipcs -q # 消息队列
ipcs -m # 共享内存
ipcs -s # 信号量
# 删除指定资源
ipcrm -q <msqid>
ipcrm -m <shmid>
ipcrm -s <semid>
预防措施——进程退出前显式销毁:
// 1. 用 atexit 注册清理函数
void cleanup_ipc() {
msgctl(msqid, IPC_RMID, NULL);
shmctl(shmid, IPC_RMID, NULL);
semctl(semid, 0, IPC_RMID);
}
atexit(cleanup_ipc);
// 2. shmctl IPC_RMID 后立即 shmdt——最后一个 detach 的进程触发真正释放
shmctl(shmid, IPC_RMID, NULL);
shmdt(shared);
// 3. Systemd 服务中,用 IPCPrivateNamespace=yes 隔离 IPC 命名空间
// 服务停止时整个命名空间的 IPC 资源自动清理
# 6.4 Unix Domain Socket
# 6.4.1 与 TCP Socket 的差异
Unix Domain Socket(UDS)是本地进程通信的事实标准——拥有和 TCP 相同的 API,但没有网络协议栈开销:
// 服务端
int srv = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr = {.sun_family = AF_UNIX};
strcpy(addr.sun_path, "/tmp/my.sock");
unlink(addr.sun_path); // 清理上次残留
bind(srv, (struct sockaddr *)&addr, sizeof(addr));
listen(srv, 5);
int client = accept(srv, NULL, NULL);
char buf[256];
read(client, buf, sizeof(buf));
close(client);
close(srv);
unlink(addr.sun_path);
TCP vs UDS 性能根源:
| 维度 | TCP localhost | Unix Domain Socket |
|---|---|---|
| 网络协议栈 | 完整经过(IP/TCP 层) | 完全绕过 |
| 数据拷贝次数 | 2-3 次(user↔kernel↔sk_buff) | 1 次(user↔kernel) |
| 校验和 | 计算 | 不计算 |
| iptables/netfilter | 经过 | 不经过 |
| 吞吐量(同机) | ~10-30 Gbps | ~40-80 Gbps |
| 延迟(同机) | 5-15 μs | 2-5 μs |
| SCM_RIGHTS | ❌ | ✅ |
| SO_PEERCRED | ❌ | ✅(获取对端 PID/UID/GID) |
获取对端进程凭证:
struct ucred cred;
socklen_t len = sizeof(cred);
getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &cred, &len);
printf("peer: pid=%d uid=%d gid=%d\n", cred.pid, cred.uid, cred.gid);
这是实现权限校验的利器——不同于 TCP 连接中完全无法验证对端是谁。
# 6.4.2 SOCK_STREAM vs SOCK_DGRAM
SOCK_STREAM(字节流):面向连接,保证顺序,无消息边界。
SOCK_DGRAM(数据报):无连接,保留消息边界,但受限于 SO_SNDBUF 大小:
// SOCK_DGRAM 服务端(无需 listen/accept)
int fd = socket(AF_UNIX, SOCK_DGRAM, 0);
bind(fd, (struct sockaddr *)&addr, sizeof(addr));
struct sockaddr_un client_addr;
socklen_t len = sizeof(client_addr);
char buf[4096];
ssize_t n = recvfrom(fd, buf, sizeof(buf), 0,
(struct sockaddr *)&client_addr, &len);
选择规则:
| 场景 | SOCK_STREAM | SOCK_DGRAM |
|---|---|---|
| 数据量 > 64 KB | ✅ | ❌(超出缓冲区会截断) |
| 需要消息边界 | ❌ | ✅ |
| 顺序有要求 | ✅ | ⚠️(不保证,但 UDS 下极少乱序) |
| 多客户端服务 | ✅(accept 天然多对多) | ⚠️(需一个 socket 绑定不同路径) |
# 6.4.3 抽象命名空间 abstract namespace
UDS 路径存在文件系统中——进程退出后残留的 socket 文件没人清理。抽象命名空间解决了这个问题:
// 普通命名空间:sun_path = "/tmp/my.sock" → 文件系统中可见
// 抽象命名空间:sun_path[0] = '\0' → 无文件系统实体!
struct sockaddr_un addr = {.sun_family = AF_UNIX};
addr.sun_path[0] = '\0';
strcpy(addr.sun_path + 1, "my_abstract_sock"); // 名字不占文件系统
socklen_t addr_len = offsetof(struct sockaddr_un, sun_path)
+ 1 + strlen("my_abstract_sock");
bind(fd, (struct sockaddr *)&addr, addr_len);
抽象命名空间的好处:
- 进程退出后自动消失(无残留文件)
- 不占文件系统 inode
- 不受文件系统权限控制(全靠 socket 层权限)
- 适合嵌入式系统中
/tmp是 tmpfs(重启清零)的场景
# 6.4.4 传递文件描述符 SCM_RIGHTS
SCM_RIGHTS 是 UDS 的杀手级功能——通过 socket 在两个进程间传递文件描述符:
// 发送方:把共享内存 fd 发给对端
void send_fd(int sock, int fd_to_send) {
struct msghdr msg = {0};
char buf[CMSG_SPACE(sizeof(int))];
memset(buf, 0, sizeof(buf));
struct iovec io = {.iov_base = "F", .iov_len = 1}; // 最少 1 字节数据
msg.msg_iov = &io;
msg.msg_iovlen = 1;
msg.msg_control = buf;
msg.msg_controllen = sizeof(buf);
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
cmsg->cmsg_level = SOL_SOCKET;
cmsg->cmsg_type = SCM_RIGHTS;
cmsg->cmsg_len = CMSG_LEN(sizeof(int));
memcpy(CMSG_DATA(cmsg), &fd_to_send, sizeof(int));
if (sendmsg(sock, &msg, 0) < 0) perror("sendmsg");
}
// 接收方:拿到发送方传来的 fd
int recv_fd(int sock) {
struct msghdr msg = {0};
char data;
struct iovec io = {.iov_base = &data, .iov_len = 1};
msg.msg_iov = &io;
msg.msg_iovlen = 1;
char cbuf[CMSG_SPACE(sizeof(int))];
msg.msg_control = cbuf;
msg.msg_controllen = sizeof(cbuf);
if (recvmsg(sock, &msg, 0) < 0) { perror("recvmsg"); return -1; }
struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg);
int fd;
memcpy(&fd, CMSG_DATA(cmsg), sizeof(int));
return fd;
}
SCM_RIGHTS 在本章案例中的应用:
1. 后台服务进程创建共享内存(shm_open + ftruncate + mmap)
2. 后台服务把共享内存 fd 通过 UDS + SCM_RIGHTS 发给 UI 进程
3. UI 进程接收 fd → mmap 同一 shm → 双方读写同一物理内存
4. 通知机制:后台服务写完后通过 UDS 发一个通知(1 字节即可)
UI 进程在 epoll 中同时监听 UDS 可读 + shared memory 用信号量同步
# 6.5 mmap 共享内存
# 6.5.1 MAP_SHARED vs MAP_PRIVATE
mmap 可以创建进程间的共享内存映射:
// 共享映射:修改对其它映射者可见,写入回磁盘
void *shared = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
// 私有映射(COW):修改私有化,不影响其它进程/磁盘
void *priv = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_PRIVATE, fd, 0);
| 特性 | MAP_SHARED | MAP_PRIVATE |
|---|---|---|
| 修改对其他进程可见 | ✅ | ❌(COW 后各自独立) |
| 修改写入磁盘 | ✅(msync 确保) | ❌ |
父子进程 fork 后 | 双方看到同一物理页 | fork 时为 SHARED,写时 COW |
| 用途 | 进程间共享数据 | 加载只读数据 + 局部修改 |
# 6.5.2 匿名映射与文件映射
文件映射(有 fd):mmap 文件的某段内容到内存——数据持久化在磁盘中。
匿名映射(无 fd):MAP_ANONYMOUS 映射的是"虚无"——等价于 malloc 但可共享给子进程:
// 匿名共享映射——父子进程共享同一块内存(无文件)
void *anon = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
// fork 后——子进程的 anon 指针指向同一物理页
pid_t pid = fork();
if (pid == 0) {
strcpy((char *)anon, "child wrote this");
} else {
wait(NULL);
printf("parent sees: %s\n", (char *)anon); // "child wrote this"
}
shm_open——POSIX 共享内存(比 SysV 更现代):
#include <sys/mman.h>
#include <fcntl.h>
// POSIX 共享内存 = /dev/shm 下的文件 + mmap
int fd = shm_open("/my_shm", O_RDWR | O_CREAT, 0666);
ftruncate(fd, 4096);
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 多进程间通过同一 shm name 共享 ptr
shm_open 底层是 /dev/shm/ tmpfs 上的文件——天然享受内核的 page cache 和回写机制。
# 6.5.3 多进程之间的同步协议
共享内存 + 信号量 = 完整的 IPC 解决方案。以下是"单生产者单消费者"的标准协议:
// 协议:
// write_pos:生产者写入的位置(被写入的数据对消费者可见)
// read_pos: 消费者读取的位置(缓冲区空间对生产者可用)
// 信号量:s_empty(空间数)和 s_full(数据项数)
struct shm_ring {
volatile uint32_t write_pos;
volatile uint32_t read_pos;
sem_t s_empty; // 初始值为 BUFFER_SIZE
sem_t s_full; // 初始值为 0
char data[BUFFER_SIZE];
};
void producer(struct shm_ring *ring, char c) {
sem_wait(&ring->s_empty); // 等空间
ring->data[ring->write_pos % BUFFER_SIZE] = c;
__sync_synchronize(); // 内存屏障(ARM 必加!)
ring->write_pos++;
sem_post(&ring->s_full); // 通知消费者
}
char consumer(struct shm_ring *ring) {
sem_wait(&ring->s_full); // 等数据
char c = ring->data[ring->read_pos % BUFFER_SIZE];
__sync_synchronize();
ring->read_pos++;
sem_post(&ring->s_empty); // 通知生产者(空间释放)
return c;
}
关键——内存屏障:ARM 是弱内存模型——write_pos++ 在 ARM 上可能被重排到 ring->data[...] = c 之前!消费者读到 write_pos 已增加但 data 还是旧值。__sync_synchronize()(等价 dmb ish)阻止这种重排。
性能优化的进一步方案——把信号量换成 futex:sem_wait/sem_post 每次都要系统调用。用 futex 实现用户态快速路径 + 竞争时陷入内核:
// 用户态自旋等待(无锁快速路径)
while (__sync_sub_and_fetch(&ring->write_pos, 0) == ring->read_pos) {
// 空,可选短暂的 cpu_relax()
}
// 拿到数据后:
__sync_add_and_fetch(&ring->read_pos, 1);
# 6.6 嵌入式 IPC 选型矩阵
# 6.6.1 五种 IPC 的性能对比表
实测数据(ARM Cortex-A7 @ 1 GHz,传输 1 KB 消息的端到端延迟):
| IPC 机制 | 延迟 | 吞吐 (MB/s) | 数据拷贝次数 | 适用数据量 |
|---|---|---|---|---|
| 匿名管道 (pipe) | ~8 μs | ~120 | 2(user↔kernel) | 小~中 |
| FIFO (命名管道) | ~8 μs | ~120 | 2 | 小~中 |
| SysV 消息队列 | ~12 μs | ~80 | 2 | 小(<= 8 KB/条) |
| Unix Domain Socket | ~5 μs | ~200 | 1 | 小~大 |
| mmap 共享内存 + 信号量 | ~2 μs | ~2000+ | 0 | 任意 |
mmap 的吞吐极限——不经过系统调用,性能接近 CPU 内存带宽(ARM Cortex-A7 DDR3 ~2 GB/s)。唯一瓶颈是同步信号量的开销(每次 ~500 ns)。
# 6.6.2 不同场景的推荐方案
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 父子进程简单通信 | pipe / pipe2 | 零配置,fork 自动继承 |
| 无亲缘进程,小数据 | UDS (SOCK_STREAM) | 高吞吐,API 统一,支持 SCM_RIGHTS |
| 无亲缘进程,广播 | UDS (SOCK_DGRAM) + 抽象命名空间 | 消息边界保留 + 无文件残留 |
| 高频大数据(视频帧/传感器原始数据) | mmap 共享内存 + futex/POSIX 信号量 | 零拷贝,直通内存带宽 |
| 可恢复的消息队列 | SysV 消息队列 / POSIX mq | 内核持久,崩溃后可恢复 |
| 进程基础架构(DBus/服务总线) | UDS (SOCK_STREAM) | 标准化 + 多客户端 + 权限校验 |
| 只读共享配置 | mmap (MAP_SHARED) 文件 | 多进程自动同步(Page Cache 一致) |
| 日志多进程写 | FIFO + O_APPEND 等价的多进程写管道 | PIPE_BUF 原子性保证 |
# 6.6.3 Qt 跨进程方案与原生 Linux IPC 对比
| 方案 | 底层 | 适用 | 开发复杂度 | 性能 |
|---|---|---|---|---|
QSharedMemory | SysV shmget 或 shm_open(平台相关) | 同一机器的 Qt 进程间 | 低 | 最高 |
QDBus | UDS | 桌面 Linux 标准 IPC | 中 | 中 |
QLocalSocket / QLocalServer | UDS | 同一机器 | 低 | 高 |
QProcess + 标准 I/O | 匿名管道 | 父子进程 | 低 | 中 |
| 原生 POSIX shm + futex | mmap + futex | 性能极致需求 | 高 | 最高 |
Qt 嵌入式推荐:
- 通用跨进程数据共享 →
QLocalSocket(UDS 的高层封装) - 高频传感器数据 →
QSharedMemory+QSystemSemaphore - Qt 与第三方非 Qt 进程通信 → 原生
shm_open+ mmap + POSIX 信号量
# 6.7 关键结论与速查表
核心结论五条:
- mmap 共享内存是最快的 IPC——数据不经过内核中转,零拷贝;代价是必须自行实现同步协议
- UDS 是同机 IPC 的最佳通用方案——性能仅次于共享内存,API 兼容 TCP,天然支持 SCM_RIGHTS 和 SO_PEERCRED
- SysV IPC 是遗留技术——资源不随进程退出自动清理,容易残留;POSIX
shm_open+ mmap 是更现代的选择 - 管道适用于父子进程——
pipe是最简单的 IPC,但受限于单向、无消息边界、仅限亲缘 - 嵌入式 IPC 优先 UDS + mmap——UDS 做控制通道(通知、fd 传递),mmap 做数据通道(零拷贝大数据传输)
五种 IPC 速查表:
| 机制 | 亲缘关系 | 消息边界 | 持久性 | 双向 | 最适数据量 |
|---|---|---|---|---|---|
pipe | 仅父子 | 无 | 进程级 | 否 | < 64 KB |
| FIFO | 任意 | 无 | 进程级(文件残留) | 否 | < 64 KB |
| SysV 消息队列 | 任意 | 有 | 内核级 | 可以(mtype 多路复用) | < 8 KB/条 |
| UDS | 任意 | 无/有(DGRAM) | 进程级(可文件残留) | ✅ | 任意 |
| mmap 共享内存 | 任意 | 无(自行实现) | 文件级或进程级 | ✅ | 任意 |
IPC 选型决策树:
需要持久化(崩溃恢复)?
├─ 是 → SysV 消息队列 / POSIX mq
└─ 否 → 数据量?
├─ < 4 KB → UDS (简单,性能够)
├─ 4 KB - 1 MB → UDS (依然是最好的)
└─ > 1 MB → mmap 共享内存 + UDS(控制通道)
高频小数据?
└─ 是 → mmap 环形缓冲区 + futex 通知
常见陷阱速查:
| 陷阱 | 根因 | 解决 |
|---|---|---|
| SysV IPC 资源泄漏 | 进程异常退出未调 IPC_RMID | atexit 清理 / IPC 命名空间隔离 |
| FIFO 残留 inode | 进程退出前未 unlink | 用抽象命名空间 UDS 替代 |
| 共享内存数据竞争 | 无同步机制 | 信号量 / futex + 内存屏障 |
| SCM_RIGHTS fd 泄露 | 接收方忘记 close | 接收后立即 dup 再关原始 fd |
| 管道写阻塞 | 管道缓冲区满 | 设 O_NONBLOCK 或消费端加速 |
| TCP localhost 延迟高 | 经过完整网络协议栈 | 换成 UDS |
下一步:把你的智能家居中控的 TCP localhost 通信替换为 UDS + mmap 环形缓冲区——用 SCM_RIGHTS 传递共享内存 fd,验证延迟是否从 15ms 降到 0.5ms 以内。
延伸阅读:
man 7 unix—— Unix Domain Socket 详解man 2 shm_open—— POSIX 共享内存man 7 mq_overview—— POSIX 消息队列man 3 sem_overview—— POSIX 信号量- 《The Linux Programming Interface》(Kerrisk) —— 第 43-48、54-57 章