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

匿名管道(Pipe)

pipe() 是 Linux/Unix 系统提供的系统调用 ,核心功能是创建一条「匿名管道」------ 内核中的一块内存缓冲区,用于亲缘进程间(父子 / 兄弟进程)的单向字节流通信 。它是最基础的 IPC 机制之一,也是管道符 |(如 ls | grep txt)的底层实现。

函数原型与基础用法

函数定义(需包含头文件 <unistd.h>

复制代码
int pipe(int pipefd[2]);

参数pipefd[2] 是一个整型数组,用于接收管道的两个文件描述符:

  • pipefd[0]:管道的读端(只能读,不能写);
  • pipefd[1]:管道的写端(只能写,不能读)。

返回值

  • 成功:返回 0
  • 失败:返回 -1,并设置 errno(如 EMFILE 表示文件描述符耗尽)。

核心功能

  • 内核在内存中创建一个单向(半双工通信 )的字节流缓冲区 (管道),通过两个文件描述符(fd)操作:fd[0] 读端、fd[1] 写端;
  • 仅支持父子 / 兄弟进程(有共同祖先)间通信(因为管道无名字,只能通过 fork 继承 fd);
  • 随进程销毁:所有关联的文件描述符关闭后,内核自动释放缓冲区,无持久化存储。

匿名管道的核心特性

1. 阻塞特性(默认)

代码中子进程的read(read_fd, &task, sizeof(Task))是阻塞调用:

  • 若无任务时,子进程会阻塞在read处,直到父进程write任务;
  • 可通过fcntl(pipe_fd[0], F_SETFL, O_NONBLOCK)设置为非阻塞(无数据时返回EAGAIN)。
2. 引用计数决定管道生命周期

管道的 "整个关闭 / 销毁" 由内核的「引用计数」决定,单一方关闭 FD(或进程结束)只会减少对应端的引用计数,只有当管道的所有读端、写端引用计数都归 0 时,内核才会销毁管道缓冲区(即 "关闭整个管道")

代码中进程池析构时close(fd)的核心作用:

  • 父进程关闭所有写端 FD 后,管道的writers引用计数变为 0;
  • 子进程的read会返回 0(EOF),触发退出逻辑;
  • 若不关闭无用 FD(比如父进程不关闭读端),子进程的read会一直阻塞(因为writers > 0)。
3. 数据是字节流(无边界)

管道传输的是「无结构的字节流」,需上层约定数据格式:

  • 代码中Task是 POD 类型,通过sizeof(Task)固定长度读取,确保数据完整性;
  • 若传输变长数据(如字符串),需在数据中增加长度标识(如先写长度,再写内容),避免粘包。

使用流程

  1. 父进程调用 pipe() 创建管道,得到读 / 写两个文件描述符;
  2. 父进程调用 fork() 创建子进程,子进程会继承父进程的两个管道文件描述符;
  3. 父 / 子进程关闭不需要的端(比如父写子读:父关闭读端 pipefd[0],子关闭写端 pipefd[1]);
  4. 进程通过 read()/write() 操作管道的读 / 写端传输数据;
  5. 通信完成后,关闭剩余的文件描述符。

示例代码(父写子读)

cpp 复制代码
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>

int main() {
    int pipefd[2];  // pipefd[0] = 读端,pipefd[1] = 写端
    pid_t pid;
    char buf[1024];

    // 1. 创建管道
    if (pipe(pipefd) == -1) {
        perror("pipe failed");  // 错误打印
        return 1;
    }

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

    if (pid == 0) {  // 子进程:读数据
        close(pipefd[1]);  // 关闭写端(子进程只读)
        ssize_t n = read(pipefd[0], buf, sizeof(buf));  // 从管道读
        if (n > 0) {
            printf("子进程收到数据:%s\n", buf);
        }
        close(pipefd[0]);  // 关闭读端
        return 0;
    } else {  // 父进程:写数据
        close(pipefd[0]);  // 关闭读端(父进程只写)
        const char *msg = "Hello from Parent Process!";
        write(pipefd[1], msg, strlen(msg) + 1);  // +1 包含字符串终止符 '\0'
        close(pipefd[1]);  // 关闭写端(触发子进程 read() 返回 EOF)
        wait(NULL);        // 等待子进程执行完毕
        return 0;
    }
}

优缺点 & 适用场景

  • 优点:接口简单、无额外资源开销(管道在内存中,随进程销毁);
  • 缺点:仅父子 / 兄弟进程、半双工、无消息边界;
  • 适用:父子进程间简单的单向数据传输(如父进程给子进程传递配置)。

底层原理

内核数据结构(核心)

管道的本质是内核维护的一个「管道对象」,包含以下关键结构:

cpp 复制代码
// 内核中管道对象的简化模型(实际定义在 linux/pipe_fs_i.h)
struct pipe_inode_info {
    char* buffer;          // 核心:环形缓冲区(默认大小 4KB,可配置)
    unsigned int size;     // 缓冲区总大小(通常为 PAGE_SIZE,即 4096 字节)
    unsigned int r_pos;    // 读指针:下一次读取的位置
    unsigned int w_pos;    // 写指针:下一次写入的位置
    unsigned int count;    // 缓冲区中已存储的字节数
    int readers;           // 读端引用计数(有多少进程持有读端 FD)
    int writers;           // 写端引用计数(有多少进程持有写端 FD)
    struct wait_queue_head wait_read;  // 读等待队列(缓冲区空时,读进程阻塞)
    struct wait_queue_head wait_write; // 写等待队列(缓冲区满时,写进程阻塞)
};
  • 环形缓冲区:解决缓冲区首尾衔接问题,写指针到末尾后回到起点,避免内存碎片;
  • 引用计数 :内核通过readers/writers判断管道是否可用(比如写端引用为 0 时,读端读取会直接返回 EOF);
  • 等待队列:实现读写的阻塞机制(无数据时读阻塞,无空间时写阻塞)。

管道的创建流程(pipe()系统调用)

代码中pipe(pipe_fd)的底层执行逻辑:

复制代码
// 代码中的调用
int pipe_fd[2];
pipe(pipe_fd); // 触发以下内核操作

内核执行步骤:

  1. 分配缓冲区:在内核态申请一块连续的内存作为管道的环形缓冲区(默认 4KB);
  2. 创建管道对象 :初始化pipe_inode_info(读写指针置 0、引用计数置 0、等待队列初始化);
  3. 分配文件描述符 :从当前进程的文件描述符表中,分配两个未使用的 FD(如 3 和 4):
    • pipe_fd[0](读端):关联管道对象的读操作接口;
    • pipe_fd[1](写端):关联管道对象的写操作接口;
  4. 更新引用计数 :将管道对象的readerswriters各加 1(当前进程同时持有读写端);
  5. 返回用户态 :将两个 FD 写入pipe_fd数组,返回 0 表示成功。

进程间共享管道(fork()的关键作用)

匿名管道只能用于亲缘进程,核心原因是fork()复制父进程的文件描述符表

cpp 复制代码
// 代码中 fork 后的 FD 继承逻辑
pid_t pid = fork();
if (pid == 0) {
    // 子进程:继承父进程的 pipe_fd[0] 和 pipe_fd[1]
    close(pipe_fd[1]); // 关闭写端,只保留读端
} else {
    // 父进程:保留 pipe_fd[1],关闭 pipe_fd[0]
    close(pipe_fd[0]);
}
  • fork()后,父子进程的pipe_fd[0]pipe_fd[1]都指向同一个内核管道对象
  • 父子进程通过关闭不需要的 FD(父关读端、子关写端),形成「父写子读」的单向通信链路;
  • 若没有fork(),其他进程无法获取管道的 FD(匿名管道无文件路径,无法通过open()打开)。

管道的读写机制

(1)写操作(write(pipe_fd[1], data, len)

内核执行逻辑:

  1. 检查管道写端引用计数(writers > 0):若为 0,触发SIGPIPE信号(进程默认崩溃);
  2. 检查缓冲区剩余空间:
    • 若空间足够:将数据拷贝到缓冲区,更新写指针w_pos和字节数count,唤醒读等待队列中的进程;
    • 若空间不足:当前进程进入写等待队列(阻塞),直到读进程取走数据、腾出空间;
  3. 特殊规则:原子写 ------ 若写入长度 ≤ PIPE_BUF(默认 4KB),内核保证写操作原子性(多进程同时写不会出现数据错乱);若超过 4KB,不保证原子性。
(2)读操作(read(pipe_fd[0], buf, len)

内核执行逻辑:

  1. 检查管道读端引用计数(readers > 0):若为 0,返回 -1(错误);
  2. 检查缓冲区数据:
    • 若有数据:将数据拷贝到用户态缓冲区,更新读指针r_pos和字节数count,唤醒写等待队列中的进程;
    • 若无数据:
      • 若写端引用计数writers > 0:当前进程进入读等待队列(阻塞),直到写进程写入数据;
      • 若写端引用计数writers = 0:返回 0(EOF,标识管道已关闭);
  3. 返回值:实际读取的字节数(≤ 请求长度len)。

管道不是磁盘文件,是内核态的一块连续内存缓冲区 (大小通常为 4KB~64KB,可通过fcntl调整),由内核管理,进程无法直接访问,只能通过文件描述符(fd)间接操作;

我们知道每个进程都会有一个fd表指针, 指向进程的fd表.

fork 子进程时:内核为子进程创建全新的 FD 表 ,并将父进程 FD 表中「FD 编号 → 文件表项」的映射关系完整复制到子进程的 FD 表中;结果:父子进程的 FD 表是「两个独立的内核对象」,只是表项内容(比如 FD 3 都指向文件表项 X)完全相同

因此操作系统 引入了COW写时拷贝, 当子进程修改父进程的全局变量, 就会触发COW, 为子进程单独创建一个新的全局变量;对于管道也是类似的道理, 管道同样由子进程和父进程通过fd表关联, 但是管道有个特点: 管道不属于虚拟地址空间, 也不是用户态的文件.

匿名管道的「一块环形缓冲区」对应一个内存 inode(资源载体),而「两个 file 结构体」是内核为「读、写两个操作端点」创建的独立上下文:

  • 共享 inode → 保证读写操作的是同一块缓冲区;
  • 独立 file → 实现读写端的操作逻辑、状态、生命周期分离,满足管道「半双工、读写分离」的核心语义。

为什么 "修改管道相关内容" 不触发 COW?

COW 的核心触发条件(关键前提)

写时拷贝仅针对 进程虚拟地址空间的「用户态物理内存页」,且必须同时满足三个条件:

  1. 多进程共享同一块「用户态」物理内存页;
  2. 该内存页被标记为「只读 + COW 标识」;
  3. 有进程尝试「修改」该内存页。

核心关键点:内核态的所有数据(包括 file 结构体、管道缓冲区)都不在 COW 的管辖范围内------COW 是为了优化「进程用户态内存共享」设计的,和内核态数据无关。

「通过 fd 写管道缓冲区」和「修改 fd 表 /file 结构体」,两者都不触发 COW,原因分别如下:

场景 1:通过 fd 写管道缓冲区(核心操作)

当父 / 子进程调用 write(fd[1], data, len) 写管道时,流程是:

plaintext

复制代码
进程用户态内存(data) → 内核态 file 结构体 → 管道缓冲区
  • 写操作的本质是「将用户态数据拷贝到内核态的管道缓冲区」,而非 "修改共享的用户态内存页";
  • 管道缓冲区是内核态内存,不属于任何进程的用户态地址空间,因此即使多个进程写,也不存在 "共享用户态页" 的前提,自然不触发 COW;
  • 每个进程的 data 是自己的私有用户态内存,修改自己的私有内存(比如给 data 赋值),也不会触发 COW(COW 仅针对「共享页」)。

场景 2:修改 fd 表(比如子进程 close (fd [0]))

fork 后父子进程的 fd 表初始是共享同一块物理内存页(标记为 COW),但:

  • 若子进程仅「使用」fd(比如读 / 写管道),不修改 fd 表 → 无 COW;
  • 若子进程「修改」fd 表(比如 close (fd [0])、dup (fd))→ 触发 fd 表所在页的 COW(子进程获得 fd 表的独立副本);
  • 但这是「fd 表的 COW」,而非「管道 /file 结构体的 COW」:
    • file 结构体是内核态数据,多个进程的 fd 指向同一个 file 结构体是内核的 "引用计数管理"(file 结构体有 f_count 字段,记录引用它的进程数),修改 fd 表只会改变引用计数,不会拷贝 file 结构体;
    • 管道缓冲区仍为内核态唯一副本,不受 fd 表 COW 的影响。

实例代码:进程池

cpp 复制代码
#include <iostream>   // 标准输入输出流(cout, cerr)
#include <vector>     // 动态数组容器,用于存储PID和文件描述符
#include <unistd.h>   // Unix标准库(fork, pipe, read, write, close等)
#include <sys/wait.h> // 进程等待相关(waitpid)
#include <sys/types.h>// 系统类型定义(pid_t等)
#include <cstring>    // C字符串操作(strerror)
#include <stdexcept>  // 标准异常类(std::runtime_error, std::invalid_argument)

// 定义简易任务结构体(序列化传输)
struct Task {
    int task_id;    // 任务ID,-1表示退出指令(特殊信号)
    int a;          // 计算参数1
    int b;          // 计算参数2
    
    // 这个结构体需要满足:
    // 1. 是POD(Plain Old Data)类型,可以安全序列化
    // 2. 大小固定(3个int,通常12字节),方便通过管道传输
    // 3. 没有指针成员,避免跨进程地址空间问题
    // 4. 包含退出机制(task_id = -1)
};

// 简易进程池类
class ProcessPool {
public:
    // 构造函数:创建指定数量的子进程
    ProcessPool(int num_processes) : num_processes_(num_processes) {
        if (num_processes <= 0) {
            throw std::invalid_argument("进程数必须大于0");
        }
        create_processes();
    }

    // 析构函数:回收子进程、关闭文件描述符
    ~ProcessPool() {
        // 向所有子进程发送退出指令
        Task exit_task{-1, 0, 0}; // 特殊任务:task_id = -1表示退出
        for (int fd : write_fds_) {
            // 1. 发送退出信号
            write(fd, &exit_task, sizeof(Task));
            // 2. 关闭写端,触发子进程read返回0(管道EOF)
            close(fd); 
        }
        // write_fds_中的fd是父进程的写端,关闭它们会:
        // - 使子进程的read返回0,从而退出循环
        // - 释放内核中的管道资源

        // 等待所有子进程退出(避免僵尸进程)
        for (pid_t pid : pids_) {
            waitpid(pid, nullptr, 0); // 阻塞等待,不关心退出状态
            std::cout << "子进程 " << pid << " 已退出" << std::endl;
        }
        
        // 设计要点:
        // 1. 确保资源释放:管道文件描述符必须关闭
        // 2. 避免僵尸进程:必须waitpid回收子进程资源
        // 3. 优雅关闭:先通知退出,再等待,避免强制kill
    }

    // 提交任务到进程池(简易版:轮询分发任务)
    void submit_task(const Task& task) {
        static int idx = 0; // 静态变量,保持轮询状态
        
        // 获取当前轮询的子进程对应的管道写端
        int fd = write_fds_[idx];
        
        // 向子进程管道写端写入任务(阻塞写入)
        ssize_t ret = write(fd, &task, sizeof(Task));
        if (ret != sizeof(Task)) {
            // 错误处理:写入失败(可能管道已关闭)
            throw std::runtime_error("任务提交失败:" + std::string(strerror(errno)));
        }
        
        std::cout << "父进程:提交任务 " << task.task_id 
                  << " 到子进程 " << pids_[idx] << std::endl;
        
        // 更新轮询索引,实现简单的负载均衡
        idx = (idx + 1) % num_processes_;
        
        // 轮询策略分析:
        // 优点:简单、公平,每个子进程获得相同数量的任务
        // 缺点:不考虑子进程的负载差异,可能某些进程处理慢
    }

private:
    int num_processes_;                // 子进程数量,决定并行度
    std::vector<pid_t> pids_;          // 子进程PID数组,用于进程管理
    std::vector<int> write_fds_;       // 父进程写端文件描述符数组
    // 设计说明:
    // 1. num_processes_:控制并发级别,根据CPU核心数调整
    // 2. pids_:记录所有子进程ID,便于后续管理和回收
    // 3. write_fds_:每个子进程对应一个管道写端,父进程通过这些fd发送任务

    // 子进程处理逻辑:循环读取任务并执行
    static void child_process(int read_fd) {
    // 注意:这是静态方法,没有this指针,可以安全地在子进程中运行
        Task task;
        
        // 无限循环,直到收到退出指令或管道关闭
        while (true) {
            // 阻塞读取管道中的任务,从read_fd读取sizeof(task)字节的内容,存放在task
            ssize_t ret = read(read_fd, &task, sizeof(Task));
            
            // 读取失败或管道关闭
            if (ret <= 0) {
                break; // 退出循环,结束子进程
            }
            
            // 检查是否为退出指令
            if (task.task_id == -1) {
                break; // 收到退出信号,终止子进程
            }
            
            // 处理任务(这里只是简单的加法计算)
            int result = task.a + task.b;
            
            // 输出执行结果
            std::cout << "子进程 " << getpid() << ":处理任务 " << task.task_id 
                      << " -> " << task.a << " + " << task.b 
                      << " = " << result << std::endl;
        }
        
        // 清理资源:关闭管道读端
        close(read_fd);
        
        // 子进程正常退出
        exit(0);
        
        // 关键点:
        // 1. 子进程完全独立,有自己的地址空间
        // 2. 通过read系统调用阻塞等待任务
        // 3. exit(0)确保子进程正确退出,不会执行父进程代码
    }

    // 创建子进程和通信管道
    void create_processes() {
        for (int i = 0; i < num_processes_; ++i) {
            // 步骤1:创建管道
            int pipe_fd[2]; // pipe_fd[0]: 读端,pipe_fd[1]: 写端
            if (pipe(pipe_fd) == -1) {
                throw std::runtime_error("管道创建失败:" + std::string(strerror(errno)));
            }
            // pipe() 在内核中创建缓冲区,返回两个文件描述符
            // 父子进程通过读写这两个fd进行通信
            
            // 步骤2:创建子进程
            pid_t pid = fork();
            if (pid == -1) {
                throw std::runtime_error("fork失败:" + std::string(strerror(errno)));
            }
            
            if (pid == 0) { 
                // 子进程代码块
                // ------------------------------------------
                // 重要:子进程继承父进程的所有文件描述符
                // 包括刚创建的pipe_fd[0]和pipe_fd[1]
                
                // 子进程关闭写端,只保留读端
                close(pipe_fd[1]);
                // 原因:子进程只需要读取任务,不需要写入
                
                // 进入任务处理循环(不会返回)
                child_process(pipe_fd[0]);
                // 注意:child_process会调用exit(),所以后面的代码不会执行
                // ------------------------------------------
            } else { 
                // 父进程代码块
                // ------------------------------------------
                // 父进程关闭读端,保留写端
                close(pipe_fd[0]);
                // 原因:父进程只需要发送任务,不需要读取
                
                // 记录子进程信息
                pids_.push_back(pid);          // 保存子进程PID
                write_fds_.push_back(pipe_fd[1]); // 保存管道写端
                
                std::cout << "父进程:创建子进程 " << pid << std::endl;
                // ------------------------------------------
            }
        }
        // fork() 工作原理:
        // 1. 创建子进程,复制父进程的地址空间
        // 2. 子进程从fork()返回0,父进程返回子进程PID
        // 3. 父子进程并发执行,调度由操作系统决定
    }
};

// 测试代码
int main() {
    try {
        // 创建包含3个子进程的进程池
        ProcessPool pool(3);

        // 提交5个测试任务
        for (int i = 0; i < 5; ++i) {
            Task task{i, i * 10, i * 20};
            pool.submit_task(task);
            usleep(100000); // 模拟任务提交间隔(可选)
        }

    } catch (const std::exception& e) {
        std::cerr << "错误:" << e.what() << std::endl;
        return 1;
    }

    return 0;
}

表面上看起来:

实际上:

输入:

bash 复制代码
​​​​​​​ls /proc/进程PID/fd -l

即可查询此进程的fd表

相关推荐
努力努力再努力wz1 小时前
【Linux网络系列】:JSON+HTTP,用C++手搓一个web计算器服务器!
java·linux·运维·服务器·c语言·数据结构·c++
物理与数学9 小时前
linux 内存分布
linux·linux内核
东城绝神9 小时前
《Linux运维总结:基于ARM64+X86_64架构使用docker-compose一键离线部署MySQL8.0.43 NDB Cluster容器版集群》
linux·运维·mysql·架构·高可用·ndb cluster
creator_Li10 小时前
即时通讯项目--(1)环境搭建
linux·运维·ubuntu
Mr'liu11 小时前
MongoDB 7.0 副本集高可用部署
linux·mongodb
文静小土豆11 小时前
Rocky Linux 二进制 安装K8S-1.35.0高可用集群
linux·运维·kubernetes
暮云星影12 小时前
二、linux系统 应用开发:整体Pipeline流程
linux·arm开发
weixin_4307509313 小时前
OpenMediaVault debian Linux安装配置企业私有网盘(三) 静态ip地址配置
linux·服务器·debian·nas·网络存储系统
4032407313 小时前
[Jetson/Ubuntu 22.04] 解决挂载 exFAT 硬盘报错 “unknown filesystem type“ 及只读权限问题的终极指南
linux·运维·ubuntu
Source.Liu13 小时前
【沟通协作软件】使用 Rufus 制作 Ubuntu 启动盘的详细过程
linux·ubuntu