Linux 进程间通信--匿名管道

Linux 进程间通信--匿名管道

七月 20, 2025 次阅读

管道通信定义

管道是Unix中最古老的进程间通信的形式,我们把从一个进程连接到另一个进程的一个数据流称为一个管道

在Linux中,父进程通过 fork() 创建子进程时,子进程会继承父进程的文件描述符表(files_struct),这是一种浅拷贝——父子进程的文件描述符(如 fd[0])指向同一个内核文件对象(struct file),包括共享文件偏移量、访问模式等状态。因此,父子进程可以通过相同的文件描述符并发读写同一个文件

若需要进程间共享数据,除了普通磁盘文件,还可以使用基于内存的通信媒介,主要包括以下两类机制:

  1. 内存文件系统(如 tmpfs/ramfs

    • 数据完全驻留内存,拥有完整的文件系统抽象(inodefile_operations),但永不触盘
    • 适合作为临时文件的存储媒介,但并非最优的进程通信(IPC)方式,因文件操作需经过VFS层,存在额外开销。
  2. 专用IPC机制

    • 匿名管道:通过 pipe() 创建,仅在内核缓冲区中流转数据,无文件系统关联(无 inode),适用于父子进程通信。
    • 共享内存:直接映射同一物理内存区域,完全绕过文件系统,性能最高。
    • 其他:消息队列、信号量等。

那么,管道通信为什么需要通过内存级文件实现资源共享呢?

  • 性能:内存的访问速度远高于磁盘。使用内核缓冲区(如匿名管道或命名管道)可以避免磁盘I/O,极大地提升数据传输的速度,减少延迟。
  • 临时性:管道通常用于进程间的短暂通信,数据是临时的,不需要持久化存储。使用内存级文件可以在进程完成通信后自动释放资源,而使用磁盘文件则需要显式清理。
  • 同步和并发性(最重要):管道是一种同步通信机制,进程可以通过管道实现数据流的同步传递。使用内存级文件可以更好地管理并发和同步,而磁盘文件的使用可能导致额外的锁机制,增加复杂性和延迟。
  • 资源效率:管道的设计是为了实现轻量级的进程间通信,仅需维护一个固定大小的内核缓冲区,而磁盘文件通常需要更多的系统资源(如磁盘空间和I/O操作)

父进程在创建管道时,为了避免读写冲突,会创建**两个文件描述符(fd)**指向同一个管道对象(内核缓冲区),分别用于读和写。这两个fd会对应两个独立的struct file对象(一个设置读模式,一个设置写模式)。若不分离读写,共用一个struct file会导致文件偏移量(f_pos)竞争,引发读写混乱。

当父进程fork()出子进程时,子进程会继承这两个fd及对应的struct file对象(引用计数+1),因此父子进程最初均持有读写两端。为实现单向通信:

  1. 若子进程负责写,则关闭读端的fd(减少读端struct file的引用计数);
  2. 若父进程负责读,则关闭写端的fd(减少写端struct file的引用计数)。
    最终,读端和写端的struct file引用计数均降为1,确保读写完全隔离,互不干扰。

fork

fork

因其单向数据流特性,这一机制(两个struct file对象+内核缓冲区)被命名为管道。由于它仅用于进程间通信,无需磁盘存储和文件路径,故称为匿名管道(无inode,不关联文件系统)。

管道通信不仅仅只局限于父子进程,只要是有血缘关系的进程都可以进行管道通信,例如,父进程创建两个子进程,随后父进程将读写端都关闭了,这时两个兄弟进程的读写段都和父进程一样,故他们直接也可以构成管道通信,父进程创建的子进程,子进程再创建一个子进程,那么由于父进程和它的孙子进程共享同一个file_struct,故也可以构成管道通信。

管道通信使用

pipe 是 Linux/Unix 系统中用于创建匿名管道(Anonymous Pipe)的系统调用,主要用于父子进程或兄弟进程之间的单向通信。它创建一个内核缓冲区,并返回两个文件描述符(fd),一个用于,一个用于


函数原型

#include <unistd.h>

int pipe(int pipefd[2]);
  • 参数
    • pipefd[2]:一个长度为 2 的整型数组,用于存储返回的文件描述符。
      • pipefd[0]读端(从管道读取数据)。
      • pipefd[1]写端(向管道写入数据)。
  • 返回值
    • 成功时返回 0,失败返回 -1 并设置 errno

基本使用流程

(1)创建管道

int pipefd[2];
if (pipe(pipefd) == -1) {
    perror("pipe");
    exit(EXIT_FAILURE);
}
  • 此时:
    • pipefd[0] 可用于 read()(读数据)。
    • pipefd[1] 可用于 write()(写数据)。

(2)fork() 创建子进程

pid_t pid = fork();
if (pid == -1) {
    perror("fork");
    exit(EXIT_FAILURE);
}
  • 父进程和子进程都会继承 pipefd,因此两者都可以读写管道。

(3)关闭不需要的文件描述符

为了实现单向通信,通常:

  • 父进程关闭写端,只读数据
close(pipefd[1]);  // 父进程关闭写端
  • 子进程关闭读端,只写数据
close(pipefd[0]);  // 子进程关闭读端

这样:

  • 父进程只能 read()(等待子进程写入)。
  • 子进程只能 write()(发送数据给父进程)。

关键特性

特性 说明
单向通信 通常一个进程读,另一个进程写。
阻塞机制 - 读空管道会阻塞,直到有数据写入。
- 写满管道会阻塞,直到有空间。
内核缓冲区 默认大小通常为 64KB(可通过 fcntl 修改)。
文件描述符继承 fork() 后子进程继承 pipefd,需手动关闭不需要的端。
匿名管道 无文件名,仅用于相关进程(父子/兄弟)通信。

使用用例

下面是一个综合测试用例,我们让父进程持续读取子进程的数据,而子进程每隔一秒写入一段数据到管道中

#include <iostream>
#include <unistd.h>
#include <cstring>
#include <sys/wait.h>

#define SIZE 1024

enum global_ret{
    pipe_error = 1,
    write_error,
    read_error,
};

using namespace std;

void Write(int fd)
{
    int cnt = 5;
    char inbuffer[SIZE];
    while(cnt--)
    {
        int pid = getpid();
        // 告知此处将 inbuffer 作为字符串缓冲区使用
        inbuffer[0] = 0;
        snprintf(inbuffer, SIZE - 1, "%s-%d: %s %d", "pid", pid, "hello, linux", cnt);
        int n = write(fd, inbuffer, strlen(inbuffer));
        if(n == -1)
        {
            perror("write error");
            exit(global_ret::write_error);
        }
        sleep(1);
    }
    exit(0);
}

void Read(int fd)
{
    char outbuffer[SIZE];
    while(true)
    {
        int n = read(fd, outbuffer, sizeof(outbuffer));
        if(n == 0)
        {
            // 读取到了文件末尾,不再读取
            return;
        }else if(n == -1)
        {
            perror("read error");
            exit(global_ret::read_error);
        }
        outbuffer[n] = 0;
        cout << "parent process get message: " << outbuffer << endl;
    }
}

int main()
{
    int fd[2];
    if(pipe(fd) == -1)
    {
        perror("pipe error");
        return global_ret::pipe_error;
    }
    pid_t id = fork();
    if(id == 0)
    {
        // 子进程
        // 关闭读端口
        close(fd[0]);
        // 执行写操作
        Write(fd[1]);
        // 退出时关闭写端口(关闭所有端口)
        close(fd[1]);
    }
    // 父进程
    // 关闭写端口
    close(fd[1]);
    // 执行读操作
    Read(fd[0]);
    // 退出时关闭读端口(关闭所有端口)
    close(fd[0]);
    int status = 0;
    int ret = waitpid(id, &status, 0);
    // 正常退出
    if (WIFEXITED(status)) {
        cout << "Child process " << id << " exited with code: " << WEXITSTATUS(status) << endl;
    } else {
        // 异常退出
        cout << "Child process " << id << " killed by signal: " << WTERMSIG(status) << endl;
    }
    return 0;
}

运行结果如下:

╭─ljx@VM-16-15-debian ~/linux_review/pipe  
╰─➤  ./main.o
parent process get message: pid-3873378: hello, linux 4
parent process get message: pid-3873378: hello, linux 3
parent process get message: pid-3873378: hello, linux 2
parent process get message: pid-3873378: hello, linux 1
parent process get message: pid-3873378: hello, linux 0
Child process 3873378 exited with code: 0

父进程的“每隔一秒读取一次”是 管道阻塞特性进程调度 的自然结果:

(1) 管道的阻塞行为

  • 当管道为空时,父进程的 read()阻塞(暂停执行),直到子进程写入数据。
  • 当管道有数据时,父进程的 read() 会立即返回数据。
  • 当所有写端关闭时read() 返回 0(EOF)。

(2) 子进程的写入节奏

  • 子进程每次写入后 sleep(1),导致数据以 每秒一条 的速率进入管道。
  • 父进程的 read()被动等待子进程的数据,因此看起来像是“同步”读取。

(3) 没有“乱码”风险

  • 管道是内核管理的缓冲区,读写操作是原子的,不会出现“读一半”或“写一半”的乱码问题。
    • 如果父进程尝试读取时子进程正在写入,内核会保证 read() 要么读到完整数据,要么阻塞等待。

异常情况

(1)子进程疯狂写入,父进程延迟读取

  • 现象
    子进程持续写入数据,直到管道缓冲区满(默认大小通常为 64KB),此时子进程的 write()阻塞,直到父进程读取数据腾出空间。
  • 原因
    管道是固定大小的内核缓冲区,当缓冲区满时,write() 会阻塞(除非设置为非阻塞模式)。

阻塞是内核的流量控制机制,防止生产者(子进程)过快导致数据丢失。


(2)父进程读取空管道

  • 现象
    父进程调用 read() 时,若管道无数据且写端未关闭,父进程会阻塞,直到子进程写入数据。
  • 原因
    管道设计为同步通信,读操作默认阻塞等待数据。

若需要非阻塞读取,可通过 fcntl(fd, F_SETFL, O_NONBLOCK) 设置,此时 read() 会返回 -1 并设置 errno=EAGAIN


(3)子进程退出,父进程读取

  • 现象
    子进程退出后关闭写端,父进程的 read() 返回 0(EOF),表示无更多数据,不会阻塞
  • 原因
    当管道所有写端关闭时,read() 会立即返回 0,通知读端通信结束。

返回 0 是合法的“文件结束”标志,不是错误


(4)父进程退出,子进程写入

  • 现象
    父进程关闭读端后退出,子进程继续写入会触发 SIGPIPE 信号(默认终止进程),错误码为 EPIPE
  • 原因
    当管道读端关闭时,写操作无意义,内核通过 SIGPIPE 终止子进程。

若我们忽略 SIGPIPEsignal(SIGPIPE, SIG_IGN)),此时 write() 返回 -1errno=EPIPE


总结对比

场景 子进程行为 父进程行为 结果
写端疯狂写入,缓冲区满 write() 阻塞 正常读取 子进程暂停,直到父进程读取
读端读取空管道 未写入数据 read() 阻塞 父进程暂停,直到子进程写入
写端关闭,读端尝试读取 已退出 read() 返回 0 父进程检测到 EOF
读端关闭,写端尝试写入 write() 触发 SIGPIPE 已退出 子进程被终止(信号 13)
  1. 管道缓冲区大小
    可通过 fcntl(fd, F_SETPIPE_SZ, size) 修改(需权限)。
  2. 非阻塞模式
    设置 O_NONBLOCK 后,read()write() 在无法立即完成时会返回 -1errno=EAGAIN)。
  3. 原子性保证
    管道保证 ≤PIPE_BUF(通常 4KB)的写入是原子的,避免多进程写入交织。

下面我们来验证一下父进程退出,子进程写入的现象:

父进程读取2秒后就退出,而子进程会持续写入5秒

void Read(int fd)
{
    char outbuffer[SIZE];
    int cnt = 2;
    while(cnt--)
    {
        int n = read(fd, outbuffer, sizeof(outbuffer));
        if(n == 0)
        {
            // 读取到了文件末尾,不再读取
            return;
        }else if(n == -1)
        {
            perror("read error");
            exit(global_ret::read_error);
        }
        outbuffer[n] = 0;
        cout << "parent process get message: " << outbuffer << endl;
    }
}

运行结果如下:

╭─ljx@VM-16-15-debian ~/linux_review/pipe  
╰─➤  ./main.o
parent process get message: pid-3876701: hello, linux 4
parent process get message: pid-3876701: hello, linux 3
Child process 3876701 killed by signal: 13

可以看到子进程被 13 号信号杀掉了