引言
在操作系统中,不同进程之间往往需要交换数据、同步行为,这就涉及到进程间通信(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 else
38 {
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 // child
63 // 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 else
39 {
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 # 在另一个终端运行