进程间通信详解(一):管道机制与实现原理
文章目录
- 引言
- 一、进程间通信介绍
- 1.1 进程间通信目的
- 1.2 进程间通信发展
- 1.3 进程间通信分类
- 二、管道
- 三、匿名管道
- 3.1 实例代码
- 3.2 用 fork 来共享管道
- 3.3 管道通信的内核实现原理
- 3.3.1 核心组件与关联关系
- 3.3.2 通信流程拆解
- 3.3.3 设计本质:OS 如何实现 “共享资源”?
- 3.3.4 对比理解:为何管道是 “OS 介导的共享”?
- 3.4 管道读写规则
- 3.5 管道特点
- 3.6 匿名管道适用场景
- 3.7 管道通信的四种情况
- 3.7.1 管道有数据 → 读端读取
- 3.7.2 管道满 → 写端写入
- 3.7.3 写端关闭 → 读端读取
- 3.7.4 读端关闭 → 写端写入
- 四、命名管道
- 4.1 创建命名管道
- 4.2 结合 open 使用:控制阻塞行为
- 4.3 示例1:父子进程通过命名管道通信
- 4.4 示例2:利用命名管道简单模拟用户端与服务端的通信
- 4.4.1 server.c
- 4.4.2 client.c
引言
在操作系统中,不同进程之间往往需要交换数据、同步行为,这就涉及到进程间通信(Inter-Process Communication,IPC)。本篇博客将聚焦于 IPC 中最基础也是最常用的一种机制——管道(Pipe)通信。我们不仅会讲解匿名管道与命名管道的使用方法,还会深入内核实现原理、分析管道的读写规则,并通过多个典型示例理解它在实际开发中的应用场景。无论你是在学习操作系统课程,还是在编写多进程程序,这篇文章都将为你打下扎实的基础。
一、进程间通信介绍
1.1 进程间通信目的
- 数据传输:一个进程需要将它的数据发送给另一个进程
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
怎么通信?
进程间通信的本质:是让不同的进程看到同一份资源(如“内存”),从而具备通信的条件。
资源由任何一个进程提供?? 不是!!
是由OS提供 -> 系统调用 -> OS的接口 -> 设计统一的通信接口
1.2 进程间通信发展
- 管道
- System V进程间通信
- POSIX 进程间通信
1.3 进程间通信分类
- 管道:
- 匿名管道pipe
- 命名管道
- System V IPC
- System V 消息队列
- System V 共享队列
- System V 信号量
- POSIX IPC
- 消息队列
- 共享内存
- 信号量
- 互斥量
- 条件变量
- 读写锁
二、管道
什么是管道
- 管道是 Unix 中最古老的进程间通信的形式
- 我们把从一个进程连接到另一个进程的一个数据流称为一个 “管道”
三、匿名管道
3.1 实例代码
#include <unistd.h>
int pipe(int fd[2]);
功能:
创建一匿名管道
参数:
fd
:文件描述符数组,其中 fd[0]
表示读端,fd[1]
表示写端
返回值:
成功返回 0,失败返回错误代码
1 #include <stdio.h>2 #include <stdlib.h>3 #include <string.h>4 #include <unistd.h>5 6 int main()7 {8 int fds[2];9 char buf[128];10 int len;11 12 if(pipe(fds) == -1)13 {14 perror("pipe");15 exit(EXIT_FAILURE);16 }17 18 while(fgets(buf, 100, stdin)) // 从键盘中读取数据19 {20 len = strlen(buf);21 if(write(fds[1], buf, len) != len) // 写入管道22 {23 perror("write");24 exit(EXIT_FAILURE);25 }26 27 memset(buf, 0x00, sizeof(buf)); 28 if((len = read(fds[0], buf, 100)) == -1) // 读取管道29 {30 perror("read");31 exit(EXIT_FAILURE);32 }33 34 if(write(1, buf, len) != len) // 输出到屏幕35 {36 perror("write");37 exit(EXIT_FAILURE);38 }39 }40 41 return 0;42 }
演示:
可以看到,从键盘中输入什么,在屏幕中会再输出一遍
3.2 用 fork 来共享管道
我之前的文章里讲过,父进程在创建子进程的时候,子进程会继承父进程的文件描述符表,这样,子进程就能获取到父进程的资源。我们可以利用这个原理来实现从子进程向父进程的单向通信。
父进程先创建管道(获得读、写端 fd[0]/fd[1]),再通过 fork 创建子进程。子进程会 复制父进程的文件描述符表(属于 PCB 的一部分),因此继承管道的读、写端。此时,父进程关闭写端 fd[1],子进程关闭读端 fd[0],就能严格实现 父进程写 → 子进程读 的单向通信。这既利用了 fork 的资源继承特性,也依赖管道的 半双工 传输规则。
这一部分代码使用C++来写:
1 #include <iostream>2 #include <unistd.h>3 #include <cstring>4 #include <sys/types.h>5 #include <sys/wait.h>6 7 void ChildWrite(int wfd)8 {9 int cnt = 0;10 char buffer[128];11 while(cnt <= 10000)12 {13 printf("child : %d\n", cnt); 14 int len = snprintf(buffer, sizeof(buffer), "cnt: %d\n", cnt++);15 write(wfd, buffer, len);16 usleep(100);17 }18 }19 20 void FatherRead(int rfd)21 {22 char buffer[128];23 while(true)24 {25 ssize_t n = read(rfd, buffer, sizeof(buffer)-1);26 if(n > 0)27 {28 buffer[n] = 0;29 std::cout << "child say: " << buffer << std::endl;30 }31 else if(n == 0)32 {33 std::cout << "n : " << n << std::endl;34 std::cout << "child 退出,我也退出" << std::endl;35 break;36 }37 else38 {39 break;40 }41 }42 }43 44 45 int main()46 {47 // 1. 创建管道48 int fds[2] = {0};49 int n = pipe(fds);50 if(n < 0)51 {52 std::cerr << "pipe error" << std::endl;53 return 1;54 }55 std::cout << "fds[0]: " << fds[0] << std::endl;56 std::cout << "fds[1]: " << fds[1] << std::endl;57 58 // 2. 创建子进程59 pid_t pid = fork();60 if(pid == 0)61 {62 // child63 // 3. 子进程关闭读端64 close(fds[0]);65 ChildWrite(fds[1]);66 close(fds[1]);67 exit(0);68 }69 // 3. 父进程关闭写端70 close(fds[1]);71 FatherRead(fds[0]);72 close(fds[0]);73 74 sleep(1);75 76 int status = 0;77 int ret = waitpid(pid, &status, 0); // 获取子进程退出信息78 if(ret > 0)79 {80 printf("exit code: %d, exit signal: %d\n", (status>>8)&0xFF, status&0x7F);81 sleep(5);82 }83 84 return 0;85 }
用图片来解释文件描述符继承问题:
3.3 管道通信的内核实现原理
3.3.1 核心组件与关联关系
- 进程的
file
结构体- 每个进程打开文件(包括管道)时,内核会为其创建
file
结构体,记录文件的操作属性(如f_mode
读写权限、f_pos
读写偏移、f_flags
标志位等)。 - 图中 “进程 1” 和 “进程 2” 各自有独立的
file
结构体,但它们的f_inode
字段指向同一个inode
(关键!实现共享的核心)。
- 每个进程打开文件(包括管道)时,内核会为其创建
inode
(索引节点)
inode 是内核中描述文件元数据的结构(如文件类型、权限、数据存储位置)。
对于管道,inode 不对应磁盘文件,而是对应 内核中的一块共享内存区域(数据页),作为管道的 “缓冲区”。- 数据页(管道缓冲区)
- 是物理内存中的一块区域,用于存储管道传输的数据。
- 进程 1 的
write
操作向这里写入数据,进程 2 的read
操作从这里读取数据。
3.3.2 通信流程拆解
- 进程 1:写操作(
write
)- 进程 1 调用
write
系统调用,传入自己的文件描述符(关联到自身的file
结构体)。 - 内核通过 file 结构体找到
f_inode
,定位到共享的inode
。 - 内核将数据写入
inode
关联的数据页(管道缓冲区)。
- 进程 1 调用
- 进程 2:读操作(
read
)- 进程 2 调用
read
系统调用,传入自己的文件描述符(关联到自身的 file 结构体)。 - 内核通过
file
结构体找到f_inode
,同样定位到同一个inode
(共享的管道)。 - 内核从
inode
关联的数据页对应的物理内存中读取数据,返回给进程 2。
- 进程 2 调用
3.3.3 设计本质:OS 如何实现 “共享资源”?
- 资源由 OS 提供:
管道的inode
和数据页由内核创建和管理,而非进程自行分配。进程只能通过系统调用(pipe()
创建管道,write()/read()
操作)访问,确保安全。 - “看到同一份资源” 的实现:
两个进程的 file 结构体通过f_inode
指向同一个内核inode
,间接共享同一块数据页。这种设计让进程无需感知物理内存,只需通过文件接口操作,屏蔽了底层复杂度。 - 管道的特性映射:
- 半双工通信:图中虽画了读写,但实际管道是单向的(一个进程写,另一个读),若需双向通信,需创建两个管道。
- 同步与互斥:内核会自动处理数据的读写同步(如写满时阻塞写进程,读空时阻塞读进程),保证通信有序。
3.3.4 对比理解:为何管道是 “OS 介导的共享”?
- 如果让 “进程自建资源”(比如进程 1 分配一块内存,告诉进程 2 地址),会有 安全问题(进程 2 的虚拟地址可能无效,或权限不足)。
- 而 OS 统一管理管道的
inode
和数据页,通过file
结构体为进程提供 “合法访问接口”,既实现了共享,又保证了系统稳定性(如内存回收、权限检查)。
总结:
管道通过内核的 inode
和共享数据页,让两个进程的 file
结构体‘间接共享同一份资源’,从而实现通信。这就好比两个用户(进程)在银行(操作系统)注册了各自的账号(file
),但他们都指向同一个保险箱(inode
+ 数据页),从而实现了受控共享。
3.4 管道读写规则
- 当没有数据可读时
- O_NONBLOCK disable:
read
调用阻塞,即进程暂停执行,一直等到有数据来到为止。 - O_NONBLOCK enable:
read
调用返回 - 1,errno
值为EAGAIN
。
- O_NONBLOCK disable:
- 当管道满的时候
- O_NONBLOCK disable:
write
调用阻塞,直到有进程读走数据 - O_NONBLOCK enable:调用返回 - 1,
errno
值为EAGAIN
- O_NONBLOCK disable:
- 如果所有管道写端对应的文件描述符被关闭,则
read
返回 0 - 如果所有管道读端对应的文件描述符被关闭,则
write
操作会产生信号SIGPIPE
,进而可能导致write
进程退出 - 当要写入的数据量不大于
PIPE_BUF
时,linux 将保证写入的原子性。 - 当要写入的数据量大于
PIPE_BUF
时,linux 将不再保证写入的原子性。
3.5 管道特点
- 只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进程创建,然后该进程调用
fork
,此后父、子进程之间就可应用该管道。 - 管道提供流式服务。
- 一般而言,进程退出,管道释放,所以管道的生命周期随进程。
- 一般而言,内核会对管道操作进行同步与互斥。
- 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
3.6 匿名管道适用场景
匿名管道适用于父子进程或兄弟进程之间进行短生命周期、快速数据交换的通信需求。
例如:
- Linux Shell 中的
ls | grep xxx
- 父进程向子进程传递初始化参数
注意:匿名管道无法在两个完全无关的进程之间通信,这时应使用命名管道或其他 IPC 机制。
3.7 管道通信的四种情况
Linux 管道默认 64KB。
3.7.1 管道有数据 → 读端读取
- 阻塞模式(默认,
O_NONBLOCK
禁用):- 若管道有数据,
read
正常读取,返回读取的字节数。 - 若管道暂时无数据,读进程会阻塞(暂停执行),直到写端写入新数据。
- 若管道有数据,
- 非阻塞模式(
O_NONBLOCK
启用):- 若管道无数据,
read
立即返回-1
,并设置errno = EAGAIN
(表示 “资源暂时不可用”)。
- 若管道无数据,
3.7.2 管道满 → 写端写入
管道缓冲区有固定容量(如 Linux 中 PIPE_BUF
通常为 4096 字节),当缓冲区被写满时:
- 阻塞模式(
O_NONBLOCK
禁用):- 写进程调用
write
时,会阻塞,直到读端取走数据、腾出缓冲区空间。
- 写进程调用
- 非阻塞模式(
O_NONBLOCK
启用):write
立即返回-1
,并设置errno = EAGAIN
。
3.7.3 写端关闭 → 读端读取
当 所有写端的文件描述符都被关闭(如父子进程均关闭写端):
- 若管道中还有剩余数据,读端会正常读取数据。
- 当管道数据被读完后,后续
read
会返回0
(表示 “文件结束”,类似读到普通文件末尾)。
3.7.4 读端关闭 → 写端写入
当 所有读端的文件描述符都被关闭(如父子进程均关闭读端):
- 写端调用
write
时,操作系统会向写进程发送SIGPIPE
信号(默认行为是终止进程),导致写进程崩溃。 - 若需避免崩溃,需捕获
SIGPIPE
信号(如通过signal
注册信号处理函数)。
四、命名管道
- 管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
- 如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。
- 命名管道是一种特殊类型的文件
4.1 创建命名管道
- 命名管道可以从命令行上创建,命令行方法是使用下面这个命令:
mkfifo filename
- 命名管道也可以从程序里创建,相关函数有:
#include <sys/types.h>
#include <sys/stat.h> int mkfifo(const char *pathname, mode_t mode);
- 参数:
pathname
:管道文件路径(如"/tmp/my_fifo"
)。mode
:权限位(如0666
),实际权限为mode & ~umask
(受进程umask
影响)。
- 返回值:
- 成功:
0
;失败:-1
,错误码存于errno
。
- 成功:
错误码解析:
错误码 | 含义 |
---|---|
EEXIST | 管道文件已存在 |
EACCESS | 路径所在目录无写权限 |
ENOSPC | 文件系统空间不足 |
ENOTDIR | 路径中某组件不是目录 |
创建命名管道:
int main(int argc, char *argv[])
{mkfifo("p2", 0644);return 0;
}
4.2 结合 open 使用:控制阻塞行为
命名管道的读写行为受 open
的 O_NONBLOCK
标志 影响:
- 默认(无
O_NONBLOCK
):- 打开读端(
O_RDONLY
):阻塞,直到有进程打开写端。 - 打开写端(
O_WRONLY
):阻塞,直到有进程打开读端。
- 打开读端(
- 开启
O_NONBLOCK
:- 打开读端:若无写端,立即返回
-1
,errno = ENXIO
。 - 打开写端:若无读端,立即返回
-1
,errno = ENXIO
。
- 打开读端:若无写端,立即返回
4.3 示例1:父子进程通过命名管道通信
1 #include <stdio.h>2 #include <unistd.h>3 #include <sys/types.h>4 #include <sys/stat.h>5 #include <fcntl.h>6 #include <string.h>7 #include <stdlib.h>8 #include <sys/wait.h>9 10 int main()11 {12 const char *fifo_path = "/home/zkp/linux/25/6/9/fifo/my_fifo";umask(0); // 设置权限掩码为 0,,以免影响后面的操作13 // 1. 创建命名管道14 if(mkfifo(fifo_path, 0666) == -1)15 {16 perror("mkfifo");17 return 1;18 }19 20 // 2. 创建子进程21 pid_t pid = fork();22 if(pid < 0)23 {24 perror("fork");25 return 1;26 }27 else if(pid == 0)28 {29 // 子进程:读端30 int fd = open(fifo_path, O_RDONLY);31 char buf[100];32 ssize_t n = read(fd, buf, sizeof(buf)); 33 buf[n] = '\0';34 printf("子进程读到:%s\n", buf);35 close(fd);36 exit(0);37 }38 else39 {40 // 父进程:写端41 int fd = open(fifo_path, O_WRONLY);42 const char *msg = "Hello, named pipe!";43 write(fd, msg, strlen(msg));44 close(fd);45 wait(NULL); // 等待子进程退出46 }47 48 // 3. 删除管道文件(也可以不删除,长期保留)49 unlink(fifo_path);50 51 return 0;52 }
运行结果:
4.4 示例2:利用命名管道简单模拟用户端与服务端的通信
我这里就用C语言简单模拟一下,真要写的话可以将 FileOper
(用于支持读写操作)、NameFifo
(用于创建管道文件)写到头文件中,再直接在 server.c
、client.c
中直接调用接口。
4.4.1 server.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>int main()
{int fd = open("myfifo", O_RDONLY);if (fd == -1){perror("open");exit(1);}char buf[128];while (read(fd, buf, sizeof(buf)) > 0) {printf("Received: %s", buf);}close(fd);return 0;
}
4.4.2 client.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>int main() {int fd = open("myfifo", O_WRONLY);if (fd == -1) {perror("open");exit(1);}char buf[128];while (fgets(buf, sizeof(buf), stdin)) {write(fd, buf, strlen(buf));}close(fd);return 0;
}
命令行演示:
mkfifo myfifo
./fifo_read # 在一个终端运行
./fifo_write # 在另一个终端运行