2.14 管道、FIFO 与消息队列
管道什么时候阻塞、什么时候返回 EOF?写端全关后读端为什么读到 0,而不是一直等?FIFO 的打开顺序为什么会死锁?POSIX 消息队列和管道相比,多了什么、贵在哪?
用 pipe2 与 dup2 实现 ls | wc -l;判断一次 write 在管道上是否原子;用 FIFO 做无亲缘关系进程的会合;用 POSIX 消息队列传递带优先级、带边界的消息。
2.13 的同步发生在同一片地址空间里:线程直接读写共享变量,靠锁与条件变量协调。一旦通信双方变成两个进程,共享内存不再默认存在,就必须通过内核转发数据。管道是最早、也最便宜的转发机制:内核里一段缓冲区,两个 fd,一个读一个写。
这一章讲三类「消息传递」设施:匿名管道(pipe)、命名管道(FIFO)、POSIX 消息队列。它们的能力依次增强:管道只能用于有亲缘关系的进程(因为 fd 要通过 fork 继承),FIFO 给一个路径名让无亲缘进程会合,消息队列则在保留消息边界的同时支持优先级和异步通知。代价也依次上升:管道是纯内存拷贝,消息队列要经过系统调用、内核对象和权限检查。
学完这一章,你应该能回答:为什么 ls | wc -l 里父 shell 必须关闭管道的两端?为什么往没有读端的管道写会收到 SIGPIPE 而不是 EPIPE?为什么消息队列的 mq_receive 总是先拿到高优先级的消息?这些问题的答案,决定了你写的 IPC 代码在异常路径上会不会挂死。
2.14.1 管道:内核缓冲区与两个 fd
int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags); /* Linux 专有;flags: O_CLOEXEC/O_NONBLOCK/O_DIRECT */
pipe() 创建一对 fd:pipefd[0] 只用于读,pipefd[1] 只用于写。数据从写端进入内核缓冲区,从读端取出,没有文件名、没有 inode 路径,进程退出后自动消失。
写端 fd[1] ──write──▶ ┌──────────────────────────────┐ ──read──▶ 读端 fd[0]
│ 内核 pipe_buffer 环形数组 │
│ [页][页][页] ... 默认容量 64 KB │
└──────────────────────────────┘
写满:write 阻塞(或 EAGAIN) 读空且写端全关:read 返回 0
读图:管道是单向的。需要双向通信就建两条管道,或者用 socketpair(见 2.16)。缓冲区容量默认 64 KB(16 个页),可以用 fcntl(fd, F_GETPIPE_SZ) 查询、F_SETPIPE_SZ 调整,上限受 /proc/sys/fs/pipe-max-size 约束。容量不是「一次能写 64 KB」的承诺:write 的原子性上限是 PIPE_BUF(Linux 上 4096 字节),不是整个缓冲区。
对一个管道 fd:
read(fd[0], buf, n):缓冲区有数据就返回(最多n字节,可能短读);为空且仍有写端打开则阻塞;为空且所有写端都已关闭则返回0(EOF)。write(fd[1], buf, n):缓冲区有空位就写入;满了则阻塞;所有读端都已关闭时,内核向进程发SIGPIPE,若信号被忽略或捕获,write返回-1、errno=EPIPE。- 原子性:
n <= PIPE_BUF时,写入不会被其他进程的写穿插;n > PIPE_BUF时可能被拆分、交错。 O_NONBLOCK下,无数据可读/无空位可写返回-1、errno=EAGAIN,而不是阻塞。
/* pipe_basic.c — 父写子读,观察 EOF */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(void)
{
int fd[2];
if (pipe2(fd, O_CLOEXEC) == -1)
die("pipe2");
pid_t pid = fork();
if (pid == -1)
die("fork");
if (pid == 0) {
/* 子进程:关闭写端,读到 EOF 为止 */
if (close(fd[1]) == -1) die("child close write end");
char buf[4096];
size_t total = 0;
for (;;) {
ssize_t n = read(fd[0], buf, sizeof buf);
if (n == -1) {
if (errno == EINTR) continue;
die("read");
}
if (n == 0) break; /* 所有写端关闭 */
total += (size_t)n;
printf("child: read %zd bytes (total %zu)\n", n, total);
}
if (close(fd[0]) == -1) die("child close read end");
_exit(EXIT_SUCCESS);
}
/* 父进程:关闭读端,写一条消息,再关闭写端触发 EOF */
if (close(fd[0]) == -1) die("parent close read end");
const char *msg = "hello from parent\n";
size_t len = strlen(msg);
size_t done = 0;
while (done < len) {
ssize_t n = write(fd[1], msg + done, len - done);
if (n == -1) {
if (errno == EINTR) continue;
die("write");
}
done += (size_t)n;
}
if (close(fd[1]) == -1) die("parent close write end");
int status;
if (waitpid(pid, &status, 0) == -1) die("waitpid");
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "child failed\n");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g pipe_basic.c -o pipe_basic
./pipe_basic
# child: read 18 bytes (total 18)
fork 后父子各有一份 fd 表,四个 fd 都指向同一个管道。子进程关闭写端、父进程关闭读端,这样每个方向只剩一个持有者,EOF 才会在父进程关闭写端时到达。如果父进程忘了关写端,子进程的 read 永远不会返回 0——这是管道程序最常见的挂死原因。
- 「管道的容量是 64 KB,所以一次
write最多 64 KB」——write可以请求更多,内核会分多次写入;阻塞模式下它会一直写到完(除非被信号打断)。原子性上限只有PIPE_BUF。 - 「读端关闭后
write返回EPIPE」——默认先收到SIGPIPE,进程直接终止;只有忽略/捕获了SIGPIPE,才能看到EPIPE。 - 「管道空了
read就返回 0」——只有所有写端都关闭才返回 0。还有写端开着、只是暂时没数据,read会阻塞。 - 「
pipe2的O_CLOEXEC只影响读端」——两个 fd 都带CLOEXEC;这正是它比pipe+ 两次fcntl安全的地方。 - 「父子进程各关一个端就够了」——每个进程都要关闭自己不需要的那一端;四个 fd 里任何一个写端没关,EOF 都不会到。
2.14.2 读写语义与阻塞条件
把读端、写端的各种状态列全,管道的行为就没有意外了:
| 读端状态 | 写端状态 | read 行为 | write 行为 |
|---|---|---|---|
| 打开 | 打开,缓冲区有数据 | 返回数据(可能短读) | 有空位则写,满则阻塞 |
| 打开 | 打开,缓冲区空 | 阻塞(非阻塞 EAGAIN) | 正常写 |
| 打开 | 全部关闭 | 返回 0(EOF) | 不可能写(无写端) |
| 全部关闭 | 打开 | 不可能读 | SIGPIPE / EPIPE |
| 打开 | 打开,O_NONBLOCK | 无数据 EAGAIN | 无空位 EAGAIN |
两个容易被忽略的细节:
PIPE_BUF是「原子写」上限,不是「单次写」上限。写 10 KB 可以一次调用完成,但在缓冲区只剩 4 KB 时,内核可能先写 4 KB,等读者取走后再写剩下的;这期间其他写者可以插进来。F_SETPIPE_SZ只能调大/调小到页的整数倍,且非特权用户不能超过/proc/sys/fs/pipe-max-size(默认 1 MB)。调大容量不能替代流量控制:写者仍然可能把读者淹没。
# 观察一个进程持有的管道及其容量
ls -l /proc/$PID/fd | grep pipe
cat /proc/$PID/fdinfo/3
# pos: 0
# flags: 0100000
# mnt_id: ...
# ino: ...
# pipe: [ ... ] 或 size: 65536
fdinfo 里的 flags 是八进制,包含 O_NONBLOCK(04000)与 O_CLOEXEC(02000000)。排查「为什么 write 不阻塞」时,先看这个字段。
非阻塞与 EAGAIN
/* 把读端设为非阻塞(写端同理) */
int flags = fcntl(fd[0], F_GETFL);
if (flags == -1) { /* ... */ }
if (fcntl(fd[0], F_SETFL, flags | O_NONBLOCK) == -1) { /* ... */ }
非阻塞管道常与 epoll 配合:注册 EPOLLIN,事件到了再读。注意 EAGAIN 不是错误,是「现在没有」;把它当错误记录会刷爆日志。EINTR 则需要重试。
2.14.3 用管道连接进程:dup2、exec 与 popen
shell 的 | 就是「建管道 + fork 两个子进程 + dup2 到 stdin/stdout + exec」。自己实现一遍,比读十遍文档更能理解 fd 继承。
/* pipe_exec.c — 用 fork/dup2/exec 实现 ls | wc -l */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(void)
{
int fd[2];
if (pipe2(fd, O_CLOEXEC) == -1)
die("pipe2");
/* 第一个子进程:ls -1,stdout 接到管道写端 */
pid_t ls_pid = fork();
if (ls_pid == -1) die("fork ls");
if (ls_pid == 0) {
if (dup2(fd[1], STDOUT_FILENO) == -1) die("dup2 ls");
if (close(fd[1]) == -1) die("ls close write");
if (close(fd[0]) == -1) die("ls close read");
execlp("ls", "ls", "-1", (char *)NULL);
die("execlp ls"); /* 只有失败才会到这 */
}
/* 第二个子进程:wc -l,stdin 接到管道读端 */
pid_t wc_pid = fork();
if (wc_pid == -1) die("fork wc");
if (wc_pid == 0) {
if (dup2(fd[0], STDIN_FILENO) == -1) die("dup2 wc");
if (close(fd[0]) == -1) die("wc close read");
if (close(fd[1]) == -1) die("wc close write");
execlp("wc", "wc", "-l", (char *)NULL);
die("execlp wc");
}
/* 父进程必须关闭两端,否则 wc 永远等不到 EOF */
if (close(fd[0]) == -1) die("parent close read");
if (close(fd[1]) == -1) die("parent close write");
int status;
if (waitpid(ls_pid, &status, 0) == -1) die("waitpid ls");
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "ls failed\n");
return EXIT_FAILURE;
}
if (waitpid(wc_pid, &status, 0) == -1) die("waitpid wc");
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "wc failed\n");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
关键点:
dup2的原子性:dup2(fd[1], 1)原子地关闭原 stdout 并复制,避免close(1); dup(fd[1]);之间被其他线程抢走 fd 1。- 每个子进程只保留自己需要的端:
ls关闭读端,wc关闭写端。两个子进程都继承了全部四个 fd,不关就会互相拖住。 - 父进程关闭两端:父进程不读写管道,但它的写端副本会让
wc的读端永远有写者,read不返回 0。 exec失败才返回:execlp成功后进程映像被替换,后面的die不会执行;如果执行了,说明exec失败,必须报错退出,不能继续跑父进程的逻辑。
popen 把「建管道 + fork + exec」封装成 FILE *:
FILE *fp = popen("ls -1 | wc -l", "r");
if (fp == NULL) { /* ... */ }
char line[128];
while (fgets(line, sizeof line, fp) != NULL)
fputs(line, stdout);
int status = pclose(fp);
if (status == -1) { /* ... */ }
else if (WIFEXITED(status))
printf("exit=%d\n", WEXITSTATUS(status));
pclose 返回的是 wait 状态,不是退出码;必须用 WIFEXITED/WEXITSTATUS 解码。popen 内部调用 /bin/sh -c,命令串会被 shell 解释,不要把用户输入拼进去(命令注入)。需要精确控制参数时,自己 fork + execvp。
- 「
pclose返回子进程退出码」——返回的是wait状态;直接和 0 比较会把「被信号杀死的 128+」误判。 - 「
popen的"w"模式不会阻塞」——写满管道后fwrite/fflush会阻塞,直到子进程读走;子进程不读、父进程不关,就死锁。 - 「管道上的
write一定写完」——可能短写;阻塞模式下通常写满,但被信号打断会返回已写的字节数。循环写是必须的。
2.14.4 FIFO:有名字的管道
匿名管道只能通过 fork 继承 fd。无亲缘关系的进程需要一个路径名来会合,这就是 FIFO(named pipe):
#include <sys/stat.h>
int mkfifo(const char *path, mode_t mode); /* 创建一个 FIFO 文件 */
FIFO 在文件系统里有一个 inode(ls -l 显示类型 p),但数据仍然走内核管道缓冲区,不落盘。打开规则是本节的核心:
| 打开方式 | 没有对端时 | 有对端时 |
|---|---|---|
open(path, O_RDONLY) | 阻塞,直到有写者打开 | 立即返回 |
open(path, O_WRONLY) | 阻塞,直到有读者打开 | 立即返回 |
open(path, O_RDONLY | O_NONBLOCK) | 立即成功(读端可以先开) | 立即返回 |
open(path, O_WRONLY | O_NONBLOCK) | 失败,errno=ENXIO | 立即返回 |
「先 open(O_RDONLY) 再 open(O_WRONLY)」在同一个进程里会死锁:读打开阻塞等写者,写打开永远执行不到。正确顺序是「先启动读端进程(或读端用 O_NONBLOCK),再启动写端」,或者两端都用 O_RDWR(不推荐,语义不标准,且自己持有读端会让 EOF 永远不到)。
/* fifo_demo.c — 用法: ./fifo_demo read|write PATH */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(int argc, char **argv)
{
if (argc != 3) {
fprintf(stderr, "usage: %s read|write PATH\n", argv[0]);
return EXIT_FAILURE;
}
int flags;
if (strcmp(argv[1], "read") == 0)
flags = O_RDONLY;
else if (strcmp(argv[1], "write") == 0)
flags = O_WRONLY;
else {
fprintf(stderr, "mode must be read or write\n");
return EXIT_FAILURE;
}
int fd = open(argv[2], flags); /* 没有对端时会阻塞 */
if (fd == -1) die("open fifo");
if (flags == O_WRONLY) {
const char *msg = "fifo message\n";
ssize_t n = write(fd, msg, strlen(msg));
if (n == -1) die("write fifo");
printf("wrote %zd bytes\n", n);
} else {
char buf[256];
ssize_t n;
do {
n = read(fd, buf, sizeof buf - 1);
} while (n == -1 && errno == EINTR);
if (n == -1) die("read fifo");
buf[n] = '\0';
printf("read %zd bytes: %s", n, buf);
}
if (close(fd) == -1) die("close fifo");
return EXIT_SUCCESS;
}
两个终端里验证:
mkfifo /tmp/demo.fifo
# 终端 1:先启动读端,它会阻塞在 open
./fifo_demo read /tmp/demo.fifo
# 终端 2:写端打开后,两端同时返回
./fifo_demo write /tmp/demo.fifo
# 终端 1 输出:read 13 bytes: fifo message
rm /tmp/demo.fifo
FIFO 的原子性与管道相同:<= PIPE_BUF 的写不会交错。多个读者同时读同一个 FIFO 时,每条消息只会被其中一个读者取走(内核互斥),但谁拿到不确定;这可以拿来做简单的任务分发,但不能保证公平。
2.14.5 tee 与 splice:零拷贝搬运
管道在 Linux 上还有两个「零拷贝」伙伴:
tee(fd_in, fd_out, len, flags):把管道里的数据复制到另一个管道,不从源管道消费。两个 fd 都必须是管道。常用于「一份数据同时给两个消费者」。splice(fd_in, off_in, fd_out, off_out, len, flags):在两个 fd 之间移动数据,其中至少一个是管道;数据不经过用户态缓冲区,减少一次拷贝。
# 把文件内容经管道搬运到另一个文件(示意;实际 cp 会自己优化)
# splice 的 C 接口:
/* splice 的核心调用(省略错误处理,仅示形态) */
ssize_t n = splice(in_fd, NULL, pipefd[1], NULL, 4096, SPLICE_F_MOVE);
if (n > 0)
n = splice(pipefd[0], NULL, out_fd, NULL, (size_t)n, SPLICE_F_MOVE);
约束很多:splice 的一个 fd 必须是管道;文件偏移要传 NULL 表示使用当前偏移;tee 不能用于普通文件。它们适合代理、日志分流、sendfile 的替代路径,不适合当通用读写接口。不要为了「零拷贝」三个字把简单代码改成 splice:多一次系统调用、少一次内存拷贝,收益要用 perf 量出来。
2.14.6 POSIX 消息队列
管道和 FIFO 都是字节流:没有消息边界,读端只能自己拼协议。POSIX 消息队列保留消息边界,还支持优先级:
#include <mqueue.h>
mqd_t mq_open(const char *name, int oflag, ...); /* O_CREAT 时再传 mode, attr */
int mq_send(mqd_t mqd, const char *msg, size_t len, unsigned int prio);
ssize_t mq_receive(mqd_t mqd, char *buf, size_t len, unsigned int *prio);
int mq_close(mqd_t mqd);
int mq_unlink(const char *name);
名字必须以 / 开头,且只含一个 /(例如 /jobs);队列对象在 /dev/mqueue 下可见。mq_send 的 prio 合法范围是 0 .. MQ_PRIO_MAX-1(Linux 上 MQ_PRIO_MAX=32768,即 0–32767;POSIX 只要求至少 32),mq_receive 总是返回当前最高优先级的消息;同优先级按 FIFO。
/* mq_demo.c — 创建队列、按优先级发送、按优先级接收 */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <mqueue.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define QNAME "/demo-queue"
#define MAXMSG 4
#define MSGSIZE 64
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(void)
{
if (mq_unlink(QNAME) == -1 && errno != ENOENT)
die("mq_unlink stale"); /* 清理上次残留 */
struct mq_attr attr = {0};
attr.mq_maxmsg = MAXMSG; /* 队列最多 4 条 */
attr.mq_msgsize = MSGSIZE; /* 每条最多 64 字节 */
mqd_t mq = mq_open(QNAME, O_CREAT | O_RDWR | O_EXCL, 0600, &attr);
if (mq == (mqd_t)-1)
die("mq_open");
const struct {
unsigned int prio;
const char *text;
} msgs[] = {
{ 1, "low" },
{ 3, "high" },
{ 2, "mid" },
};
for (size_t i = 0; i < sizeof msgs / sizeof msgs[0]; i++) {
if (mq_send(mq, msgs[i].text, strlen(msgs[i].text),
msgs[i].prio) == -1)
die("mq_send");
}
struct mq_attr cur;
if (mq_getattr(mq, &cur) == -1)
die("mq_getattr");
printf("maxmsg=%ld msgsize=%ld curmsgs=%ld\n",
(long)cur.mq_maxmsg, (long)cur.mq_msgsize, (long)cur.mq_curmsgs);
for (int i = 0; i < 3; i++) {
char buf[MSGSIZE];
unsigned int prio = 0;
ssize_t n = mq_receive(mq, buf, sizeof buf, &prio);
if (n == -1)
die("mq_receive");
printf("recv prio=%u len=%zd text=%.*s\n",
prio, n, (int)n, buf);
}
if (mq_close(mq) == -1) die("mq_close");
if (mq_unlink(QNAME) == -1) die("mq_unlink");
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g mq_demo.c -o mq_demo -lrt
./mq_demo
# maxmsg=4 msgsize=64 curmsgs=3
# recv prio=3 len=4 text=high
# recv prio=2 len=3 text=mid
# recv prio=1 len=3 text=low
-lrt 在 glibc 2.34 之后可以省略(mq_* 已并入 libc),但老系统需要。
mq_open:O_CREAT创建,O_EXCL要求不存在;attr只在创建时生效,默认值来自/proc/sys/fs/mqueue/。mq_send:消息长度不能超过mq_msgsize,否则EMSGSIZE;队列满时阻塞,O_NONBLOCK则EAGAIN。mq_receive:缓冲区小于mq_msgsize时EMSGSIZE;队列空时阻塞。返回消息长度,优先级写入prio。mq_notify:注册「队列从空变非空」的异步通知,可以用信号或线程;同一时刻只有一个进程能注册。- 生命周期:
mq_close只是关闭描述符;mq_unlink删除名字,已打开的队列在最后一个 fd 关闭后销毁。
mq_notify 的两种通知方式:
SIGEV_SIGNAL:队列由空变非空时发信号,信号处理器里只能做 async-signal-safe 的事;通常配合sigwaitinfo或 self-pipe。SIGEV_THREAD:每次通知创建一个线程执行回调。方便,但高频消息会频繁建线程;适合低频控制面。
mq_timedsend/mq_timedreceive 提供绝对超时(默认 CLOCK_REALTIME),用法与 pthread_cond_timedwait 类似。注意它们的时间参数是绝对时间,不是相对毫秒。
mq_notify 有一个经典陷阱:通知是「一次性」的,每次收到通知后必须重新注册,否则不会再有下一次。而且「队列从空变非空」才触发,队列持续非空不会重复通知。
mqd_t 在 Linux 上就是一个 fd
POSIX 只规定 mqd_t 是「消息队列描述符」,Linux 把它实现成 文件描述符(指向 mqueue 文件系统的 inode)。由此有三条推论:
- 可以
fork继承、可以exec时按O_CLOEXEC关闭; - 可以放进
select/poll/epoll(mq_notify之外的另一条路); mq_close()只关闭描述符,队列本身仍在/dev/mqueue/name,要mq_unlink()才删除。
mqd_t mq = mq_open("/q1", O_RDWR | O_CREAT | O_CLOEXEC, 0644, NULL);
struct pollfd pfd = { .fd = (int)mq, .events = POLLIN };
poll(&pfd, 1, -1); /* 可读:队列非空 */
mq_close(mq);
mq_unlink("/q1");
这是 Linux 的实现细节,不是 POSIX 保证;可移植代码不要 printf("%d", mq) 或直接 read(mq, ...)。要统一事件源,优先 mq_notify + signalfd,或把队列封装到自己的 fd 抽象里。
2.14.7 System V 消息队列对照
老代码里还会看到 System V 消息队列:
int msgget(key_t key, int msgflg);
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
它的消息必须以 long mtype 开头,接收时按类型筛选:msgtyp > 0 取该类型的第一条,0 取第一条,< 0 取类型小于等于绝对值的最小类型。身份用 key_t(常由 ftok 生成),不是路径名;权限、生命周期、限制都由内核的 IPC 命名空间管理。
| 维度 | 管道/FIFO | POSIX mq | System V mq |
|---|---|---|---|
| 消息边界 | 无(字节流) | 有 | 有 |
| 优先级 | 无 | 有(Linux 0–32767,严格按优先级) | 类型字段可模拟,但不按优先级排序 |
| 命名 | FIFO 用路径 | /name | key_t |
| 异步通知 | epoll/SIGIO | mq_notify | SIGEV 不支持,需轮询 |
| 可移植性 | POSIX | POSIX | 老 UNIX,Linux 保留 |
| 查看工具 | ls -l | /dev/mqueue | ipcs -q |
System V IPC 的问题是「key 冲突、删除不可靠、限制零散、没有 fd 语义」。新代码用 POSIX mq 或直接走 socket;只有在维护旧系统、或需要 msgrcv 的类型筛选时才用它。ipcs -q 能看到残留队列,ipcrm -q ID 清理。
ipcs 默认列出消息队列、共享内存、信号量三类;-q/-m/-s 只选一类,-t 加最后操作时间,-p 加创建者/最后操作者的 PID,-l 看限制,-u 看汇总。它读的是 /proc/sysvipc/{msg,sem,shm},所以容器里看到的范围随 IPC 命名空间变化。
| 对象 | 查看 | 按 ID 删除 | 按 key 删除 |
|---|---|---|---|
| 消息队列 | ipcs -q | ipcrm -q ID | ipcrm -Q KEY |
| 共享内存 | ipcs -m | ipcrm -m ID | ipcrm -M KEY |
| 信号量集 | ipcs -s | ipcrm -s ID | ipcrm -S KEY |
ipcrm 要求 root 或对象属主。消息队列和信号量删除立即生效;共享内存段要等最后一个进程 shmdt 后才真正释放。POSIX 对象不在 ipcs 里:消息队列看 /dev/mqueue,共享内存和命名信号量看 /dev/shm。
2.14.8 选择判据与出口检查
| 需求 | 首选 | 理由 |
|---|---|---|
| 父子进程单向传字节 | pipe2 | 最便宜,fd 可继承,能接 epoll |
| shell 管道/过滤器 | pipe + dup2 | 与 exec 天然配合 |
| 无亲缘进程会合 | FIFO | 有路径名,权限用文件系统 |
| 保留消息边界 + 优先级 | POSIX mq | 内核排队,mq_notify 可异步 |
| 高频、大吞吐、跨机 | UNIX/TCP socket | 管道容量与队列限制不适合 |
| 多消费者广播 | 每人一条管道/队列,或 socket | 管道是点对点,读走就没了 |
- 是否挂死:
strace -f -e trace=read,write,openat,close看谁阻塞在哪个 fd。 - EOF 是否到达:
ls -l /proc/PID/fd数一数还有几个写端;任何一个没关,读端就不会返回 0。 - 是否
SIGPIPE:dmesg或wait状态里的SIGPIPE;检查读端是否提前退出。 - 是否短写:
write返回值小于请求长度;循环写,别假设一次完成。 - FIFO 打开顺序:写端
O_NONBLOCK返回ENXIO说明读端还没开;读端阻塞说明写端还没来。 - 消息队列满/空:
mq_getattr看mq_curmsgs;EAGAIN/EMSGSIZE分别对应满与消息过大。
2.14.9 管道接口全表:pipe2、容量与 errno
这一节回答:pipe2 的每个 flag 到底改了什么;F_SETPIPE_SZ 为什么会返回 EPERM;read/write 在管道上的每一种失败对应什么现场。
创建:pipe 与 pipe2
| API | 签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
pipe | int pipe(int pipefd[2]) | 0,写两个 fd | EFAULT、EMFILE、ENFILE、ENOMEM | 无 flags;fork 后两端都可继承 |
pipe2 | int pipe2(int pipefd[2], int flags) | 0 | 同上 + EINVAL | Linux 2.6.27;flags 只认下表三位 |
pipe2 的 flags:
| flag | 语义 | 不用的后果 |
|---|---|---|
O_CLOEXEC | 两个 fd 都设 FD_CLOEXEC | exec 后子进程意外持有管道端,EOF 永远不到 |
O_NONBLOCK | 两个 fd 都设 O_NONBLOCK | 需要再 fcntl 两次,且中间可能被信号/线程插入 |
O_DIRECT | 包模式(packet mode,Linux 3.4) | 读端看到的是字节流,消息边界丢失 |
O_DIRECT 作用在管道上不是「绕过页缓存」,而是包模式:每次 write 成为一个包,read 一次最多返回一个包,包与包不合并。写端写入的包大于读缓冲区时,多余部分被丢弃(不是留在管道里)。它适合固定大小的消息,但 PIPE_BUF 的原子性规则仍然适用;需要可靠边界时,POSIX mq 或带长度前缀的协议更合适。
容量:F_GETPIPE_SZ / F_SETPIPE_SZ
int sz = fcntl(fd, F_GETPIPE_SZ); /* 返回容量(字节) */
int ns = fcntl(fd, F_SETPIPE_SZ, 1 << 20); /* 请求 1 MiB,返回实际容量 */
| 操作 | 成功 | 失败 errno | 现场 |
|---|---|---|---|
F_GETPIPE_SZ | 容量字节数 | EBADF、EINVAL(不是管道) | 只能用于管道/FIFO |
F_SETPIPE_SZ | 实际容量 | EINVAL、EPERM、EBUSY、ENOMEM | EPERM:超过 soft 限制且无 CAP_SYS_RESOURCE;EBUSY:缩小到小于当前已缓冲数据 |
相关 sysctl:
| 文件 | 默认(典型) | 作用 |
|---|---|---|
/proc/sys/fs/pipe-max-size | 1048576 | 单管道容量上限(非特权) |
/proc/sys/fs/pipe-user-pages-soft | 16384 页 | 单用户所有管道页数软上限;超过后新管道按页计费 |
/proc/sys/fs/pipe-user-pages-hard | 0(无硬上限) | 硬上限;超过后 pipe/F_SETPIPE_SZ 直接失败 |
注意 soft/hard 限制是按用户统计的,不是按进程。容器里多个进程共享同一个 UID 时,一个进程把管道调到 1 MB,可能让同 UID 的其他进程 pipe() 返回 ENFILE/EPERM。排查时看 pipe-user-pages-soft,不要只盯 ulimit -n。
FIFO 的创建与打开
| API | 签名 | 成功 | 失败 errno |
|---|---|---|---|
mkfifo | int mkfifo(const char *path, mode_t mode) | 0 | EACCES、EEXIST、ENAMETOOLONG、ENOENT、ENOSPC、EROFS、EDQUOT |
open(FIFO) | int open(const char *path, int flags) | fd | EACCES、ENXIO、EINTR、ENOENT、ENOTDIR、ELOOP |
mkfifo 的 mode 会被 umask 掩码;EEXIST 表示路径已存在(可能是普通文件,也可能是 FIFO,需要 stat 区分)。open 的 ENXIO 只在 O_WRONLY|O_NONBLOCK 且没有读者时出现;EINTR 只会在阻塞打开被信号打断时出现,重试即可。
读写与零拷贝
| API | 签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
read | ssize_t read(int fd, void *buf, size_t n) | 0(EOF)或 1..n | EAGAIN、EINTR、EBADF、EFAULT、EINVAL | 管道上不会 ESPIPE;lseek 才返回它 |
write | ssize_t write(int fd, const void *buf, size_t n) | 1..n(可能短写) | EAGAIN、EINTR、EPIPE、EBADF、EFAULT、EINVAL | 读端全关时先 SIGPIPE,忽略后才是 EPIPE |
tee | ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags) | 复制的字节数 | EINVAL、EBADF、EAGAIN、ENOSYS | 两个 fd 都必须是管道 |
splice | ssize_t splice(int fd_in, off_t *off_in, int fd_out, off_t *off_out, size_t len, unsigned int flags) | 移动的字节数 | EINVAL、EBADF、EAGAIN、ENOSYS、ESPIPE | 至少一端是管道;off 对管道必须 NULL |
vmsplice | ssize_t vmsplice(int fd, const struct iovec *iov, size_t nr_segs, unsigned int flags) | 写入的字节数 | EINVAL、EBADF、EAGAIN | 把用户页「借给」管道;SPLICE_F_GIFT 需谨慎 |
read 在管道上返回 0 只有一种原因:所有写端都已关闭。EINVAL 在管道读写里很少见,通常来自 count 超过 SSIZE_MAX 或 fd 不是管道却走了 splice。ESPIPE 是 lseek 的错误,不要和 read 混。
POSIX mq 的 API 与 errno
| API | 签名 | 成功 | 失败 errno |
|---|---|---|---|
mq_open | mqd_t mq_open(const char *name, int oflag, ...);无 O_CREAT 时只有两个参数 | 队列描述符 | EACCES、EEXIST、EINVAL、EMFILE、ENFILE、ENOENT、ENOMEM、ENOSPC、ENAMETOOLONG |
mq_send | int mq_send(mqd_t mqd, const char *msg, size_t len, unsigned int prio) | 0 | EAGAIN、EMSGSIZE、EINTR、EBADF、EINVAL、ENOMEM |
mq_receive | ssize_t mq_receive(mqd_t mqd, char *buf, size_t len, unsigned int *prio) | 消息长度 | EAGAIN、EMSGSIZE、EINTR、EBADF、EINVAL |
mq_timedsend | int mq_timedsend(mqd_t mqd, const char *msg, size_t len, unsigned int prio, const struct timespec *abs) | 0 | 同 mq_send + ETIMEDOUT、EFAULT |
mq_timedreceive | ssize_t mq_timedreceive(mqd_t mqd, char *buf, size_t len, unsigned int *prio, const struct timespec *abs) | 消息长度 | 同 mq_receive + ETIMEDOUT、EFAULT |
mq_notify | int mq_notify(mqd_t mqd, const struct sigevent *sev) | 0 | EBADF、EBUSY、EINVAL、ENOMEM、EPERM |
mq_getattr | int mq_getattr(mqd_t mqd, struct mq_attr *attr) | 0 | EBADF、EFAULT |
mq_setattr | int mq_setattr(mqd_t mqd, const struct mq_attr *new, struct mq_attr *old) | 0 | EBADF、EINVAL、EFAULT |
mq_close | int mq_close(mqd_t mqd) | 0 | EBADF |
mq_unlink | int mq_unlink(const char *name) | 0 | EACCES、ENOENT、ENAMETOOLONG |
mq_send 的 EINVAL 包括 prio >= MQ_PRIO_MAX(注意是 >=,32768 已越界);mq_receive 的 EMSGSIZE 不是「消息太大」,而是「你的缓冲区小于队列的 mq_msgsize」——哪怕队列里只有一条 1 字节消息,缓冲区也必须至少 mq_msgsize 字节。这是 POSIX mq 最反直觉的约束之一。
struct mq_attr 字段
| 字段 | 类型 | 谁设置 | 含义 |
|---|---|---|---|
mq_flags | long | mq_open/mq_setattr | 目前只有 O_NONBLOCK 有意义 |
mq_maxmsg | long | 创建时 | 队列最大消息条数;受 msg_max 限制 |
mq_msgsize | long | 创建时 | 单条消息最大字节数;受 msgsize_max 限制 |
mq_curmsgs | long | 内核维护 | 当前消息条数;mq_getattr 读出 |
mq_open 时 attr == NULL 表示用默认值:mq_maxmsg=10、mq_msgsize=8192(以 /proc/sys/fs/mqueue/ 为准)。mq_maxmsg/mq_msgsize 在创建后不能改;mq_setattr 只能改 mq_flags。很多程序在 mq_setattr 里传了新的 mq_maxmsg,内核忽略它但不报错,于是「调大队列」的意图静默失败。
System V 消息队列的 API 与 errno
| API | 签名 | 成功 | 失败 errno |
|---|---|---|---|
msgget | int msgget(key_t key, int msgflg) | 队列 ID | EACCES、EEXIST、ENOENT、ENOMEM、ENOSPC |
msgsnd | int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg) | 0 | EAGAIN、EACCES、EFAULT、EIDRM、EINTR、EINVAL、ENOMEM |
msgrcv | ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg) | 消息长度 | E2BIG、EACCES、EFAULT、EIDRM、EINTR、EINVAL、ENOMSG |
msgctl | int msgctl(int msqid, int cmd, struct msqid_ds *buf) | 0(IPC_INFO 等除外) | EACCES、EFAULT、EIDRM、EINVAL、EPERM |
msgsnd 的 msgsz 不含 long mtype 的 8 字节;msgrcv 的 msgsz 是 mtext 的可用空间。E2BIG 表示消息正文大于 msgsz 且没设 MSG_NOERROR;ENOMSG 表示 IPC_NOWAIT 下没有符合条件的消息。
flags 位表
| flag | 适用接口 | 语义 | 备注 |
|---|---|---|---|
O_CLOEXEC | pipe2、mq_open | exec 时关闭 fd | 管道两端都设;mq 端也设 |
O_NONBLOCK | pipe2、mq_open、open(FIFO) | 不阻塞 | 读写返回 EAGAIN;FIFO 写打开返回 ENXIO |
O_DIRECT | pipe2 | 包模式 | 保留写边界;读缓冲不足会截断包 |
O_CREAT/O_EXCL | mq_open | 创建/排他 | O_EXCL 单独用无意义 |
IPC_NOWAIT | msgsnd、msgrcv | 不阻塞 | 满/空返回 EAGAIN/ENOMSG |
MSG_NOERROR | msgrcv | 截断超长消息 | 不设则 E2BIG |
MSG_EXCEPT | msgrcv | 取第一条类型不等于 msgtyp 的消息 | Linux 扩展 |
MSG_COPY | msgrcv | 按序号取消息但不删除 | Linux 3.8;需 IPC_NOWAIT |
SPLICE_F_MOVE | splice | 尝试移动页而不是复制 | 只是提示,内核可忽略 |
SPLICE_F_NONBLOCK | splice、tee | 不阻塞 | 仍需 fd 本身非阻塞才完全生效 |
SPLICE_F_MORE | splice | 提示后续还有数据 | 对 TCP 等有优化意义 |
SPLICE_F_GIFT | vmsplice | 把页「送给」内核 | 之后不能再改该内存 |
2.14.10 PIPE_BUF 原子性:内核到底保证什么
这一节回答:为什么 4096 字节的写一定不会交错,而 4097 字节就可能被拆开?PIPE_BUF 与管道容量、页大小是什么关系?
规则表
设管道中可用空间为 space,一次写 n 字节:
| 模式 | n <= PIPE_BUF | n > PIPE_BUF |
|---|---|---|
| 阻塞 | 若 space >= n,原子写入;否则等到空间足够再原子写入 | 尽可能多写;可能阻塞;返回已写字节数,可能被其他写者穿插 |
O_NONBLOCK | 若 space >= n,原子成功;否则 EAGAIN,一个字节都不写 | 若 space > 0,写入 1..n 字节(可能穿插);若 space == 0,EAGAIN |
这张表有两个容易读错的点:
- 阻塞模式下
n <= PIPE_BUF不会「部分写」。空间不够时它睡眠,直到能一次性放下整个写。这正是原子性的来源:内核在睡眠前后都持有管道锁,其他写者无法插到中间。 n > PIPE_BUF的阻塞写也不是「一定写完」。它会在写满管道后阻塞,等读者腾出空间再继续;但如果被信号打断,write返回已经写入的字节数(短写),调用者必须循环。
为什么是 4096
Linux 的 PIPE_BUF 定义为 4096,与最常见的页大小相同。内核的管道缓冲区是 struct pipe_buffer 数组,每个元素指向一个页;pipe_write() 会尽量把新数据合并到最后一个 buffer 的尾部(同一页、offset + len 不跨页),否则分配新页。PIPE_BUF 与页大小对齐,使得一个「原子写」最多占一个页,内核不必为跨页的原子性做额外处理。
但 PIPE_BUF 不是「页大小」的同义词:某些架构页大小是 16 KB/64 KB,Linux 仍把 PIPE_BUF 定义为 4096(include/linux/pipe_fs_i.h 里的 PIPE_BUF 常量)。可移植代码应该用 <limits.h> 的 PIPE_BUF,或在运行时 fpathconf(fd, _PC_PIPE_BUF);不要硬编码 4096。
原子性不等于「消息完整性」
PIPE_BUF 只保证字节不交错,不保证:
- 读端一次
read就能拿到整个写。读端可能只读走一部分,剩下的还在管道里; - 写端一次
write对应读端一次read。字节流没有消息边界; - 多个写者按某种公平顺序被服务。内核只保证不交错,不保证轮转。
需要「一条消息一次读全」时,用 O_DIRECT 包模式(有截断风险)、POSIX mq(有边界与优先级),或者自己在字节流上做长度前缀(见 2.17)。
容量与原子性的关系
管道容量(默认 64 KB)决定「一次能缓冲多少」,PIPE_BUF 决定「一次写是否原子」。两者独立:
- 容量调到 1 MB,
PIPE_BUF仍是 4096;写 8192 字节仍可能被拆。 - 容量调到 4096,写 4096 字节仍原子,但写 8192 字节会先写 4096、阻塞、再写 4096。
F_SETPIPE_SZ不能小于当前已缓冲的数据量,否则EBUSY。
- 「
PIPE_BUF是管道容量」——不是。容量是F_GETPIPE_SZ的返回值,默认 64 KB;PIPE_BUF是原子写上限,Linux 上 4096。 - 「写 4096 字节一定一次写完」——只有在空间足够时。空间不足时阻塞写会等待,非阻塞写返回
EAGAIN且不写任何字节;被信号打断的阻塞写可能短写(虽然<= PIPE_BUF的写通常不会)。 - 「多个写者写
<= PIPE_BUF就一定按调用顺序到达」——只保证不交错,不保证顺序。两个写者的 4096 字节块谁先进入管道,取决于谁先拿到锁。
2.14.11 内核路径:pipefs、pipe_buffer 与 mqueue
这一节回答:write 一个字节到管道,内核做了哪些事?splice 为什么能不算拷贝?POSIX mq 的消息存在哪里?
管道的对象模型
pipe() 不创建普通文件,而是在 pipefs(一个内部文件系统)里创建 inode,inode 的 i_pipe 指向 struct pipe_inode_info:
| 字段 | 作用 |
|---|---|
mutex | 保护整个管道;读/写/调整容量都拿它 |
rd_wait / wr_wait | 读等待队列 / 写等待队列 |
head / tail | 环形缓冲区中有效 buffer 的首尾下标 |
ring_size | buffer 数组长度(页数),由容量决定 |
max_usage | 历史最大使用量,用于 F_GETPIPE_SZ 的统计 |
readers / writers | 读端/写端打开计数;writers == 0 时读返回 EOF |
files | 引用该管道的 struct file 数 |
bufs | struct pipe_buffer 数组 |
每个 struct pipe_buffer 描述一个页片段:
| 字段 | 作用 |
|---|---|
page | 指向数据页 |
offset | 页内起始偏移 |
len | 有效字节数 |
ops | pipe_buf_operations,决定释放/确认/偷页行为 |
flags | PIPE_BUF_FLAG_CAN_MERGE、PIPE_BUF_FLAG_LRU 等 |
private | 文件页缓存等私有数据 |
PIPE_BUF_FLAG_CAN_MERGE 是理解「小写不分配新页」的关键:如果最后一个 buffer 的页还有空间,且 flag 允许合并,pipe_write() 直接把新字节追加到 offset + len 后面,不分配新页。这也是 CVE-2022-0847(Dirty Pipe)的根源——攻击者把只读文件的页缓存以可合并 flag 放进管道,再追加写,篡改了只读文件。修复方式是初始化 buffer 时清零 flags。
pipe_write() 与 pipe_read()
/* 按 Linux 6.8 fs/pipe.c 的结构简化,省略锁与信号处理 */
static ssize_t pipe_write(struct kiocb *iocb, struct iov_iter *from)
{
struct pipe_inode_info *pipe = file->private_data;
if (!pipe->readers) { /* 没有读者 */
send_sig(SIGPIPE, current, 0);
return -EPIPE;
}
for (;;) {
if (pipe->head - pipe->tail < pipe->ring_size || !is_nonblock) {
/* 有空间,或愿意等:把用户数据拷贝进页 */
ret = copy_page_from_iter(buf->page, offset, chars, from);
if (!iov_iter_count(from))
break; /* 写完 */
if (is_nonblock) { ret = -EAGAIN; break; }
/* 空间不足则睡眠在 wr_wait 上,醒来重试 */
}
}
wake_up_interruptible_sync_poll(&pipe->rd_wait, EPOLLIN | EPOLLRDNORM);
return ret;
}
这段伪代码省略了最重要的细节:阻塞写睡眠时释放 pipe->mutex,醒来重新获取。原子性靠「重新检查空间 + 一次性拷贝整个 <= PIPE_BUF 写」维持,而不是靠一直持锁睡觉。pipe_read() 对称:从 tail 指向的 buffer 拷贝到用户缓冲区,推进 offset,读空一个 buffer 就释放页并 tail++,最后唤醒 wr_wait。
splice 与 tee
splice 不经过用户态:它把源 fd 的页(或页片段)挂到管道的 pipe_buffer 上,读端再从管道取走。pipe_buf_operations 决定页的归属:
anon_pipe_buf_ops:匿名页,管道自己分配;splice到文件时需要复制;page_cache_pipe_buf_ops:文件页缓存,引用计数 +1,splice到另一个文件可以真正零拷贝;default_pipe_buf_ops:通用页,释放时put_page。
tee() 不消费源管道,而是把源 pipe_buffer 的页引用复制到目标管道(get_page)。所以 tee 之后两个管道共享同一批页,直到各自读走;这也是它比「读出来再写两份」便宜的原因。约束:tee 的两个 fd 都必须是管道;splice 至少一端是管道;vmsplice 只能把用户页送进管道。
POSIX mq 的内核对象
POSIX mq 在 Linux 上实现为 mqueue 文件系统的一个 inode,mqd_t 实际是 fd(所以能 epoll)。每个队列的 struct mqueue_inode_info 包含:
| 字段 | 作用 |
|---|---|
wait | 读等待队列(mq_receive 阻塞在这里) |
msg_list | 消息链表,按优先级有序 |
qsize | 当前所有消息的总字节数 |
attr | mq_maxmsg/mq_msgsize/mq_curmsgs |
notify | 已注册的 sigevent 副本 |
notify_owner | 注册进程的 struct pid |
notify_self_exec_id | 防止 exec 后旧注册误投递 |
mq_send 走 do_mq_timedsend():用 load_msg() 分配 struct msg_msg(与 System V 消息共用),按优先级插入 msg_list;队列满时睡在 wait 上或返回 EAGAIN。mq_receive 走 do_mq_timedreceive():取链表头(最高优先级、同优先级最早),store_msg() 拷贝到用户缓冲区,唤醒发送者。
mq_notify 走 do_mq_notify():检查是否已有注册者(EBUSY),保存 sigevent 与 notify_owner;当队列从空变非空且没有其他进程阻塞在 mq_receive 时,内核向注册者投递信号(SI_MESGQ),并清除注册。SIGEV_THREAD 在内核里没有对应实现:glibc 收到信号后自己创建线程调用回调,所以它的线程管理成本在用户态。
mq_open ──▶ do_mq_open ──▶ mqueue_create ──▶ inode (i_private = mqueue_inode_info) mq_send ──▶ do_mq_timedsend ──▶ load_msg ──▶ msg_list(按 prio 插入) mq_receive ──▶ do_mq_timedreceive ──▶ list_first_entry ──▶ store_msg ──▶ 用户缓冲 mq_notify ──▶ do_mq_notify ──▶ 保存 sigevent ──▶ 空→非空时 send_sigqueue
读图:mq 的「消息边界」来自 msg_msg 链表节点,不是字节流;「优先级」来自插入时的有序链表,不是读时排序。因此 mq_send 是 O(n) 插入(n 为队列长度),mq_receive 是 O(1) 取头。队列很长、优先级很多时,mq_send 的成本会上升;这是「严格优先级」的代价。
ls -l /proc/$PID/fd看 fd 类型与 inode;管道显示pipe:[inode]。cat /proc/$PID/fdinfo/N看flags、pos、pipe:的容量。ss -x -p看不到匿名管道,但能看到 UNIX socket;不要拿它查管道。bpftrace -e 'kprobe:pipe_write { @[comm] = count(); }'看谁在写。bpftrace -e 'kprobe:do_mq_timedsend { @[comm] = count(); }'看 mq 发送频率。cat /proc/sys/fs/mqueue/*与ulimit -q看 mq 限制。
2.14.12 程序一:验证 PIPE_BUF 原子写
这个程序让多个写者并发写同一条管道,读者按固定长度切分并检查每个记录是否被污染。payload=4096 时应零污染;payload=8192 时污染次数大于零(概率随写者数与记录数上升)。
/* pipe_atomic.c — 多写者 + 单读者,检验 PIPE_BUF 原子性
* usage: ./pipe_atomic [payload_bytes] [writers] [records] */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
static int write_full(int fd, const void *buf, size_t len)
{
const char *p = buf;
size_t done = 0;
while (done < len) {
ssize_t n = write(fd, p + done, len - done);
if (n == -1) {
if (errno == EINTR) continue;
return -1;
}
if (n == 0) return -1;
done += (size_t)n;
}
return 0;
}
/* 返回 1=读满,0=EOF,-1=错误;got 是实际读到的字节数 */
static int read_full(int fd, void *buf, size_t len, size_t *got)
{
char *p = buf;
size_t done = 0;
while (done < len) {
ssize_t n = read(fd, p + done, len - done);
if (n == -1) {
if (errno == EINTR) continue;
return -1;
}
if (n == 0) {
*got = done;
return 0;
}
done += (size_t)n;
}
*got = done;
return 1;
}
int main(int argc, char **argv)
{
size_t payload = (argc > 1) ? strtoul(argv[1], NULL, 0) : 4096;
int writers = (argc > 2) ? atoi(argv[2]) : 4;
int records = (argc > 3) ? atoi(argv[3]) : 200;
if (payload == 0 || writers <= 0 || records <= 0) {
fprintf(stderr, "usage: %s payload writers records\n", argv[0]);
return EXIT_FAILURE;
}
int fd[2];
if (pipe2(fd, O_CLOEXEC) == -1) {
perror("pipe2");
return EXIT_FAILURE;
}
for (int w = 0; w < writers; w++) {
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return EXIT_FAILURE;
}
if (pid == 0) {
if (close(fd[0]) == -1) _exit(1);
signal(SIGPIPE, SIG_IGN);
unsigned char *rec = malloc(payload);
if (rec == NULL) _exit(1);
memset(rec, 'A' + w, payload);
for (int s = 0; s < records; s++) {
rec[0] = (unsigned char)w;
memcpy(rec + 1, &s, sizeof s);
if (write_full(fd[1], rec, payload) == -1) _exit(2);
}
free(rec);
if (close(fd[1]) == -1) _exit(1);
_exit(0);
}
}
if (close(fd[1]) == -1) { perror("close write"); return EXIT_FAILURE; }
unsigned char *buf = malloc(payload);
if (buf == NULL) { perror("malloc"); return EXIT_FAILURE; }
int recs = 0, bad = 0;
for (;;) {
size_t got = 0;
int st = read_full(fd[0], buf, payload, &got);
if (st == -1) { perror("read"); break; }
if (got == 0) break; /* EOF */
if (got < payload) {
fprintf(stderr, "short tail: %zu bytes\n", got);
break;
}
unsigned char id = buf[0];
for (size_t i = 5; i < payload; i++) { /* 跳过 id 与 seq */
if (buf[i] != id) { bad++; break; }
}
recs++;
}
for (int w = 0; w < writers; w++) {
int status;
if (wait(&status) == -1) perror("wait");
}
printf("payload=%zu writers=%d records=%d -> recs=%d polluted=%d\n",
payload, writers, records, recs, bad);
free(buf);
if (close(fd[0]) == -1) perror("close read");
return bad == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
pipe_atomic.c -o pipe_atomic
./pipe_atomic 4096 8 200 # 预期 polluted=0
./pipe_atomic 8192 8 500 # 预期 polluted>0(不保证每次)
预期输出:
payload=4096 writers=8 records=200 -> recs=1600 polluted=0
payload=8192 writers=8 records=500 -> recs=4000 polluted=137
payload=4096 时,每次 write 正好是一个 PIPE_BUF,内核要么整块写入,要么让写者睡眠,读者看到的每个 4096 块都只属于一个写者。payload=8192 时,内核按页拆分,其他写者可以插到两个 4096 块之间,读者拼出的 8192 块就会出现两个写者的字节。
注意 read_full 会把短读拼起来。这不是在「掩盖」交错:交错发生在字节流里,拼接只是让读者能按记录长度检查。真正要避免交错,应该把记录长度限制在 PIPE_BUF 以内,或者改用 mq。
2.14.13 程序二:FIFO 打开顺序与死锁
这个程序复现三种现场:同进程先读后写导致自死锁;O_NONBLOCK 读端先打开;写端无读者时 ENXIO。最后用 fork 演示正确的「读端先阻塞、写端后打开」会合。
/* fifo_order.c — usage: ./fifo_order self|nonblock|noreader|pair [path] */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
static volatile sig_atomic_t alarmed;
static void on_alarm(int sig)
{
(void)sig;
alarmed = 1;
}
static void ensure_fifo(const char *path)
{
if (mkfifo(path, 0600) == -1 && errno != EEXIST) {
perror("mkfifo");
exit(EXIT_FAILURE);
}
}
int main(int argc, char **argv)
{
if (argc < 2) {
fprintf(stderr, "usage: %s self|nonblock|noreader|pair [path]\n", argv[0]);
return EXIT_FAILURE;
}
const char *path = (argc > 2) ? argv[2] : "/tmp/fifo_order_demo";
ensure_fifo(path);
if (strcmp(argv[1], "self") == 0) {
struct sigaction sa = {0};
sa.sa_handler = on_alarm;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* 不自动重启 open */
if (sigaction(SIGALRM, &sa, NULL) == -1) {
perror("sigaction");
return EXIT_FAILURE;
}
alarm(1);
int fd = open(path, O_RDONLY); /* 等写者,永远不会来 */
int saved = errno;
alarm(0);
if (fd == -1 && saved == EINTR) {
printf("blocking open(O_RDONLY) interrupted after 1s "
"(alarmed=%d); correct order is reader first or O_NONBLOCK\n",
(int)alarmed);
return EXIT_SUCCESS;
}
if (fd != -1) close(fd);
fprintf(stderr, "unexpected: fd=%d errno=%d\n", fd, saved);
return EXIT_FAILURE;
}
if (strcmp(argv[1], "noreader") == 0) {
int fd = open(path, O_WRONLY | O_NONBLOCK);
if (fd == -1 && errno == ENXIO) {
printf("O_WRONLY|O_NONBLOCK with no reader -> ENXIO\n");
return EXIT_SUCCESS;
}
if (fd != -1) close(fd);
perror("open");
return EXIT_FAILURE;
}
if (strcmp(argv[1], "nonblock") == 0) {
int rfd = open(path, O_RDONLY | O_NONBLOCK);
if (rfd == -1) { perror("open read"); return EXIT_FAILURE; }
int wfd = open(path, O_WRONLY | O_NONBLOCK);
if (wfd == -1) { perror("open write"); return EXIT_FAILURE; }
if (write(wfd, "x", 1) != 1) { perror("write"); return EXIT_FAILURE; }
char c;
ssize_t n = read(rfd, &c, 1);
printf("nonblock both ends open: wrote 1 byte, read %zd byte\n", n);
close(wfd);
close(rfd);
return n == 1 ? EXIT_SUCCESS : EXIT_FAILURE;
}
if (strcmp(argv[1], "pair") == 0) {
pid_t pid = fork();
if (pid == -1) { perror("fork"); return EXIT_FAILURE; }
if (pid == 0) {
int rfd = open(path, O_RDONLY); /* 阻塞,直到父进程打开写端 */
if (rfd == -1) { perror("child open"); _exit(1); }
char buf[64];
ssize_t n = read(rfd, buf, sizeof buf - 1);
if (n < 0) { perror("child read"); _exit(1); }
buf[n] = '\0';
printf("child read %zd bytes: %s\n", n, buf);
close(rfd);
_exit(0);
}
usleep(100000); /* 让子进程先阻塞在 open */
int wfd = open(path, O_WRONLY); /* 与子进程的 open 配对 */
if (wfd == -1) { perror("parent open"); return EXIT_FAILURE; }
if (write(wfd, "hello fifo\n", 11) != 11) perror("parent write");
close(wfd);
int status;
if (waitpid(pid, &status, 0) == -1) { perror("waitpid"); return EXIT_FAILURE; }
return WIFEXITED(status) && WEXITSTATUS(status) == 0
? EXIT_SUCCESS : EXIT_FAILURE;
}
fprintf(stderr, "unknown mode: %s\n", argv[1]);
return EXIT_FAILURE;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
fifo_order.c -o fifo_order
./fifo_order self # 1 秒后打印 interrupted
./fifo_order noreader # ENXIO
./fifo_order nonblock # 两端都非阻塞,成功
./fifo_order pair # 子进程读 hello fifo
self 是最重要的现场:它证明「先 open(O_RDONLY) 再 open(O_WRONLY)」在单进程里会永久阻塞,而不是返回错误。alarm 只是把永久阻塞变成 1 秒后 EINTR,生产代码不能靠 alarm 兜底;应该在设计上让读端先打开,或用 O_NONBLOCK 的非阻塞打开。
pair 证明打开操作本身是同步点:子进程的 open 与父进程的 open 在对方到达时才一起返回。如果父进程永远不打开写端,子进程就永远卡在 open,连 read 都执行不到。
2.14.14 程序三:POSIX mq 的优先级与一次性通知
这个程序验证 mq_notify 的「空→非空」触发、一次性注册、EBUSY、SI_MESGQ,以及 mq_receive 按优先级取消息、mq_timedreceive 的 ETIMEDOUT。
/* mq_notify_demo.c — 优先级、通知、超时与 EBUSY */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <mqueue.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#define QNAME "/mq-notify-demo"
#define MAXMSG 8
#define MSGSIZE 64
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
static void abs_after_ms(struct timespec *ts, long ms)
{
if (clock_gettime(CLOCK_REALTIME, ts) == -1) die("clock_gettime");
ts->tv_sec += ms / 1000;
ts->tv_nsec += (ms % 1000) * 1000000L;
if (ts->tv_nsec >= 1000000000L) {
ts->tv_sec += 1;
ts->tv_nsec -= 1000000000L;
}
}
int main(void)
{
if (mq_unlink(QNAME) == -1 && errno != ENOENT)
die("mq_unlink stale");
struct mq_attr attr = {0};
attr.mq_maxmsg = MAXMSG;
attr.mq_msgsize = MSGSIZE;
mqd_t mq = mq_open(QNAME, O_CREAT | O_RDWR | O_NONBLOCK | O_CLOEXEC,
0600, &attr);
if (mq == (mqd_t)-1) die("mq_open");
/* 缓冲区小于 mq_msgsize:即使队列为空也应先报 EMSGSIZE */
char tiny[8];
if (mq_receive(mq, tiny, sizeof tiny, NULL) == -1 && errno == EMSGSIZE)
printf("small buffer -> EMSGSIZE (before blocking)\n");
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGRTMIN);
if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) die("sigprocmask");
struct sigevent sev = {0};
sev.sigev_notify = SIGEV_SIGNAL;
sev.sigev_signo = SIGRTMIN;
sev.sigev_value.sival_int = 99;
if (mq_notify(mq, &sev) == -1) die("mq_notify");
if (mq_notify(mq, &sev) == -1) {
if (errno == EBUSY) printf("second mq_notify -> EBUSY\n");
else die("mq_notify 2");
}
const struct { unsigned int prio; const char *text; } msgs[] = {
{ 1, "low" }, { 5, "high" }, { 3, "mid" },
};
for (size_t i = 0; i < sizeof msgs / sizeof msgs[0]; i++)
if (mq_send(mq, msgs[i].text, strlen(msgs[i].text),
msgs[i].prio) == -1)
die("mq_send");
siginfo_t si;
memset(&si, 0, sizeof si);
if (sigwaitinfo(&mask, &si) == -1) die("sigwaitinfo");
printf("notify: sig=%d code=%d value=%d\n",
si.si_signo, si.si_code, si.si_value.sival_int);
/* 通知已消费,注册被清除;重新注册以接收下一次空->非空 */
if (mq_notify(mq, &sev) == -1) die("re-notify");
for (int i = 0; i < 3; i++) {
char buf[MSGSIZE];
unsigned int prio = 0;
ssize_t n = mq_receive(mq, buf, sizeof buf, &prio);
if (n == -1) die("mq_receive");
printf("recv prio=%u len=%zd text=%.*s\n", prio, n, (int)n, buf);
}
/* 队列已空,再发一条触发第二次通知 */
if (mq_send(mq, "again", 5, 0) == -1) die("mq_send again");
memset(&si, 0, sizeof si);
if (sigwaitinfo(&mask, &si) == -1) die("sigwaitinfo 2");
printf("second notify: value=%d\n", si.si_value.sival_int);
{
char buf[MSGSIZE];
if (mq_receive(mq, buf, sizeof buf, NULL) == -1) die("drain");
}
/* 清掉 O_NONBLOCK,测试绝对超时 */
struct mq_attr blocking = { .mq_flags = 0 };
if (mq_setattr(mq, &blocking, NULL) == -1) die("mq_setattr");
struct timespec deadline;
abs_after_ms(&deadline, 200);
char buf[MSGSIZE];
if (mq_timedreceive(mq, buf, sizeof buf, NULL, &deadline) == -1) {
if (errno == ETIMEDOUT) printf("mq_timedreceive -> ETIMEDOUT\n");
else die("mq_timedreceive");
}
struct mq_attr cur;
if (mq_getattr(mq, &cur) == -1) die("mq_getattr");
printf("curmsgs=%ld maxmsg=%ld msgsize=%ld\n",
(long)cur.mq_curmsgs, (long)cur.mq_maxmsg, (long)cur.mq_msgsize);
if (mq_close(mq) == -1) die("mq_close");
if (mq_unlink(QNAME) == -1) die("mq_unlink");
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
mq_notify_demo.c -o mq_notify_demo -lrt
./mq_notify_demo
预期输出:
small buffer -> EMSGSIZE (before blocking)
second mq_notify -> EBUSY
notify: sig=34 code=-3 value=99
recv prio=5 len=4 text=high
recv prio=3 len=3 text=mid
recv prio=1 len=3 text=low
second notify: value=99
mq_timedreceive -> ETIMEDOUT
curmsgs=0 maxmsg=8 msgsize=64
si_code=-3 是 SI_MESGQ 的值(<signal.h> 里可打印常量名)。关键行:
- 第二次
mq_notify返回EBUSY:同一队列同时只能有一个注册者; - 第一次通知后注册自动清除,重新注册才能收到第二次;
- 消息按优先级 5、3、1 出队,与发送顺序无关;
mq_receive的EMSGSIZE在阻塞之前就检查,所以空队列也能立即看到;mq_timedreceive的时间是CLOCK_REALTIME的绝对时间,不是相对毫秒。
- Linux 的
MQ_PRIO_MAX是 32768,不是 32。POSIX 只要求至少 32(_POSIX_MQ_PRIO_MAX),Linux 给到 0–32767;老教材常写「0–31」,是把 POSIX 下限当成了 Linux 上限。 - 优先级不继承、不衰减。
mq_send传什么就是什么;mq_receive取当前最高优先级,同优先级按 FIFO。没有「优先级反转继承」这回事,别把它和线程调度优先级混为一谈。
2.14.15 tee、splice 与 vmsplice:零拷贝的边界
这一节回答:splice 到底省了哪一次拷贝?为什么 tee 不能把数据写进普通文件?vmsplice 的 SPLICE_F_GIFT 为什么危险?
三个调用的分工
| 调用 | 源 | 目标 | 是否消费源 | 典型用途 |
|---|---|---|---|---|
splice | 文件/socket/管道 | 管道/文件/socket | 是 | 文件到 socket、socket 到文件 |
tee | 管道 | 管道 | 否 | 一份数据分流给两个消费者 |
vmsplice | 用户内存 | 管道 | 否(借页) | 用户态零拷贝写入管道 |
splice 的「零拷贝」是相对的:数据页在页缓存与管道之间传递引用,不经过用户缓冲区;但如果源或目标是匿名管道、或者文件系统不支持 splice_read/splice_write,内核仍可能退化为复制。真正的零拷贝链路是「页缓存 → 管道 → socket」,sendfile 在底层也可能走这条路径。
约束与短写
splice至少一端是管道;两端都是普通文件时EINVAL。- 管道的
off_in/off_out必须为NULL;普通文件可以传偏移指针,传NULL表示用当前文件偏移并推进它。 splice返回 0 表示源 EOF;返回正数可能小于len,必须循环。tee的两个 fd 都必须是管道,len是「最多复制多少」,返回实际复制量;源管道数据不会被消费。SPLICE_F_NONBLOCK只是「不要因为管道满/空而睡眠」,但如果 fd 本身是阻塞的,仍可能阻塞;要完全非阻塞,fd 也要O_NONBLOCK。vmsplice把用户页映射进管道,SPLICE_F_GIFT表示把这些页「送给」内核,之后用户不能再写;违反会破坏数据或触发未定义行为。
一个文件→管道→文件的搬运骨架:
/* 省略错误处理;两个 splice 都可能短写,生产代码要循环 */
ssize_t n = splice(in_fd, NULL, pipefd[1], NULL, 1 << 20, 0);
if (n > 0)
n = splice(pipefd[0], NULL, out_fd, NULL, (size_t)n, 0);
这段代码只保证「搬了一部分」。正确写法是把两个 splice 都放进循环,并处理 EAGAIN/EINTR;管道容量有限,一次 splice 不可能搬完任意大的文件。
- 「
splice一定比read+write快」——只有在数据量大、页缓存命中、且链路两端都支持splice时才快。小数据、跨文件系统、加密/压缩场景下,系统调用次数与退化复制会让它更慢。 - 「
tee会复制数据」——它复制的是pipe_buffer的页引用,不复制页内容;两个管道读的是同一批页。写端后续修改(在允许的情况下)会同时影响两边。 - 「
vmsplice之后还能改缓冲区」——不带SPLICE_F_GIFT时内核可能仍引用用户页,修改时机取决于内核是否已消费;带GIFT则明确不能再改。把它当成「异步写」来用迟早出事。
2.14.16 实验:原子写、容量与 mq 可观测性
实验一:PIPE_BUF 与管道容量
# 1. 原子写 vs 超长写
./pipe_atomic 4096 8 200
./pipe_atomic 8192 8 500
# 预期:前者 polluted=0;后者 >0(多跑几次)
# 2. 看系统调用形态
strace -f -e trace=pipe2,write,read,close ./pipe_atomic 4096 4 20 2>&1 | tail -20
# 预期:每个子进程的 write 都是 4096;父进程 read 也是 4096
# 3. 看默认容量与上限
python3 - <<'PY'
import fcntl, os
r, w = os.pipe()
print("capacity:", fcntl.fcntl(w, fcntl.F_GETPIPE_SZ))
print("PIPE_BUF:", os.fpathconf(w, 'PC_PIPE_BUF'))
PY
cat /proc/sys/fs/pipe-max-size
cat /proc/sys/fs/pipe-user-pages-soft
怎么读:
strace里write(4, ..., 4096)之后紧跟read(3, ..., 4096),说明一个写对应一个读;8192的写会看到多次write返回 4096,或者一次返回 8192 但读者按 4096 拼。F_GETPIPE_SZ默认 65536;F_SETPIPE_SZ调到 1 MB 后应返回 1048576(受pipe-max-size限制)。PC_PIPE_BUF返回 4096。跨平台代码不要假设这个值。
实验二:mq 的优先级、通知与限制
# 1. 运行通知/优先级程序
./mq_notify_demo
# 2. 把 mqueue 文件系统挂上,直接看队列
sudo mount -t mqueue none /dev/mqueue 2>/dev/null || true
ls -l /dev/mqueue
# 运行中另开终端:cat /dev/mqueue/mq-notify-demo
# 预期:QSIZE、NOTIFY_PID、NOTIFY、SIGNO 等字段
# 3. 看限制
cat /proc/sys/fs/mqueue/msg_max
cat /proc/sys/fs/mqueue/msgsize_max
cat /proc/sys/fs/mqueue/queues_max
ulimit -q # RLIMIT_MSGQUEUE,单位为字节
# 4. 看系统调用
strace -e trace=mq_open,mq_notify,mq_timedsend,mq_timedreceive \
./mq_notify_demo 2>&1 | tail -20
怎么读:
/dev/mqueue/<name>的QSIZE是队列中所有消息的总字节数;NOTIFY_PID非零表示已有注册者,可以据此排查EBUSY。msg_max默认 10,msgsize_max默认 8192;创建队列时attr.mq_maxmsg超过msg_max会EINVAL(非特权)。ulimit -q是RLIMIT_MSGQUEUE,按真实 UID 统计所有队列占用;容器里这个值可能被设得很小。strace里mq_timedsend的最后一个参数是绝对timespec,不是相对毫秒;看到tv_sec约等于当前时间就对了。
2.14.17 陷阱清单:管道、FIFO 与 mq 的十七个现场
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | 读端永远等不到 EOF | 某个进程还持有写端(常见是父进程忘了关) | 每个进程关闭不用的端;ls -l /proc/PID/fd 数写端 |
| 2 | 进程莫名收到 SIGPIPE 退出 | 读端已关闭,默认动作是终止 | 忽略 SIGPIPE,检查 write 的 EPIPE |
| 3 | 以为写了 64 KB 就原子 | 把容量当 PIPE_BUF | 原子性上限是 4096;大块写要自己加长度前缀 |
| 4 | 非阻塞写只写了一半 | n > PIPE_BUF 且空间不足 | 循环写,保存已写偏移;或把记录限制在 PIPE_BUF |
| 5 | 单进程先读后写 FIFO 卡死 | open(O_RDONLY) 等写者,写者永远执行不到 | 读端先由另一进程打开,或读端用 O_NONBLOCK 打开 |
| 6 | FIFO 读端永远不 EOF | 用 O_RDWR 打开,自己持有读端 | 不要用 O_RDWR;读写分两个 fd/进程 |
| 7 | 非阻塞写打开返回 ENXIO | 还没有读者 | 先启动读者;或重试并处理 ENXIO |
| 8 | mq_receive 返回 EMSGSIZE | 缓冲区小于 mq_msgsize,与消息实际长度无关 | 用 mq_getattr 取 mq_msgsize 分配缓冲区 |
| 9 | mq_send 返回 EINVAL | prio >= MQ_PRIO_MAX(Linux 32768) | 合法范围 0–32767;不要写死 31 |
| 10 | 第二次 mq_notify 返回 EBUSY | 同一队列只能有一个注册者 | 先 mq_notify(mq, NULL) 撤销,再注册 |
| 11 | 通知只来一次 | 通知后注册被清除,且只在空→非空时触发 | 每次收到后重新注册;持续非空不会重复通知 |
| 12 | mq_timedreceive 超时时间不对 | 传了相对时间,或 fd 带 O_NONBLOCK 直接 EAGAIN | 用 CLOCK_REALTIME 绝对时间;清除 O_NONBLOCK |
| 13 | mq_open 名字报 EINVAL | 名字不以 / 开头,或含第二个 / | 用 /name,只含一个斜杠 |
| 14 | 队列删不掉/残留 | 只 mq_close 没 mq_unlink;或有进程还开着 | mq_unlink 删名字,最后一个 close 后销毁 |
| 15 | msgrcv 返回 E2BIG | 消息正文大于缓冲区且没设 MSG_NOERROR | 加大缓冲区,或设 MSG_NOERROR 并检查截断 |
| 16 | System V 队列阻塞中队列被删 | 另一个进程 IPC_RMID,阻塞调用返回 EIDRM | 把 EIDRM 当成「队列消失」,重新创建/退出 |
| 17 | splice 返回 EINVAL | 两端都是普通文件,或管道传了非 NULL 偏移 | 至少一端是管道;管道偏移传 NULL |
pipe2的O_DIRECT是 Linux 3.4 起;更老的内核pipe2只认O_CLOEXEC/O_NONBLOCK,传O_DIRECT会EINVAL。F_SETPIPE_SZ是 Linux 2.6.35 起;更老内核只有固定 64 KB,fcntl返回EINVAL。MSG_COPY是 Linux 3.8 起,且必须与IPC_NOWAIT一起用;其他 UNIX 没有这个 flag。mq_notify的SIGEV_THREAD由 glibc 实现,内核只处理SIGEV_SIGNAL/SIGEV_NONE;静态链接或非 glibc 环境行为可能不同。/dev/mqueue需要手动挂载(多数发行版会挂);容器里没有挂载点时,ls /dev/mqueue看不到队列,不代表队列不存在。
2.14.18 System V 消息队列:msgrcv 的类型筛选
这一节回答:msgtyp 的三种取值到底选哪条消息?MSG_EXCEPT/MSG_COPY 与 MSG_NOERROR 各自改了什么?
msgrcv 的选择规则
设队列中有若干条消息,每条以 long mtype 开头:
msgtyp | 选择规则 | 典型用途 |
|---|---|---|
> 0 | 第一条类型等于 msgtyp 的消息 | 按请求类型取响应 |
== 0 | 第一条消息(FIFO),不看类型 | 单一队列多生产者 |
< 0 | 类型小于等于 msgtyp 绝对值的消息中,类型最小的一条 | 按优先级取(小类型优先) |
< 0 的规则容易被误读成「取绝对值最大的」或「取类型最小的」;准确说法是:先筛出 mtype <= |msgtyp|,再取其中 mtype 最小的。若多条同类型,取最早进入的。
flags 与错误
| flag | 作用 | 不设的后果 |
|---|---|---|
IPC_NOWAIT | 无匹配消息时立即返回 ENOMSG | 阻塞直到有消息 |
MSG_NOERROR | 消息正文大于缓冲区时截断,返回截断后的长度 | 返回 E2BIG,消息留在队列 |
MSG_EXCEPT | 与 msgtyp > 0 合用,取第一条类型不等于 msgtyp 的消息 | 只按类型匹配 |
MSG_COPY | 与 msgtyp 作序号,复制第 msgtyp 条消息但不删除 | 取走并删除 |
MSG_COPY 是 Linux 扩展,必须配 IPC_NOWAIT;msgtyp 此时是 0 基序号,不是类型。它适合「窥视队列」的调试工具,不适合业务逻辑。
msqid_ds 与限制
msgctl(msqid, IPC_STAT, &ds) 返回的 struct msqid_ds 关键字段:
| 字段 | 含义 |
|---|---|
msg_perm | 属主、权限位(ipc_perm) |
msg_stime / msg_rtime / msg_ctime | 最后发送/接收/修改时间 |
msg_cbytes | 队列中所有消息正文的总字节数 |
msg_qnum | 当前消息条数 |
msg_qbytes | 队列最大字节数(可 IPC_SET 调整,需权限) |
msg_lspid / msg_lrpid | 最后发送/接收进程 PID |
限制来自 /proc/sys/kernel/msgmax(单条正文上限)、msgmnb(单队列字节上限)、msgmni(系统队列数上限)。这些是 IPC 命名空间内的值;容器里看到的与宿主机可能不同。
msgget 的 key 常用 ftok(path, id) 生成,但 ftok 只取 inode 与设备号的部分位,不同路径可能碰撞。新代码用 IPC_PRIVATE 让内核分配 ID,再通过 fork 继承或通过文件传递 ID;不要依赖 ftok 的唯一性。
ipcs -q找残留队列;ipcs -q -p看创建者/最后操作者。ipcrm -q ID按 ID 删除;ipcrm -Q KEY按 key 删除。- 删除时若有进程阻塞在
msgsnd/msgrcv,它们会收到EIDRM;程序必须处理这个错误,而不是当成「不可能」。 - 容器退出后 IPC 对象随 IPC 命名空间销毁;宿主机上残留的队列要手动清。
2.14.19 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出管道 read 返回 0 的条件 | 所有写端关闭,而非「暂时没数据」 | 2.14.1 |
| 判断一次写是否原子 | 以 PIPE_BUF 为界 | 2.14.2 |
用 dup2 实现两个进程的管道连接 | 每个进程都关掉不用的端 | 2.14.3 |
| 解释 FIFO 的打开阻塞规则 | 覆盖 O_NONBLOCK 的 ENXIO | 2.14.4 |
| 用 POSIX mq 发/收带优先级的消息 | 高优先级先出,长度正确 | 2.14.6 |
| 说出 POSIX mq 与 SysV mq 的差异 | 至少三点 | 2.14.7 |
Q1 父进程创建管道后 fork,子进程读、父进程写。父进程写完不关闭写端,子进程的 read 会怎样?为什么?
Q2 往一个读端已全部关闭的管道 write,默认会发生什么?如果想让 write 返回错误码,需要做什么?
Q3 一个进程先 open(fifo, O_RDONLY),再 open(fifo, O_WRONLY)。会发生什么?怎么改?
Q4 mq_send 依次发送优先级 1、3、2 的三条消息,mq_receive 的返回顺序是什么?如果队列满时再发,会怎样?
Q5 为什么 pclose 的返回值不能直接和 0 比较?
答案
A1 子进程会一直阻塞在 read。管道还有父进程持有的写端,内核认为「可能还有数据」,不返回 EOF。父进程必须 close(fd[1])。
A2 内核向进程发送 SIGPIPE,默认动作是终止进程。要看到 EPIPE,必须用 signal(SIGPIPE, SIG_IGN) 忽略,或用 sigaction 捕获;之后 write 返回 -1、errno=EPIPE。
A3 死锁。open(O_RDONLY) 阻塞等待写者,而写者打开永远执行不到。改法:读端用 O_RDONLY|O_NONBLOCK 先打开,或把读端放到另一个进程/线程先启动。
A4 顺序是 3(high)、2(mid)、1(low);同优先级才按 FIFO。队列满时 mq_send 阻塞,若 O_NONBLOCK 则返回 EAGAIN;消息超过 mq_msgsize 返回 EMSGSIZE。
A5 pclose 返回的是 wait 状态,包含退出码、信号、core dump 标志。必须用 WIFEXITED(status) 判断正常退出,再用 WEXITSTATUS(status) 取退出码;被信号终止时退出码无意义。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 44 章 管道和 FIFO(p.728–754,PDF p.773–799) | 2.14.1–2.14.5、2.14.9–2.14.13、2.14.15 | 完整:44.1 概述、44.2 创建与使用、44.5 popen、44.7 FIFO、44.8 客户端/服务器、44.9 非阻塞、44.10 读写语义(表 44-2/44-3) |
| K | 第 52 章 POSIX 消息队列(p.874–894,PDF p.919–938) | 2.14.6、2.14.14、2.14.16 | 完整:52.2 mq_open、52.3 描述符关系、52.4 特性、52.5 交换消息(含超时)、52.6 通知、52.7 Linux 特性、52.8 限制 |
| K | 第 46 章 System V 消息队列(p.769–790,PDF p.814–835) | 2.14.7、2.14.18 | 对照:msgget/msgsnd/msgrcv/msgctl、类型筛选、MSG_NOERROR/MSG_EXCEPT/MSG_COPY、ipcs |
| C | 第 10.8 节 管道 | 2.14.1–2.14.3 | 背景(fd 继承与 dup2) |
| O | — | — | 无直接落点(OSTEP 的 IPC 以锁/条件变量为主,本章对照其并发章节) |
| W | — | — | 无直接落点 |
| N | — | — | 无直接落点(网络 socket 在 2.16–2.18) |
下一站:2.15 共享内存与信号量换一条完全不同的路:不复制数据,让两个进程映射同一块物理页;再用信号量解决「共享了内存但没有同步」的问题。