管道与 socketpair

本页目录

pipe 提供单向字节流,socketpair 创建一对已经连接的本地套接字,适合双向通信。两者都通过文件描述符表达引用和生命周期,也都需要协议来区分正常结束、部分传输与错误。

管道:容量和原子写入是两个概念

pipefd[0] 是读端,pipefd[1] 是写端。Linux 的 pipe2 可以在创建时原子设置 O_CLOEXEC 和 O_NONBLOCK,避免先创建再设置标志的竞争窗口。

PIPE_BUF 是多写者之间不交错写入的保证边界,不是管道总容量。长度不超过该界限的一次写入可获得相应原子性;超过时可能与别的写者交错。非阻塞模式下,小于等于 PIPE_BUF 的写入若空间不足会返回 EAGAIN,不能把它理解为必定部分成功。pipe(7)

读取没有消息边界:一次 write 不保证对应一次 read,即使写入本身不与其他写者交错。应用可采用固定长度、长度前缀或转义分隔符协议。

容量影响能缓冲多少数据。常见 Linux 配置使用多页容量,但页大小、用户配额和内核限制会改变结果;用 F_GETPIPE_SZ 查询,F_SETPIPE_SZ 请求调整,并检查实际返回值。增大缓冲可平滑突发,也可能增加排队延迟,不能消除持续生产快于消费的失衡。

读取批次边界与协议消息边界独立 协议消息 长度 3 + abc 消息 1 长度 5 + hello 消息 2 可能的 read 头部前 2 字节 还不能解码长度 头部尾部 + ab 消息 1 尚不完整 c + 整条消息 2 一次可完成两条消息 接收端保留未完成前缀,验证长度上限,完整消息才交给业务;EOF 时检查残留。

长度前缀需要一个跨读取批次的解析状态

下面约定每条消息先有 4 字节大端无符号长度,再跟相应 payload;允许空消息,单条最多 1024 字节。解析器接收的是读取到的字节块,块边界不进入协议。示例人为拆分头部和 payload,验证它仍能得到两条完整消息。

class Decoder:
    def __init__(self):
        self.pending = bytearray()
        self.expected = None

    def feed(self, chunk):
        self.pending.extend(chunk)
        messages = []
        while True:
            if self.expected is None:
                if len(self.pending) < 4:
                    break
                self.expected = int.from_bytes(self.pending[:4], "big")
                del self.pending[:4]
                if self.expected > 1024:
                    raise ValueError("message too large")
            if len(self.pending) < self.expected:
                break
            messages.append(bytes(self.pending[:self.expected]))
            del self.pending[:self.expected]
            self.expected = None
        return messages

    def finish(self):
        if self.expected is not None or self.pending:
            raise ValueError("truncated message at EOF")

wire = (3).to_bytes(4, "big") + b"abc" + (5).to_bytes(4, "big") + b"hello"
decoder = Decoder()
messages = []
for chunk in [wire[:2], wire[2:6], wire[6:]]:
    messages.extend(decoder.feed(chunk))
assert messages == [b"abc", b"hello"]
decoder.finish()
assert Decoder().feed(bytes(4)) == [b""]
for bad in [(1025).to_bytes(4, "big"), b"\0\0", (3).to_bytes(4, "big") + b"ab"]:
    try:
        decoder = Decoder()
        decoder.feed(bad)
        decoder.finish()
    except ValueError:
        pass
    else:
        raise AssertionError("malformed stream accepted")

解析错误后本例放弃这条流,不尝试猜测下一个消息头;应用若需要重新同步,协议必须设计明确的恢复规则。feed 把输入块与本批完整消息暂存在内存中,所以实际接入还应限制单次 read 大小和待处理消息队列,不能认为“单条 1024 字节”已经限制整个进程的内存。

收到 EOF 只说明字节流结束。若还差两个 payload 字节,应该报告截断而不是把半条消息当成功;已经交付的早期完整消息是否需要撤销,是上层事务协议的问题。

EOF 与 SIGPIPE 来自引用状态

EOF 取决于所有写端引用:fork 后两侧都要关闭多余端 父进程 保留写端,关闭读端 子进程 保留读端,关闭写端 字节流 父进程完成写入并关闭 最后一个写端引用消失 子进程读尽缓冲后 read 返回 0(EOF) 若子进程仍保留自己的写端,即使父进程已经关闭,读端也可能一直等不到 EOF。

所有写端都关闭且缓冲读完,read 才返回 0。写入时若所有读端都已关闭,通常产生 SIGPIPE;若该信号被忽略或适当处理,write 以 EPIPE 失败。空缓冲但仍有写者,与真正 EOF 是不同状态。

fork、dup 和继承都会增加引用。O_CLOEXEC 在成功 exec 时关闭相应描述符,不会替你在 fork 后立即关闭不使用的一端。socket 的 shutdown(SHUT_WR) 还可以明确半关闭一个方向,与简单减少某个 fd 引用不同。

关闭一个写端,为什么还不是 EOF?

先复制写端,再关闭原引用,空读仍是 EAGAIN。随后写入并关闭最后一个写端,第一次读取仍应返回缓冲中的数据,下一次才是 EOF。关闭的是引用,读取的是字节;这两条状态必须一起判断。

正在呈现知识画面
关闭一个写端,为什么还不是 EOF?

EOF 要同时满足缓冲耗尽与所有写端关闭。仍有写引用时,空缓冲非阻塞读取是 EAGAIN。

一个验证 EOF 的父子进程例子

下面仅发送固定的五字节 payload,处理短写与 EINTR,并让子进程读到 EOF 后核对总长度。

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    int fd[2];
    if (pipe2(fd, O_CLOEXEC) == -1) return 1;
    pid_t child = fork();
    if (child == -1) { close(fd[0]); close(fd[1]); return 1; }
    if (child == 0) {
        close(fd[1]);
        size_t total = 0;
        char buf[32];
        for (;;) {
            ssize_t n = read(fd[0], buf, sizeof buf);
            if (n > 0) total += (size_t)n;
            else if (n == 0) break;
            else if (errno != EINTR) _exit(2);
        }
        close(fd[0]);
        _exit(total == 5 ? 0 : 3);
    }
    close(fd[0]);
    const char payload[] = "hello";
    size_t done = 0;
    int failed = 0;
    while (done < 5) {
        ssize_t n = write(fd[1], payload + done, 5 - done);
        if (n > 0) done += (size_t)n;
        else if (n < 0 && errno == EINTR) continue;
        else { failed = 1; break; }
    }
    close(fd[1]);
    int status;
    while (waitpid(child, &status, 0) == -1) {
        if (errno != EINTR) return 1;
    }
    return failed || !WIFEXITED(status) || WEXITSTATUS(status) != 0;
}

用 cc -Wall -Wextra pipe_demo.c -o pipe_demo 编译。真实协议还要处理对端提前退出、超时、SIGPIPE 策略和输入大小限制;这里的长度核对只用于说明生命周期,不是通用消息完整性校验。

不用等待超时,也能验证多余写端导致无 EOF

以下单进程例子复制一个写端,关闭原写端后对空管道非阻塞读取。由于复制的写端仍存在,结果是 EAGAIN;关闭最后一个写端后,同一次读取才返回 0。

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
    int fd[2];
    if (pipe2(fd, O_NONBLOCK | O_CLOEXEC) == -1) return 1;
    int extra = fcntl(fd[1], F_DUPFD_CLOEXEC, 0);
    if (extra == -1) { close(fd[0]); close(fd[1]); return 1; }
    if (close(fd[1]) == -1) { close(extra); close(fd[0]); return 1; }
    char byte;
    errno = 0;
    ssize_t before = read(fd[0], &byte, 1);
    int saved = errno;
    int failed = !(before == -1 && (saved == EAGAIN || saved == EWOULDBLOCK));
    if (close(extra) == -1) failed = 1;
    if (read(fd[0], &byte, 1) != 0) failed = 1;
    if (close(fd[0]) == -1) failed = 1;
    if (!failed) puts("writer remains: EAGAIN; last writer closed: EOF");
    return failed;
}

保存为 pipe-refs.c,用 cc -Wall -Wextra pipe-refs.c -o pipe-refs 编译运行。本例没有写入数据,因此最后一次关闭后立即可见 EOF;若已有数据,还必须先读尽它。F_DUPFD_CLOEXEC 为新描述符也设置关闭执行标志,避免误以为所有复制接口都会自动继承 FD_CLOEXEC。F_DUPFD_CLOEXEC

socketpair:类型决定边界

int sv[2];
int rc = socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, sv);
/* 检查 rc;sv[0]、sv[1] 均可读写,结束时分别 close。 */

这是一段需要 <sys/socket.h> 的 API 片段。socketpair 的两端创建后已互相连接,不必再做 bind、listen、accept。socketpair(2)

AF_UNIX 类型数据模型接收时需要注意
SOCK_STREAM可靠、有序字节流自行分帧,处理短读短写
SOCK_DGRAM本地可靠、有序数据报保留边界,缓冲太小可能截断消息
SOCK_SEQPACKET面向连接、可靠、有序记录保留边界,检查截断及记录大小

Linux AF_UNIX 数据报不等于 UDP 的交付语义,不能写成“不可靠”。SOCK_SEQPACKET 也不是到 Linux 5.3 才出现。可靠性表示接口范围内的传输语义,并不保证接收进程读到后一定完成业务处理;崩溃恢复仍需要上层协议。unix(7)

AF_UNIX 避开 IP/TCP 路径,但仍有自己的协议实现、缓冲和背压;“不走任何协议栈、没有流控”不准确。延迟与吞吐应按消息大小、并发、复制路径和调度测量。

凭证与文件描述符传递

SCM_RIGHTS 传递的是对打开文件描述的引用,接收进程取得自己的 fd 数字,不是把发送者那个整数原样变成可用句柄。共享的文件偏移和状态也可能随该打开文件描述关联,需要理解所有权与关闭责任。

SO_PEERCRED 可用于查询连接对端的凭证,SCM_CREDENTIALS 则是消息辅助数据机制,接收策略和内核校验依接口规定。内核提供的身份信息应再结合应用授权规则,不能看到 UID 就无条件信任任何操作。

recvmsg 要检查 MSG_CTRUNC、控制缓冲大小和接收 fd 数量。拒绝一条消息时也必须关闭已接收但不再使用的 fd;Linux 的 MSG_CMSG_CLOEXEC 可避免收到的描述符意外跨 exec 泄露。unix(7) 辅助数据

splice:减少用户缓冲往返,不承诺绝对不复制

splice 要求至少一端是管道,可将数据在支持的 fd 与 pipe 之间传递。某些路径能移动页引用,避免先复制到用户缓冲再写回内核,但实际是否复制取决于端点与内核实现,不能统一保证物理数据一次都不复制。splice(2)

一次 splice 也可能短传输、阻塞或返回 EAGAIN。文件到管道再到套接字的循环要记录已进入管道但尚未发出的字节,先处理背压,避免重复读取或丢弃尾部。它与 sendfile 有相关优化用途,但不能把 sendfile 的所有实现细节固定成某两次 splice 调用。

eventfd 与选型

eventfd 是内核维护的 64 位计数器接口:普通读取返回累计值并清零,EFD_SEMAPHORE 模式每次读取递减一。它适合通知或工作计数,不承载任意字节消息,也不是只能沿某个单向端点传输的管道。eventfd(2)

单向流选 pipe;双向字节协议选 socketpair STREAM;需要记录边界可考虑 SEQPACKET;传递 fd 或身份信息用 AF_UNIX 辅助数据;只需通知则考虑 eventfd。大块共享数据的生命周期与同步见 共享内存与信号量。

关键词:pipe, PIPE_BUF, EOF, SIGPIPE, socketpair, AF_UNIX, SOCK_SEQPACKET, SCM_RIGHTS, credentials, splice, eventfd, backpressure