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表

相关推荐
天骄t2 小时前
HTML入门:从基础结构到表单实战
linux·数据库
大聪明-PLUS2 小时前
了解 Linux 系统中用于流量管理的 libnl 库
linux·嵌入式·arm·smarc
食咗未2 小时前
Linux USB HOST EXTERNAL VIRTUAL COM PORT
linux·驱动开发
没有啥的昵称2 小时前
linux下用QLibrary载入动态库
linux·qt
飞Link2 小时前
【CentOS】Linux(CentOS7)安装教程
linux·运维·服务器·centos
知识分享小能手2 小时前
Ubuntu入门学习教程,从入门到精通,Ubuntu 22.04中的过滤器知识点详解(13)
linux·学习·ubuntu
牛奔3 小时前
Linux 的日志分析命令
linux·运维·服务器·python·excel
飞Link3 小时前
【Linux】Linux(CentOS7)配置SSH免密登录
linux·运维·服务器
飞Link3 小时前
【Java】Linux(CentOS7)下安装JDK8(Java)教程
java·linux·运维·服务器