管道(Pipe):进程间通信的“水管”哲学

管道这东西,说白了就是一根虚拟的水管。一头接进程A,一头接进程B。A往里面倒数据,B从另一头接。就这么简单。

我刚开始学Linux编程时,觉得管道太小儿科了。后来在项目中遇到一个日志采集的场景,才真正体会到它的精妙。嗯,咱们今天就把管道掰开揉碎了讲清楚。

匿名管道:最朴素的IPC方式

匿名管道,名字就叫“匿名”,因为它没有名字。它只在有亲缘关系的进程间工作——通常是父子进程。

它的原理其实很直白:

  • 内核维护一个缓冲区,就像个环形队列
  • 一端写,一端读,数据流是单向的
  • 写端关闭后,读端读到EOF
  • 读端关闭后,写端收到SIGPIPE信号

核心API就四个:

  • pipe(int fd[2]) — 创建管道,fd[0]读端,fd[1]写端
  • read(fd[0], buf, size) — 从读端读取数据
  • write(fd[1], buf, size) — 向写端写入数据
  • close(fd) — 关闭端

这里有个坑,我踩过好几次。创建管道后,父子进程都会持有两个文件描述符。如果你不关掉不需要的那一端,就会出问题。

我曾经犯过的错:

父进程写完后没关读端,子进程读完后没关写端。结果子进程一直阻塞在read()上,因为写端没关,它以为还有数据要来。排查了半天,最后发现是文件描述符没关干净。

匿名管道的限制

说实话,匿名管道的限制挺多的。我列几个常见的:

限制项 说明
只能用于亲缘进程 父子、兄弟进程之间才能用
单向通信 数据只能从一个方向流
缓冲区有限 Linux默认是65536字节(64KB)
无名字 其他进程无法通过名字找到它
生命周期随进程 进程结束,管道就没了

你想想看,如果两个不相关的进程要通信,匿名管道就无能为力了。这时候就需要命名管道出场。

命名管道(FIFO):给管道一个名字

命名管道,也叫FIFO。它和匿名管道的本质一样,都是内核缓冲区。但区别在于:它在文件系统里有一个名字,像个特殊的文件。

创建FIFO有两种方式:

// 命令行
mkfifo /tmp/my_fifo

// C代码
#include <sys/types.h>
#include <sys/stat.h>

int ret = mkfifo("/tmp/my_fifo", 0666);
if (ret == -1) {
    perror("mkfifo");
}

命名管道的使用场景,我总结了几种:

  • 日志收集:多个进程往同一个FIFO写日志,一个进程统一读取
  • 简单消息队列:生产者-消费者模式,FIFO就是那个队列
  • 跨用户通信:不同用户启动的进程,通过FIFO交换数据
  • 脚本间通信:Shell脚本里经常用FIFO做数据中转

我的个人习惯:

用FIFO时,我一般会在/tmp目录下创建,文件名带上进程ID或时间戳,避免冲突。用完记得unlink删除,不然下次启动会报文件已存在。

实战:用管道实现父子进程数据交换

光说不练假把式。咱们写个完整的例子:父进程生成一批随机数,通过管道发给子进程,子进程计算平均值后返回结果。

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

#define NUM_COUNT 10

int main() {
    int fd1[2], fd2[2];  // 两根管道,实现双向通信
    pid_t pid;
    int numbers[NUM_COUNT];
    int sum = 0, avg = 0;

    // 创建两根管道
    if (pipe(fd1) == -1 || pipe(fd2) == -1) {
        perror("pipe");
        exit(1);
    }

    pid = fork();
    if (pid == -1) {
        perror("fork");
        exit(1);
    }

    if (pid == 0) {
        // 子进程
        close(fd1[1]);  // 关掉fd1的写端
        close(fd2[0]);  // 关掉fd2的读端

        // 从管道读取数据
        read(fd1[0], numbers, sizeof(numbers));
        
        // 计算平均值
        for (int i = 0; i < NUM_COUNT; i++) {
            sum += numbers[i];
        }
        avg = sum / NUM_COUNT;

        // 把结果写回父进程
        write(fd2[1], &avg, sizeof(avg));

        close(fd1[0]);
        close(fd2[1]);
        exit(0);
    } else {
        // 父进程
        close(fd1[0]);  // 关掉fd1的读端
        close(fd2[1]);  // 关掉fd2的写端

        srand(time(NULL));
        for (int i = 0; i < NUM_COUNT; i++) {
            numbers[i] = rand() % 100;
            printf("生成数字: %d\n", numbers[i]);
        }

        // 把数据发给子进程
        write(fd1[1], numbers, sizeof(numbers));

        // 等待子进程计算结果
        read(fd2[0], &avg, sizeof(avg));
        printf("子进程计算的平均值: %d\n", avg);

        wait(NULL);
        close(fd1[1]);
        close(fd2[0]);
    }

    return 0;
}

这个例子用了两根管道来实现双向通信。为什么不用一根?因为管道是单向的。一根管道只能一个方向流数据。

运行结果大概是这样的:

生成数字: 42
生成数字: 17
生成数字: 89
生成数字: 33
生成数字: 56
生成数字: 71
生成数字: 24
生成数字: 68
生成数字: 95
生成数字: 11
子进程计算的平均值: 50

避坑指南:

我曾经在项目中遇到一个诡异的问题:父进程write完数据后,子进程read不到。排查了半天,发现是父进程write的数据量超过了管道缓冲区大小(64KB),导致write阻塞了。解决办法是分多次write,或者用非阻塞模式。

管道的性能考量

说实话,管道不是最快的IPC方式。它的性能受几个因素影响:

  • 上下文切换:每次read/write都可能触发系统调用
  • 数据拷贝:数据要从用户空间拷贝到内核空间,再拷贝到另一个用户空间
  • 缓冲区大小:太小了频繁阻塞,太大了浪费内存

我一般建议:数据量小于1MB,通信频率不高的情况下,用管道完全够用。如果追求高性能,可以考虑共享内存或消息队列。

小技巧:

可以用fcntl(fd, F_SETFL, O_NONBLOCK)把管道设置成非阻塞模式。这样read/write不会阻塞,但你要自己处理EAGAIN错误。我一般在调试阶段用阻塞模式,上线前改成非阻塞。

好了,管道这块就讲这么多。记住一句话:管道是进程间通信的“水管”,简单但够用。下一章咱们聊聊消息队列,那个比管道更灵活一些。