fork 后父子进程可以用 pipe 传递字节流

pipe 是 Unix/Linux 中最基础的进程间通信机制之一。它创建一条由内核维护的单向字节流通道,一个文件描述符用于读取,另一个文件描述符用于写入。

它常用于父子进程通信、命令行管道、日志转发、数据流处理,以及和 spliceteevmsplice 等零拷贝接口组合使用。

函数签名

#include <unistd.h>

int pipe(int pipefd[2]);

调用成功返回 0,失败返回 -1 并设置 errno

pipefd 数组包含两个文件描述符:

pipefd[0]  // 读端
pipefd[1]  // 写端

数据流向固定为:

write(pipefd[1]) -> kernel pipe buffer -> read(pipefd[0])

pipe 是单向通道。如果两个进程需要双向通信,通常需要创建两个 pipe。

基本读写示例

同一个进程中也可以使用 pipe。下面示例先写入字符串,再从读端取出数据:

#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(void) {
    int pipefd[2];
    if (pipe(pipefd) < 0) {
        perror("pipe");
        return 1;
    }

    const char *msg = "hello pipe";
    write(pipefd[1], msg, strlen(msg));

    char buf[128];
    ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
    if (n > 0) {
        buf[n] = '\0';
        printf("read: %s\n", buf);
    }

    close(pipefd[0]);
    close(pipefd[1]);
    return 0;
}

这段代码没有体现进程间通信,但展示了 pipe 的基本数据方向:写端进入,读端取出。

父子进程通信

pipe 最常见的用法是配合 fork。父进程先创建 pipe,然后 fork 子进程。fork() 会复制当前进程,形成父进程和子进程两条执行路径。调用返回后,两边都会继续从 fork() 后面的代码往下执行,但返回值不同:子进程中返回 0,父进程中返回子进程的 PID,失败时返回 -1

fork 后,父子进程都持有同一组 pipe 文件描述符,因此可以通过关闭不用的一端来建立单向通信。

#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    // pipefd[0] 是读端,pipefd[1] 是写端。
    int pipefd[2];

    // 创建一条匿名管道。失败时返回 -1,并设置 errno。
    if (pipe(pipefd) < 0) {
        perror("pipe");
        return 1;
    }

    // fork 会创建一个子进程。
    // 从这一行之后,父进程和子进程都会继续执行后面的代码。
    pid_t pid = fork();
    if (pid < 0) {
        // pid < 0 表示 fork 失败,没有创建出子进程。
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        // pid == 0 表示当前代码运行在子进程中。
        // 子进程只负责读取,所以关闭不用的写端。
        close(pipefd[1]);

        char buf[128];

        // 从 pipe 的读端读取父进程写入的数据。
        // 这里最多读取 sizeof(buf) - 1 字节,留 1 字节给字符串结尾 '\0'。
        ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
        if (n > 0) {
            // read 读取的是原始字节,不会自动补字符串结尾。
            // 如果要按 C 字符串打印,需要手动添加 '\0'。
            buf[n] = '\0';
            printf("child read: %s\n", buf);
        }

        // 子进程读完后关闭读端并退出。
        close(pipefd[0]);
        return 0;
    }

    // 运行到这里的是父进程。
    // 父进程只负责写入,所以关闭不用的读端。
    close(pipefd[0]);

    const char *msg = "hello from parent";

    // 向 pipe 的写端写入数据,子进程会从读端读到这些字节。
    write(pipefd[1], msg, strlen(msg));

    // 写完后关闭写端。这样子进程继续 read 时可以正确感知 EOF。
    close(pipefd[1]);

    // 等待子进程退出,避免产生僵尸进程。
    wait(NULL);
    return 0;
}

这里父进程只保留写端,子进程只保留读端。关闭不用的一端很重要,否则读端可能无法正确收到 EOF。wait(NULL) 用于等待子进程结束,避免子进程退出后短暂变成僵尸进程。

close 只关闭当前进程的 fd

fork() 之后,父进程和子进程里的 pipefd[0]pipefd[1] 数字通常是一样的。但这个数字不是系统全局唯一的管道编号,而是各自进程文件描述符表里的索引。

可以把 fork 后的关系理解成:

内核 pipe 对象

父进程 fd 3 -> 读端引用
父进程 fd 4 -> 写端引用
子进程 fd 3 -> 读端引用
子进程 fd 4 -> 写端引用

当子进程执行:

close(pipefd[1]);

关闭的是子进程 fd 表里的写端引用,不会关闭父进程 fd 表里的 pipefd[1]。关闭后可以理解成:

内核 pipe 对象

父进程 fd 3 -> 读端引用
父进程 fd 4 -> 写端引用
子进程 fd 3 -> 读端引用
子进程 fd 4 已关闭

此时父进程仍然可以通过自己的 pipefd[1] 写入数据。只有当所有进程里指向写端的 fd 都关闭后,读端继续读取才会得到 EOF。反过来,只有当所有进程里指向读端的 fd 都关闭后,写端继续写入才会触发 SIGPIPE 或返回 EPIPE

因此示例中的 close 不是关闭对方的端口,而是关闭当前进程不需要的引用。这样可以让内核准确判断还有没有读者或写者。

pipe 是字节流

pipe 传输的是字节流,不是消息队列。它不保留每一次 write 的业务边界。

例如写端连续写入:

write(fd, "hello", 5);
write(fd, "world", 5);

读端可能一次读到:

helloworld

也可能分多次读到。应用层如果需要消息边界,应自行定义协议,例如固定长度头部、换行分隔、TLV 格式等。

有一个特殊规则:当单次写入长度不超过 PIPE_BUF 时,Linux 保证这次写入相对于其他写入是原子的。这个保证只解决多个写者并发写入时的数据交错问题,不等于读端一定按同样长度读取。

阻塞与 EOF

默认情况下,pipe 是阻塞的。

读端行为:

写端行为:

这些规则决定了 pipe 程序必须认真关闭不再使用的文件描述符。

非阻塞 pipe

可以使用 pipe2 创建带标志的 pipe:

#define _GNU_SOURCE
#include <fcntl.h>
#include <unistd.h>

int pipefd[2];
pipe2(pipefd, O_NONBLOCK | O_CLOEXEC);

常用标志:

非阻塞 pipe 常与 pollepoll 或事件循环组合使用。

命名管道 FIFO

普通 pipe 通常用于有亲缘关系的进程,因为文件描述符需要通过 fork 继承。没有亲缘关系的进程可以使用 FIFO,也叫命名管道。

创建 FIFO:

#include <sys/stat.h>

mkfifo("/tmp/myfifo", 0600);

之后不同进程可以像打开普通文件一样打开它:

int fd = open("/tmp/myfifo", O_RDONLY);

FIFO 仍然是字节流,读写语义和 pipe 接近,但它在文件系统中有名字,适合无亲缘关系进程之间建立简单通信通道。

与 splice 的关系

splice 经常需要 pipe 作为中转结构:

int pipefd[2];
pipe(pipefd);

splice(file_fd, NULL, pipefd[1], NULL, len, 0);
splice(pipefd[0], NULL, socket_fd, NULL, len, 0);

这时 pipe 不一定是为了两个进程通信,而是作为内核中的数据搬运缓冲区。数据路径是:

file_fd -> pipe buffer -> socket_fd

用户态不需要分配大块数据缓冲区,也不直接读取文件内容。

注意事项

pipe 的容量有限,不能把它当作无限队列使用。写端速度长期高于读端时,最终会阻塞或在非阻塞模式下返回 EAGAIN

多个进程同时写同一个 pipe 时,应关注写入大小和协议边界。超过 PIPE_BUF 的写入可能和其他写者的数据交错。

长期运行的程序应统一管理文件描述符生命周期。忘记关闭写端会导致读端一直等不到 EOF;忘记关闭读端会让写端错误处理变得不明确。

总结

pipe 是一条由内核维护的单向字节流。它可以连接父子进程,也可以作为 splice 的内核中转缓冲区。正确使用 pipe 的关键是理解读端、写端、阻塞、EOF 和文件描述符关闭规则。

本文由 AI 辅助生成,可能存在错误或遗漏,请以实际资料和官方文档为准。