浅学进程间通信1(管道)

40 阅读11分钟

进程间通信 IPC (Inter Process Communication)

image.png

管道

image.png

image.png 管道(Pipe)是 Linux 内核提供的一种进程间通信(IPC)机制,它基于一个名为 pipefs 的内核伪文件系统实现。

pipefs 简单介绍

  • 内核启动早期就会 kern_mount(&pipe_fs_type),把 pipefs 挂在内部 VFS 命名空间里,用户态永远 ls 不到它,但所有匿名管道对象都“住”在这个文件系统里
  • 它只存在于内存,没有对应的磁盘块设备;super-block 也只有一个,全局共享
  • /proc/filesystems 中能看到一行 nodev pipefs,表示“无设备节点”的伪文件系统。

匿名管道在 pipefs 里的“一生”

① 进程调用 `pipe()/pipe2()`  
↓  
② 内核调用 `do_pipe_flags()`  
– 在 pipefs 中分配一个 inode(`struct inode *inode = pipefs_create_inode(...)`)[](https://my.oschina.net/emacs_8773548/blog/17230515)  
– 同一 inode 生成两个 `struct file`:一个只读、一个只写,对应返回给用户的 fd[0]、fd[1]  
↓  
③ 父子进程通过 `fork` 继承这两个 fd,就能以“写 fd→pipefs 缓冲区→读 fd”完成半双工字节流通信[](https://www.cnblogs.com/cps666/p/17339265.html)  
↓  
④ 所有 fd 关闭后,inode 引用计数归零,pipefs 自动回收这块内存缓冲区。

命名管道(FIFO)与 pipefs 的关系

(1) FIFO 也要复用 pipefs 的 inode 和缓冲区代码,但它会先在一个普通文件系统(ext4、xfs…)里创建一个“入口”文件,权限、路径对用户可见;打开时内核再把该文件绑定到 pipefs 新生成的 inode 上,因此无血缘关系的进程也能通过同一路径打开它进行通信

(2) SELinux 给这类文件打标签为 fifo_file,和匿名管道一样走 pipefs 的读写函数

虚拟文件系统中的pipe数据结构

image.png pipe_inode_info - 管道的核心结构

struct pipe_inode_info {
    struct mutex mutex;          // 互斥锁
    wait_queue_head_t rd_wait;   // 读等待队列
    wait_queue_head_t wr_wait;   // 写等待队列
    unsigned int head;           // 环形缓冲区头指针
    unsigned int tail;           // 环形缓冲区尾指针
    unsigned int max_usage;      // 最大使用量
    unsigned int ring_size;      // 环形缓冲区大小
    struct pipe_buffer *bufs;    // 缓冲区数组(粗放管道数据的 环形数组)
    // ...
};

pipe_buffer - 管道缓冲区

struct pipe_buffer {
    struct page *page;          // 存储数据的物理页
    unsigned int offset;        // 页内偏移
    unsigned int len;           // 数据长度
    const struct pipe_buf_operations *ops; // 操作函数
};

匿名管道

image.png 匿名管道(Anonymous Pipe)是 Unix/Linux 系统里最简单、最轻量的一种进程间通信(IPC)机制。它本质上是一块内核维护的内存缓冲区,由两个文件描述符(fd)分别充当“读端”和“写端”——父进程在调用 pipe() 系统调用时一次性拿到一对 fd:fd[0] 只读、fd[1] 只写。

因为管道没有名字,所以只能在亲缘进程(父子、兄弟)之间使用:父进程先 pipe(),再 fork(),把不需要的一端关掉,于是子进程和父进程就通过这一对 fd 单向传数据——数据从写端进去,从读端出来,字节流、先进先出、无结构边界。

Shell 里常见的 ps -ef | grep 关键字 | awk '{print $2}' 中的竖线“|”就是匿名管道的壳层语法:

  1. 每遇到一个“|”,Shell 都会提前 pipe() 创建一条匿名管道;
  2. 然后把左边命令的 stdout(fd 1)重定向到管道的写端,把右边命令的 stdin(fd 0)重定向到读端;
  3. 最后并发启动两条命令,数据在内核缓冲区里流动,对用户完全透明。

特点速记

  • 单向通信:数据只能从一个进程流向另一个进程,不能反向
  • 无名字:只能用于具有共同祖先的进程。
  • 字节流:读写端不保留消息边界,需要应用自己定义协议。
  • 缓冲区有限:Linux 默认 64 KB,写端阻塞或返回 EAGAIN。
  • 随进程存亡:所有指向它的 fd 关闭后,内核自动回收。
  • 只能父子传递:不能打开两次,也不能在毫无关系的进程间使用。

一句话:匿名管道就是“内核给你一根临时水管”,让父子进程可以按字节顺序、单向、无痛地传数据,用完即走,不占文件系统名字空间。

命名管道

image.png prw-r--r-- 1 root root hello 其中‘ p ’ 就是管道pipe的缩写, hello就是管道文件 image.png 命名管道(Named Pipe,FIFO)就是“有名字的管道”。它继承了匿名管道“字节流、先进先出”的语义,却突破了两个限制:

  1. 无需亲缘关系——任何进程只要知道路径就能打开;
  2. 生命周期随文件系统——不依赖进程是否健在。

应用开发使用的管道API

1.1 匿名管道创建

/**
 * pipe - 创建匿名管道
 * @pipefd: 包含两个整数的数组,用于接收管道文件描述符
 * 
 * 成功时:pipefd[0] = 读端文件描述符
 *        pipefd[1] = 写端文件描述符
 * 返回值:0成功,-1失败并设置errno
 * 
 * 注意:创建的管道是阻塞模式,缓冲区大小由操作系统决定
 */
#include <unistd.h>
int pipe(int pipefd[2]);

/**
 * pipe2 - 创建匿名管道并设置标志
 * @pipefd: 包含两个整数的数组
 * @flags: 管道标志位(O_NONBLOCK, O_CLOEXEC)
 * 
 * 相比pipe()的优势:
 * 1. 原子性:创建管道和设置标志是原子操作
 * 2. 支持close-on-exec标志
 * 
 * 返回值:0成功,-1失败
 */
#define _GNU_SOURCE
#include <fcntl.h>
#include <unistd.h>
int pipe2(int pipefd[2], int flags);

/* 可用标志位 flags:
 * O_NONBLOCK  - 非阻塞模式
 * O_CLOEXEC   - exec时自动关闭(Linux 2.6.23+)
 */

1.2 命名管道创建

/**
 * mkfifo - 创建命名管道(FIFO)
 * @pathname: FIFO文件路径
 * @mode: 文件权限模式(受umask影响)
 * 
 * 创建的FIFO可以用open()打开,支持多进程访问
 * 读端打开时会阻塞直到写端打开,除非指定O_NONBLOCK
 * 
 * 返回值:0成功,-1失败
 */
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);

/**
 * mkfifoat - 在指定目录创建FIFO
 * @dirfd: 目录文件描述符
 * @pathname: FIFO文件路径(相对或绝对)
 * @mode: 文件权限模式
 * 
 * 作用类似于mkfifo(),但可以指定目录
 * 如果pathname是绝对路径,则忽略dirfd
 * 
 * 返回值:0成功,-1失败
 */
#include <fcntl.h>
#include <sys/stat.h>
int mkfifoat(int dirfd, const char *pathname, mode_t mode);

/**
 * mknod - 创建特殊文件(包括FIFO)
 * @pathname: 文件路径
 * @mode: 文件类型和权限
 * @dev: 设备号(创建FIFO时设为0)
 * 
 * 可以用来创建FIFO:mode = S_IFIFO | permissions
 * 通常更推荐使用mkfifo(),语义更明确
 * 
 * 返回值:0成功,-1失败
 */
#include <sys/stat.h>
#include <sys/types.h>
int mknod(const char *pathname, mode_t mode, dev_t dev);

/**
 * mknodat - 在指定目录创建特殊文件
 * @dirfd: 目录文件描述符
 * @pathname: 文件路径
 * @mode: 文件类型和权限
 * @dev: 设备号
 * 
 * 返回值:0成功,-1失败
 */
int mknodat(int dirfd, const char *pathname, mode_t mode, dev_t dev);

2. 标准I/O封装API

/**
 * popen - 创建管道并启动进程
 * @command: 要执行的shell命令
 * @type: 打开模式:
 *        "r" - 父进程读取子进程的标准输出
 *        "w" - 父进程写入到子进程的标准输入
 * 
 * 功能:
 * 1. 创建管道
 * 2. fork()创建子进程
 * 3. 在子进程中执行shell命令
 * 4. 返回标准I/O FILE指针
 * 
 * 注意:popen()返回的FILE指针需要由pclose()关闭,而不是fclose()
 */
#include <stdio.h>
FILE *popen(const char *command, const char *type);

/**
 * pclose - 关闭popen()创建的管道流
 * @stream: popen()返回的FILE指针
 * 
 * 功能:
 * 1. 关闭标准I/O流
 * 2. 等待子进程结束
 * 3. 返回子进程的退出状态
 * 
 * 返回值:子进程退出状态,-1表示错误
 */
int pclose(FILE *stream);

3. 零拷贝高效传输API

/**
 * splice - 在内核空间移动数据,避免用户空间拷贝
 * @fd_in:     源文件描述符
 * @off_in:    源偏移指针(NULL表示当前位置)
 * @fd_out:    目标文件描述符
 * @off_out:   目标偏移指针(NULL表示当前位置)
 * @len:       要移动的字节数
 * @flags:     控制标志位
 * 
 * 功能:将数据从fd_in移动到fd_out,不经过用户空间
 * 支持:管道->管道、管道->文件、文件->管道
 * 
 * 返回值:成功移动的字节数,-1失败
 */
#define _GNU_SOURCE
#include <fcntl.h>
#include <unistd.h>
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

/* splice()标志位:
 * SPLICE_F_MOVE      - 尝试移动页而不是复制(当前未实现)
 * SPLICE_F_NONBLOCK  - 非阻塞操作
 * SPLICE_F_MORE      - 提示后续还有更多数据
 * SPLICE_F_GIFT      - 未使用
 */

/**
 * vmsplice - 将用户空间内存"赠送"给管道
 * @fd:      管道写端文件描述符
 * @iov:     iovec结构体数组
 * @nr_segs: iov数组元素个数
 * @flags:   控制标志位
 * 
 * 功能:将用户空间数据写入管道,可避免拷贝
 * 注意:数据可能被内核重用,写入后不应再访问
 * 
 * 返回值:成功写入的字节数,-1失败
 */
ssize_t vmsplice(int fd, const struct iovec *iov,  unsigned long nr_segs, unsigned int flags);

/* vmsplice()标志位 flags:
 * SPLICE_F_GIFT      - 用户放弃缓冲区所有权(必须为匿名内存)
 * SPLICE_F_NONBLOCK  - 非阻塞操作
 */

/**
 * tee - 在两个管道间复制数据,不消耗源数据
 * @fd_in:  源管道读端
 * @fd_out: 目标管道写端
 * @len:    要复制的字节数
 * @flags:  控制标志位
 * 
 * 功能:类似splice(),但不从源管道移除数据
 * 只能用于管道到管道的复制
 * 
 * 返回值:成功复制的字节数,-1失败
 */
ssize_t tee(int fd_in, int fd_out,  size_t len, unsigned int flags);

/* tee()标志位 flags:
 * SPLICE_F_NONBLOCK  - 非阻塞操作
 * SPLICE_F_MOVE      - 未实现
 * SPLICE_F_GIFT      - 未使用
 */

4. 控制与查询API

/**
 * fcntl - 控制文件描述符属性
 * @fd:  文件描述符
 * @cmd: 命令
 * @...: 可变参数(取决于cmd)
 * 
 * 用于管道的常见命令:
 * F_GETPIPE_SZ - 获取管道容量
 * F_SETPIPE_SZ - 设置管道容量(Linux 2.6.35+)
 * F_GETFL      - 获取文件状态标志
 * F_SETFL      - 设置文件状态标志(如O_NONBLOCK)
 */
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );

// 使用示例:
int get_pipe_size(int fd) {
    return fcntl(fd, F_GETPIPE_SZ);  // 返回管道缓冲区大小
}

int set_pipe_size(int fd, int size) {
    return fcntl(fd, F_SETPIPE_SZ, size);  // 设置新大小
}

/**
 * fpathconf - 获取管道相关配置值
 * @fd:  文件描述符
 * @name: 配置名称
 * 
 * 常用name值:
 * _PC_PIPE_BUF - 原子写入的最大字节数(PIPE_BUF)
 * _PC_NAME_MAX - 路径名最大长度
 * 
 * 返回值:配置值,-1失败
 */
#include <unistd.h>
long fpathconf(int fd, int name);

// 使用示例:
long pipe_buf = fpathconf(pipefd[0], _PC_PIPE_BUF);

5. 高级控制API

/**
 * pipe_buf - 原子写入保证常量
 * 
 * POSIX保证写入小于PIPE_BUF字节的数据是原子的
 * 多个进程/线程同时写入不会交错
 * 典型值:512, 4096, 或由_PC_PIPE_BUF查询
 */
#define PIPE_BUF 4096  /* 常见值,实际值应使用fpathconf获取 */

/**
 * ioctl - 设备I/O控制(管道特定操作)
 * @fd:     文件描述符
 * @request: 请求码
 * @...:    可变参数
 * 
 * 管道相关请求(Linux特有):
 * FIONREAD - 获取可读字节数
 * 
 * 注意:ioctl不是POSIX标准,可移植性差
 */
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);

// 使用示例:
int bytes_available;
ioctl(pipefd[0], FIONREAD, &bytes_available);

拓展: 我开发中遇到的疑惑

由于我开发过程中总把pipe管道与epoll搞混,pipe管道最新api不是epoll吗?

epoll 并不是“管道的新 API”,而是 Linux 的 I/O 多路复用机制;它跟 pipe/pipe2 属于两个层面的东西,只是经常一起使用。
一句话区分:

  • pipe/pipe2 —— 负责“创建”管道,返回一对 fd;
  • epoll —— 负责“监听”任何 fd(包括管道、socket、eventfd…)上的读写事件。

为什么容易混淆

  1. 在 Android Looper、libuv、Go runtime 等事件循环里,最常见的配方就是:
    pipe2() 创建通知管道 → epoll_ctl() 把读端 fd 加进 epoll 监听表 → 其他线程/进程往写端丢一个字节,epoll 立刻返回,实现“自唤醒”。
  2. 因为这套组合太经典,很多人就把“管道+epoll”当成“管道的新 API”,其实 epoll 本身对管道没有任何特殊路径,只是把它当成普通的 pollable file。

最新的“管道 API”依旧只有两级

系统调用出现版本作用备注
pipe(int fd[2])1973 UNIX V3创建匿名管道POSIX 标准
pipe2(int fd[2], int flags)Linux 2.6.27同上,但可一次性指定 O_NONBLOCKO_CLOEXEC 等标志Linux 专属

2008 年以后,内核侧只对管道实现做过 容量可调F_SETPIPE_SZ)、环形页缓存 等性能优化,没有再新增系统调用;用户态 API 保持稳定。


结论

  • 如果你问“创建/读写管道”的最新接口,答案仍然是 pipe2()
  • 如果你问“怎样让管道不阻塞又及时响应”,答案是把它扔进 epoll(或 select/poll)里监听——但那是 I/O 多路复用层的事,不是管道层的新 API。