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. 通信完成后,关闭剩余的文件描述符。

示例代码(父写子读)

#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;
    }
}

 优缺点 & 适用场景

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

底层原理

内核数据结构(核心)

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

// 内核中管道对象的简化模型(实际定义在 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()复制父进程的文件描述符表

// 代码中 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 的影响。

    实例代码:进程池

    #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;
    }

    表面上看起来:

    实际上:

    输入:

    ​​​​​​​ls /proc/进程PID/fd -l

    即可查询此进程的fd表

    评论
    成就一亿技术人!
    拼手气红包6.0元
    还能输入1000个字符
     
    红包 添加红包
    表情包 插入表情
     条评论被折叠 查看
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值