1. 引言

在多进程程序设计中,每个进程拥有独立的地址空间,彼此之间无法直接访问对方的数据。然而,在实际开发中,进程之间往往需要协作完成一项任务,例如生产者-消费者模型、客户端-服务端模型等,这就涉及进程间通信问题。

管道是 Unix/Linux 系统中最古老、最经典的进程间通信方式之一。它实现简单、使用广泛,是理解 IPC 机制的绝佳起点。本文将围绕无名管道和有名管道展开,从原理、API 到代码示例,帮助你彻底掌握管道通信。

2. 进程间通信概述

2.1 为什么需要进程间通信

进程是操作系统分配资源的基本单位。为了保证安全性,操作系统为每个进程分配了独立的虚拟地址空间,一个进程无法直接读取或修改另一个进程的内存。当多个进程需要协同工作时,就必须通过操作系统提供的 IPC 机制来交换数据。

2.2 常见的 IPC 方式

IPC 方式特点
管道半双工,数据单向流动,简单高效
命名管道管道的有名版本,支持无亲缘关系进程通信
消息队列以消息为单位传输,支持多对多
共享内存速度最快,需配合信号量同步
信号异步通知机制
套接字支持跨主机通信

2.2 常见的 IPC 方式

从实现机制上看,Linux 下的 IPC 可以分为三大类:

类别主要方式说明
古老的通信方式无名管道、有名管道、信号早期 Unix 就提供的经典机制,简单直接
IPC 对象通信System V 消息队列、共享内存、信号量集以内核对象形式存在,功能更强;BSD 也提供了对应的 IPC 实现
Socket 通信UNIX 域套接字、网络套接字既可单机通信,也可跨主机网络通信

其中,管道是最基础、最常见的 IPC 方式:

IPC 方式特点
管道半双工,数据单向流动,简单高效
命名管道管道的有名版本,支持无亲缘关系进程通信
消息队列以消息为单位传输,支持多对多(实际项目中使用相对较少)
共享内存速度最快,需配合信号量同步
信号异步通知机制
套接字支持跨主机通信

本文聚焦于最基础的两种:无名管道和有名管道。

3. 无名管道

3.1 什么是无名管道

无名管道,也称匿名管道,是 Unix/Linux 中最简单的 IPC 机制。它的本质是内核维护的一块环形缓冲区,通过两个文件描述符来访问:

  • fd[0]:读端,用于从管道读取数据
  • fd[1]:写端,用于向管道写入数据

数据在管道中按先进先出的顺序流动,写入的数据只能被读取一次。

3.2 无名管道的特性

无名管道具有以下几个重要特性:

无名管道具有以下几个重要特性:

  • 半双工:数据只能单向流动,需要两个管道才能实现双向通信。实际编程中通常把一个管道当作单工来使用。
  • 无名字:管道没有文件名,不能通过 open() 打开,只能由创建它的进程及子进程使用。
  • 亲缘限制:由于管道没有名字,其他进程无法获知它的存在,因此只能用于父子进程或兄弟进程之间的通信。
  • 字节流:管道传输的是无格式的字节流,不保留消息边界。
  • 生命周期:管道随进程结束而销毁。
  • 缓冲区有限:管道缓冲区大小有限,通常为 64KB,写满后继续写入会阻塞。
  • 特殊文件:所有管道本质上都是特殊文件,不支持定位操作,因此不能使用 lseek() 或 fseek()。
  • 文件 IO 访问:管道是特殊文件,读写时应优先使用文件 IO:open、read、write、close;也可以选择带缓冲区的标准 IO:fgets、fread、fgetc 等。

无名管道的底层实现是内核维护的队列(环形缓冲区),数据按先进先出顺序流动。### 3.3 创建无名管道

使用 pipe() 系统调用创建无名管道:

#include <unistd.h>

int pipe(int pipefd[2]);
  • 成功返回 0,失败返回 -1 并设置 errno
  • pipefd[0] 为读端文件描述符
  • pipefd[1] 为写端文件描述符

3.4 父子进程通信示例

下面是一个经典的父子进程通信示例:子进程向管道写入消息,父进程从管道读取。

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

#define BUF_SIZE 128

int main() {
    int fd[2];
    pid_t pid;
    char buf[BUF_SIZE];

    if (pipe(fd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }

    pid = fork();
    if (pid < 0) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        /* 子进程:关闭读端,向管道写入数据 */
        close(fd[0]);
        const char *msg = "Hello from child process!";
        write(fd[1], msg, strlen(msg) + 1);
        close(fd[1]);
        _exit(EXIT_SUCCESS);
    } else {
        /* 父进程:关闭写端,从管道读取数据 */
        close(fd[1]);
        ssize_t n = read(fd[0], buf, sizeof(buf));
        if (n > 0) {
            printf("Parent received: %s\n", buf);
        }
        close(fd[0]);
        wait(NULL);
    }

    return 0;
}

3.5 双向管道通信示例

单向管道只能实现一个方向的数据流动。要实现父子进程之间的双向收发,需要创建两个管道:一个用于父进程向子进程发送数据,另一个用于子进程向父进程发送数据。

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

#define BUF_SIZE 128

int main() {
    int parent_to_child[2];
    int child_to_parent[2];
    pid_t pid;
    char buf[BUF_SIZE];

    if (pipe(parent_to_child) == -1 || pipe(child_to_parent) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }

    pid = fork();
    if (pid < 0) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        /* 子进程:父->子管道保留读端,子->父管道保留写端 */
        close(parent_to_child[1]);
        close(child_to_parent[0]);

        read(parent_to_child[0], buf, sizeof(buf));
        printf("Child received: %s\n", buf);

        const char *reply = "Hello from child!";
        write(child_to_parent[1], reply, strlen(reply) + 1);
        printf("Child sent: %s\n", reply);

        close(parent_to_child[0]);
        close(child_to_parent[1]);
        _exit(EXIT_SUCCESS);
    } else {
        /* 父进程:父->子管道保留写端,子->父管道保留读端 */
        close(parent_to_child[0]);
        close(child_to_parent[1]);

        const char *msg = "Hello from parent!";
        write(parent_to_child[1], msg, strlen(msg) + 1);
        printf("Parent sent: %s\n", msg);

        read(child_to_parent[0], buf, sizeof(buf));
        printf("Parent received: %s\n", buf);

        close(parent_to_child[1]);
        close(child_to_parent[0]);
        wait(NULL);
    }

    return 0;
}

这里一共需要关闭 4 个文件描述符:

  • 子进程关闭:parent_to_child[1](写端)、child_to_parent[0](读端)
  • 父进程关闭:parent_to_child[0](读端)、child_to_parent[1](写端)

为什么要关闭这 4 个文件描述符?

  • 每个管道都是半双工的,进程只应保留自己真正需要的那一端。
  • 关闭本进程不需要的读端,是为了保证当管道写端全部关闭后,对端的 read() 能返回 0。例如父进程若不关闭 parent_to_child[0],即使子进程读完了数据,父进程自己也还持有该管道读端,子进程的 read() 就永远等不到 EOF,从而阻塞。
  • 关闭本进程不需要的写端,既能避免误写数据,也能让对端通过 read() 返回 0 正确判断数据发送完毕,避免双向通信中的死锁。

预期运行输出:

Parent sent: Hello from parent!
Child received: Hello from parent!
Child sent: Hello from child!
Parent received: Hello from child!

3.6 关键注意事项

无名管道的通用编程顺序为:

  1. 使用 pipe() 创建管道
  2. 调用 fork() 创建子进程
  3. 父子进程按需关闭不使用的端口,读写管道
  4. 通信结束后关闭管道
  • 关闭未使用的端口:子进程写数据前必须关闭读端,父进程读数据前必须关闭写端,否则可能出现阻塞或数据错乱。
  • 读阻塞:当管道为空且写端未关闭时,read() 会阻塞等待数据。
  • 写阻塞:当读端存在,但写入数据超过 64KB 缓冲区上限时,继续写入会阻塞,直到对端读出数据腾出空间。
  • 读到 EOF:当所有写端关闭且管道中没有内容时,read() 返回 0,表示管道已无数据可读,进程间通信结束。
  • SIGPIPE 信号:当读端全部关闭后,继续向管道写入数据会导致管道破裂,触发 SIGPIPE 信号,默认行为是终止写进程。
  • 关闭未使用的端口:子进程写数据前必须关闭读端,父进程读数据前必须关闭写端,否则可能出现阻塞或数据错乱。
  • 读阻塞:当管道为空且写端未关闭时,read() 会阻塞等待数据。
  • 读到 EOF:当所有写端关闭后,read() 返回 0,表示管道已无数据可读。
  • SIGPIPE 信号:当读端全部关闭后,向管道写入数据会触发 SIGPIPE 信号,默认行为是终止进程。

4. 有名管道

4.1 为什么需要有名管道

无名管道的最大局限在于:它只能在有亲缘关系的进程之间使用。当两个互不相干的进程需要通信时,无名管道就无能为力了。有名管道正是为了解决这一问题而引入的。

4.2 什么是有名管道

有名管道,也称 FIFO,是一种存在于文件系统中的特殊文件。它拥有一个路径名,任何知道该路径名的进程都可以打开它进行通信,不再受亲缘关系限制。

有名管道与无名管道的核心区别:

对比项无名管道有名管道
文件系统可见性不可见以特殊文件形式存在
通信范围父子/兄弟进程任意进程
创建方式pipe()mkfifo()
打开方式继承文件描述符open()
持久性随进程销毁手动删除才销毁

4.3 创建有名管道

使用 mkfifo() 函数创建有名管道:

#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *pathname, mode_t mode);
  • pathname:管道文件的路径名
  • mode:权限,如 0666(受 umask 影响)
  • 成功返回 0,失败返回 -1

也可以通过 shell 命令创建:

mkfifo mypipe

4.4 写端示例

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

#define FIFO_PATH "/tmp/myfifo"

int main() {
    /* 若管道不存在则创建 */
    if (access(FIFO_PATH, F_OK) == -1) {
        if (mkfifo(FIFO_PATH, 0666) == -1) {
            perror("mkfifo");
            exit(EXIT_FAILURE);
        }
    }

    int fd = open(FIFO_PATH, O_WRONLY);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    const char *msg = "Hello from writer process!";
    write(fd, msg, strlen(msg) + 1);
    printf("Writer sent data.\n");

    close(fd);
    return 0;
}

4.5 读端示例

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>

#define FIFO_PATH "/tmp/myfifo"
#define BUF_SIZE 128

int main() {
    /* 若管道不存在则创建 */
    if (access(FIFO_PATH, F_OK) == -1) {
        if (mkfifo(FIFO_PATH, 0666) == -1) {
            perror("mkfifo");
            exit(EXIT_FAILURE);
        }
    }

    int fd = open(FIFO_PATH, O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    char buf[BUF_SIZE];
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) {
        printf("Reader received: %s\n", buf);
    }

    close(fd);
    return 0;
}

4.6 有名管道的阻塞行为

open() 打开有名管道时有以下阻塞规则:

  • 只读打开:阻塞直到有进程以写方式打开同一个管道。
  • 只写打开:阻塞直到有进程以读方式打开同一个管道。
  • 使用 O_NONBLOCK 标志可以改变阻塞行为,只读打开立即返回成功;若没有读端,只写打开会失败并返回 ENXIO。

5. 典型应用场景

5.1 无名管道典型场景

  • 父子进程之间的简单数据传递
  • Shell 管道符 | 的底层实现,如 ls | grep txt
  • 进程中简单的任务分工与结果回传

5.2 有名管道典型场景

  • 客户端-服务端架构中,服务端与多个客户端之间的请求分发
  • 日志收集系统,多个进程向同一管道写入日志
  • 跨进程的命令控制,如向后台守护进程发送指令
  • 不同语言编写的进程之间交换数据

6. 管道通信的局限与改进

管道虽然简单易用,但也存在一些固有局限:

  • 数据无格式:管道传输的是字节流,收发双方需要自行约定数据结构。
  • 缓冲区大小固定:大量数据写入会阻塞。
  • 数据一次性读取:已被读取的数据无法再次读取。
  • 无消息边界:连续写入的数据可能被一次读出,需要应用层处理。

针对这些局限,可以根据实际需求选择更合适的 IPC 方式:

  • 需要结构化消息和消息边界 → 消息队列
  • 需要高速大数据量传输 → 共享内存
  • 需要双向持久连接 → Unix 域套接字

7. 总结

本文系统地介绍了进程间通信中最基础的两种管道机制:

  • 无名管道通过 pipe() 创建,不占用文件系统,简单高效,适用于父子进程之间的单向通信,是理解 IPC 的入门首选。
  • 有名管道通过 mkfifo() 创建,以特殊文件形式存在,打破了亲缘进程的限制,允许任意进程通过路径名建立通信。

掌握管道的工作原理和使用细节,是深入学习 Linux 多进程编程的重要基石。建议动手运行文中的示例代码,并通过修改阻塞参数、增加双向管道等方式加深理解。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐