无名管道:pipe函数、管道的读写规则、管道的阻塞与非阻塞、父子进程管道通信示例

大家好,我是你们的老朋友。今天我们来聊聊进程通信里最基础、也最经典的一个机制——无名管道。说实话,我刚开始学 Linux 系统编程那会儿,觉得管道这东西挺简单的,不就是一根管子嘛,一头写一头读。但后来在项目中踩过几次坑,才发现里面门道不少。嗯,咱们今天就把这块彻底讲透。

1. pipe 函数:创建管道的入口

无名管道,说白了就是内核里的一块缓冲区。它没有名字,所以只能在有亲缘关系的进程之间用——最常见的场景就是父子进程。创建它的函数就是 pipe()

#include <unistd.h>
int pipe(int pipefd[2]);

这个函数很简单,传入一个长度为 2 的 int 数组。调用成功后:

  • pipefd[0] 是读端,用来从管道里取数据
  • pipefd[1] 是写端,用来往管道里塞数据

我个人习惯把这两个文件描述符分别叫做 fd_readfd_write,这样代码读起来一目了然。返回 0 表示成功,-1 表示失败,常见的失败原因是系统文件描述符用完了。

小提示:pipe 创建出来的管道是半双工的。什么意思?就是数据只能从一个方向流。如果你想双向通信,得创建两个管道。

2. 管道的读写规则:别想得太复杂

管道的读写规则其实很直观,但有几个细节容易让人迷糊。我当年刚入行时就在这上面栽过跟头。

2.1 写端写入,读端读出

数据从写端进去,从读端出来。先进先出,没有随机访问。你想想看,这就像一根水管,水只能从一头进另一头出,不能从中间接个水龙头。

2.2 读空与写满

  • 读空:如果管道里没数据了,读操作会阻塞,直到有数据写入。
  • 写满:管道有容量上限(通常是 65536 字节,也就是 64KB)。写满了再写,也会阻塞。

我在项目中遇到过一个问题:父进程往管道里写了一大堆数据,子进程读得慢,结果父进程写满了就卡住了。当时排查了半天,最后发现是子进程处理逻辑太慢,读端没及时取数据。解决办法?要么加大缓冲区,要么让子进程读得快一点。

2.3 写端关闭后,读端读到 EOF

当所有写端都被关闭后,读端再读会返回 0,表示读到文件末尾。这个特性很重要,很多管道通信的结束信号就是靠这个来传递的。

2.4 读端关闭后,写端收到 SIGPIPE

如果读端关了,写端还在写,内核会向写端进程发送 SIGPIPE 信号。这个信号的默认行为是终止进程。所以,如果你不想让进程挂掉,记得处理这个信号,或者忽略它。

注意:我曾经在一个网络代理程序里忘了处理 SIGPIPE,结果客户端断开连接后,服务端直接崩溃了。排查了好久才发现是管道读端关闭导致的。从那以后,我写任何涉及管道的代码,第一件事就是 signal(SIGPIPE, SIG_IGN)。

3. 管道的阻塞与非阻塞

默认情况下,管道是阻塞模式的。但你可以通过 fcntl() 把它改成非阻塞。

#include <fcntl.h>
int flags = fcntl(pipefd[0], F_GETFL, 0);
fcntl(pipefd[0], F_SETFL, flags | O_NONBLOCK);

改成非阻塞后,读写行为会发生变化:

操作 阻塞模式 非阻塞模式
读空管道 阻塞等待数据 立即返回 -1,errno 设为 EAGAIN
写满管道 阻塞等待空间 立即返回 -1,errno 设为 EAGAIN

非阻塞模式适合用在事件驱动或轮询的场景里。比如你用 select()epoll() 监控多个管道时,非阻塞模式就很有用。

核心要点:阻塞模式简单可靠,适合大多数场景。非阻塞模式灵活高效,适合复杂 I/O 复用场景。选哪个?看你的应用场景。

4. 父子进程管道通信示例

光说不练假把式。咱们直接上一个完整的例子,看看父子进程怎么通过管道通信。

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

int main() {
    int pipefd[2];
    pid_t pid;
    char buf[1024];

    // 1. 创建管道
    if (pipe(pipefd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }

    // 2. 创建子进程
    pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        // 子进程:负责读数据
        close(pipefd[1]);  // 关闭写端

        while (1) {
            memset(buf, 0, sizeof(buf));
            ssize_t n = read(pipefd[0], buf, sizeof(buf) - 1);
            if (n == 0) {
                // 写端关闭,读到 EOF
                printf("子进程:父进程关闭了写端,我退出了\n");
                break;
            } else if (n == -1) {
                perror("read");
                break;
            }
            printf("子进程收到:%s", buf);
        }

        close(pipefd[0]);
        exit(EXIT_SUCCESS);
    } else {
        // 父进程:负责写数据
        close(pipefd[0]);  // 关闭读端

        const char *messages[] = {
            "Hello, 子进程!\n",
            "这是第二条消息\n",
            "最后一条消息,再见!\n"
        };

        for (int i = 0; i < 3; i++) {
            write(pipefd[1], messages[i], strlen(messages[i]));
            sleep(1);  // 模拟处理间隔
        }

        close(pipefd[1]);  // 关闭写端,通知子进程结束
        wait(NULL);        // 等待子进程退出
        printf("父进程:子进程已退出\n");
    }

    return 0;
}

这个例子很简单,但包含了所有关键点:

  • 先 pipe,再 fork。顺序不能乱。
  • 父子进程各自关闭不需要的端。子进程关写端,父进程关读端。
  • 父进程写完数据后关闭写端,子进程读到 EOF 后退出。
  • 父进程用 wait() 等待子进程结束,避免僵尸进程。
我的习惯:每次 fork 之后,第一时间把不需要的文件描述符关掉。这不仅是好习惯,还能避免一些隐蔽的 bug。比如子进程忘了关写端,父进程读数据时就会一直阻塞,因为写端没关完,内核不知道数据已经发完了。

5. 知识体系总览

下面这张图把无名管道的核心知识点串起来了,方便你整体把握。

无名管道知识体系 无名管道 (pipe) pipe(int pipefd[2]) 读写规则:FIFO、读空阻塞、写满阻塞、EOF、SIGPIPE 阻塞模式 vs 非阻塞模式 (fcntl + O_NONBLOCK) 父子进程通信:先 pipe 再 fork,关闭不需要的端

好了,关于无名管道的内容就讲到这里。记住,管道虽然简单,但它是理解更复杂 IPC 机制(比如命名管道、消息队列、共享内存)的基础。把这块吃透了,后面的路就好走了。

一句话总结:无名管道是亲缘进程间最简单、最轻量的通信方式。用好它,关键就三点——正确创建、及时关闭、理解阻塞。

公众号:蓝海资料掘金营,微信deep3321