进程间通信(IPC)与匿名管道
目录
一、进程间通信(IPC)概述
1. 核心概念
2. 核心目的
3. IPC分类
二、匿名管道
1. 什么是管道
示例:Shell中的管道
2. 匿名管道的原理
3. 匿名管道的实现
3.1 创建管道:pipe()函数
3.2 使用 fork 共享管道
3.3 站在文件描述符角度理解管道
3.4 示例代码
4. 匿名管道的核心特性与规则
4.1 读写规则
4.2 四种特殊情况
🌵情况 1:写端不写,读端一直读
🌵情况 2:读端不读,写端一直写
🌵情况 3:写端关闭后
🌵情况 4:读端关闭后
4.3 五种特征
🌴特征1:血缘关系限制
🌴特征2:流式服务
🌴特征3:生命周期随进程
🌴特征4:内核同步与互斥
🌴特征5:半双工通信
一、进程间通信(IPC)概述
1. 核心概念
进程间通信(Interprocess Communication, IPC)是操作系统提供的一种机制,用于不同进程之间的数据交换与协作。由于每个进程拥有独立的虚拟地址空间,直接访问对方内存不可行,因此需要通过操作系统提供的共享资源实现通信。其本质是让不同进程看到同一份资源(如内存缓冲区、文件或内核数据结构)。
2. 核心目的
-
数据传输:一个进程需要将它的数据发送给另一个进程,如客户端与服务器间的请求响应。
-
资源共享:多个进程共享同一文件或内存区域。
-
事件通知:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如子进程终止时要通知父进程)。
-
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
3. IPC分类
类型 | 具体实现 |
---|---|
管道 | 匿名管道、命名管道(FIFO) |
System V IPC | System V 消息队列、System V 共享内存、System V 信号量 |
POSIX IPC | 消息队列、共享内存、信号量、互斥量、条件变量、读写锁 |
其他机制 | 信号(Signal)、套接字(Socket)、内存映射文件(Memory-Mapped Files) |
二、匿名管道
1. 什么是管道
管道是 UNIX 中最古老的进程间通信方式。它是一种半双工的通信机制,本质是内核维护的环形缓冲区,数据只能单向流动。管道通过将一个进程的标准输出连接到另一个进程的标准输入,实现数据的传递。
示例:Shell中的管道
who | wc -l # 统计登录用户数
who
命令的输出通过管道传递给wc
处理,实现进程间协作。
2. 匿名管道的原理
匿名管道用于本地父子进程之间的通信。其原理是让父子进程共享同一文件资源,通过操作系统维护的文件描述符实现通信。匿名管道的数据存储在内存中,不会写入磁盘。
父进程和子进程的
task_struct
:
每个进程都有一个
task_struct
,用于描述进程的状态和资源。父进程和子进程各自有自己的
task_struct
,但它们可以通过共享的文件描述符表进行通信。文件描述符表
files_struct
和fd_array
:
files_struct
是与进程相关的文件描述符表,管理进程打开的文件或管道。
fd_array
是一个数组,存储了文件描述符(file descriptor),每个文件描述符对应一个打开的文件或管道。父进程和子进程的
fd_array
都指向了同一个struct file
,这表明它们共享了同一份文件资源(例如管道)。文件结构
struct file
和inode
:
struct file
是 Linux 内核中表示打开文件的结构体。
inode
是文件系统中用于存储文件元数据的数据结构,描述了文件在文件系统中的位置和属性。内核级文件缓冲区:
内核中有一个文件缓冲区,用于缓存文件数据,提高文件读写效率。
当进程通过文件描述符读写文件时,数据会先从磁盘加载到文件缓冲区,再由进程从缓冲区中读取或写入。
磁盘文件
file.txt
:
磁盘上的文件
file.txt
是实际存储数据的地方。当进程通过文件描述符读写文件时,数据会先从磁盘加载到文件缓冲区,再由进程从缓冲区中读取或写入。
父子进程共享资源:
父进程和子进程通过共享的文件描述符和文件系统结构(如
struct file
和inode
)访问同一份数据。这种机制是管道通信的基础:父进程和子进程通过共享的管道文件描述符进行数据交换。
内核管理:
内核负责管理管道的缓冲区和同步机制,确保数据的正确传递。
数据在内核缓冲区中传输,不需要刷新到磁盘,因此效率较高。
3. 匿名管道的实现
3.1 创建管道:pipe()
函数
pipe
函数是用于创建匿名管道的系统调用,其函数原型如下:
#include <unistd.h>
int pipe(int pipefd[2]); // pipefd[0]为读端,pipefd[1]为写端
🌻参数说明:
pipefd
:这是一个指向整数数组的指针,数组必须能够容纳至少两个元素。pipe
函数会填充这个数组,使其包含两个文件描述符:
pipefd[0]
:管道的读端文件描述符(用于从管道中读取数据)。
pipefd[1]
:管道的写端文件描述符(用于向管道中写入数据)。
🌻返回值:
成功:返回 0,并且
pipefd
数组被填充为两个有效的文件描述符。失败:返回 -1,表示创建管道失败。此时,
pipefd
数组的内容是未定义的。可以通过errno
获取具体的错误原因。
🌻注意事项:
文件描述符的使用:
pipefd[0]
通常用于读取数据,应该在读端进程中使用。
pipefd[1]
通常用于写入数据,应该在写端进程中使用。关闭不需要的文件描述符:
在父子进程通信时,父进程和子进程需要分别关闭不需要的文件描述符。例如,父进程关闭写端文件描述符(
pipefd[1]
),子进程关闭读端文件描述符(pipefd[0]
)。文件描述符的继承:
当父进程调用
fork()
创建子进程时,子进程会继承父进程的文件描述符表,包括管道的两个文件描述符。
3.2 使用 fork
共享管道
1. 父进程调用pipe()
创建管道。
2. 调用fork()
创建子进程。
3. 父子进程分别关闭未使用的端(父关写端,子关读端)。
3.3 站在文件描述符角度理解管道
1. 父进程创建管道
2. 父进程 fork 出子进程
3. 父进程关闭 fd[0],子进程关闭 fd[1]
3.4 示例代码
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int pipefd[2]; // 用于存储管道的读端和写端文件描述符
pid_t pid;
// 创建匿名管道
if (pipe(pipefd) == -1)
{
perror("pipe");
exit(1);
}
// 创建子进程
pid = fork();
if (pid == -1)
{
perror("fork");
exit(1);
}
if (pid == 0)
{
// 子进程
close(pipefd[0]); // 关闭读端
const char *msg = "Hello from child!";
int count = 5;
while (count--)
{
write(pipefd[1], msg, strlen(msg) + 1); // 向管道写入数据
}
close(pipefd[1]); // 关闭写端
exit(0);
}
else
{
// 父进程
close(pipefd[1]); // 关闭写端
char buffer[128];
ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1); // 从管道读取数据
for (int i = 0; i < 5; i++)
{
if (n > 0)
{
buffer[n] = '\0'; // 确保字符串以 '\0' 结尾
printf("Received from child: %s\n", buffer);
}
else if (n == 0)
{
printf("No data received.\n");
}
else
{
perror("read");
}
}
close(pipefd[0]); // 关闭读端
wait(NULL); // 等待子进程结束
}
return 0;
}
代码解释:
🌵创建匿名管道:
if (pipe(pipefd) == -1) {
perror("pipe");
exit(1);
}
-
pipe(pipefd)
创建一个匿名管道,pipefd[0]
是读端文件描述符,pipefd[1]
是写端文件描述符。
🌵创建子进程:
pid = fork();
if (pid == -1) {
perror("fork");
exit(1);
}
-
fork()
创建一个子进程。pid
为 0 表示子进程,pid
为正数表示父进程。
🌵子进程写入数据:
if (pid == 0) {
close(pipefd[0]); // 关闭读端
const char *msg = "Hello from child!";
write(pipefd[1], msg, strlen(msg) + 1); // 向管道写入数据
close(pipefd[1]); // 关闭写端
exit(0);
}
-
子进程关闭读端文件描述符
pipefd[0]
,只保留写端文件描述符pipefd[1]
。 -
子进程通过
write()
函数将数据写入管道。 -
写入完成后,子进程关闭写端文件描述符并退出。
🌵父进程读取数据:
else {
close(pipefd[1]); // 关闭写端
char buffer[128];
read(pipefd[0], buffer, sizeof(buffer)); // 从管道读取数据
printf("Received from child: %s\n", buffer);
close(pipefd[0]); // 关闭读端
wait(NULL); // 等待子进程结束
}
-
父进程关闭写端文件描述符
pipefd[1]
,只保留读端文件描述符pipefd[0]
。 -
父进程通过
read()
函数从管道中读取数据。 -
读取完成后,父进程打印从子进程接收到的数据,并关闭读端文件描述符。
-
父进程调用
wait(NULL)
等待子进程结束,确保子进程的资源被正确回收。
运行结果:
4. 匿名管道的核心特性与规则
4.1 读写规则
1. 当没有数据可读时
O_NONBLOCK
禁用(默认阻塞模式):
如果管道中没有数据,
read()
调用会阻塞,即进程会暂停执行,直到有数据可读为止。
O_NONBLOCK
启用(非阻塞模式):
如果管道中没有数据,
read()
调用会立即返回-1
,并设置errno
为EAGAIN
,表示没有数据可读。
2. 当管道满的时候
O_NONBLOCK
禁用(默认阻塞模式):
如果管道已满,
write()
调用会阻塞,即进程会暂停执行,直到有空间可写为止。
O_NONBLOCK
启用(非阻塞模式):
如果管道已满,
write()
调用会立即返回-1
,并设置errno
为EAGAIN
,表示没有空间可写。
3. 如果所有管道写端对应的文件描述符被关闭
- 当所有写端文件描述符都被关闭时,
read()
调用会返回0
,表示管道已经关闭,没有更多数据可读。
4. 如果所有管道读端对应的文件描述符被关闭
- 当所有读端文件描述符都被关闭时,
write()
操作会产生SIGPIPE
信号,这通常会导致写进程退出。
5. 写入数据的原子性
当写入的数据量不大于
PIPE_BUF
时:
Linux 会保证写入操作的原子性,即写入的数据要么全部写入,要么都不写入,不会被其他进程的读写操作打断。
当写入的数据量大于
PIPE_BUF
时:
Linux 不再保证写入操作的原子性,数据可能会被拆分成多个部分写入,可能会被其他进程的读写操作打断。
🌴总结:
阻塞模式:默认情况下,
read()
和write()
会阻塞,直到有数据可读或有空间可写。非阻塞模式:启用
O_NONBLOCK
后,read()
和write()
会立即返回,不会阻塞。文件描述符关闭:关闭写端文件描述符后,读端会返回
0
;关闭读端文件描述符后,写端会产生SIGPIPE
信号。原子性:写入数据量不大于
PIPE_BUF
时,写入操作是原子的;大于PIPE_BUF
时,写入操作可能不原子。
4.2 四种特殊情况
写端不写,读端一直读:读端进程挂起,直到有数据可读。
读端不读,写端一直写:写端进程挂起,直到有空间可写。
写端关闭后:读端读取完数据后继续执行,不会挂起。
读端关闭后:写端进程收到
SIGPIPE
信号,被操作系统终止。
🌵情况 1:写端不写,读端一直读
描述:
-
如果写端不写数据,而读端一直尝试读取数据,读端进程会挂起,直到有数据可读。
代码验证:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int pipefd[2];
pid_t pid;
if (pipe(pipefd) == -1)
{
perror("pipe");
exit(1);
}
pid = fork();
if (pid == -1)
{
perror("fork");
exit(1);
}
if (pid == 0)
{
// 子进程:写端不写数据
close(pipefd[0]); // 关闭读端
close(pipefd[1]); // 关闭写端(不写数据)
exit(0);
}
else
{
// 父进程:读端一直读
close(pipefd[1]); // 关闭写端
char buffer[128];
printf("父进程尝试读取数据...\n");
ssize_t n = read(pipefd[0], buffer, sizeof(buffer)); // 读取数据
if (n > 0)
{
buffer[n] = '\0';
printf("Received: %s\n", buffer);
}
else if (n == 0)
{
printf("没有数据可读,子进程关闭了写端。\n");
}
else
{
perror("read");
}
close(pipefd[0]); // 关闭读端
wait(NULL); // 等待子进程结束
}
return 0;
}
运行结果:
解释:
- 子进程关闭了写端,没有写入数据。
- 父进程尝试读取数据时,由于没有数据可读,
read()
会阻塞,直到子进程关闭写端,父进程的read()
返回 0。
🌵情况 2:读端不读,写端一直写
描述:
-
如果读端不读数据,而写端一直尝试写入数据,写端进程会挂起,直到有空间可写。
代码验证:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int pipefd[2];
pid_t pid;
if (pipe(pipefd) == -1)
{
perror("pipe");
exit(1);
}
pid = fork();
if (pid == -1)
{
perror("fork");
exit(1);
}
if (pid == 0)
{
// 子进程:写端一直写
close(pipefd[0]); // 关闭读端
for (int i = 0; i < 5; i++)
{
char buffer[128];
sprintf(buffer, "数据 %d\n", i + 1);
write(pipefd[1], buffer, strlen(buffer) + 1); // 写入数据
sleep(1); // 模拟写入间隔
}
close(pipefd[1]); // 关闭写端
exit(0);
}
else
{
// 父进程:读端不读
close(pipefd[1]); // 关闭写端
printf("父进程不读取数据...\n");
sleep(6); // 模拟父进程不读取数据
close(pipefd[0]); // 关闭读端
wait(NULL); // 等待子进程结束
}
return 0;
}
运行结果:
解释:
- 父进程关闭了读端,没有读取数据。
- 子进程尝试写入数据时,由于管道已满,
write()
会阻塞,直到父进程读取数据或关闭读端。
🌵情况 3:写端关闭后
描述:
-
如果写端关闭后,读端读取完数据后会继续执行,不会挂起。
代码验证:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int pipefd[2];
pid_t pid;
if (pipe(pipefd) == -1)
{
perror("pipe");
exit(1);
}
pid = fork();
if (pid == -1)
{
perror("fork");
exit(1);
}
if (pid == 0)
{
// 子进程:写端写入数据后关闭
close(pipefd[0]); // 关闭读端
char buffer[128];
sprintf(buffer, "子进程写入的数据\n");
write(pipefd[1], buffer, strlen(buffer) + 1); // 写入数据
close(pipefd[1]); // 关闭写端
exit(0);
}
else
{
// 父进程:读端读取数据
close(pipefd[1]); // 关闭写端
char buffer[128];
ssize_t n = read(pipefd[0], buffer, sizeof(buffer)); // 读取数据
if (n > 0)
{
buffer[n] = '\0';
printf("父进程读取到数据: %s\n", buffer);
}
else if (n == 0)
{
printf("写端已关闭,没有更多数据。\n");
}
else
{
perror("read");
}
close(pipefd[0]); // 关闭读端
wait(NULL); // 等待子进程结束
}
return 0;
}
运行结果:
解释:
- 子进程写入数据后关闭写端。
- 父进程读取数据后,
read()
返回 0,表示写端已关闭,没有更多数据。
🌵情况 4:读端关闭后
描述:
-
如果读端关闭后,写端进程会收到
SIGPIPE
信号,导致写进程被操作系统终止。
代码验证:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
void handle_sigpipe(int sig)
{
printf("收到 SIGPIPE 信号,写端被终止。\n");
exit(1);
}
int main()
{
int pipefd[2];
pid_t pid;
if (pipe(pipefd) == -1)
{
perror("pipe");
exit(1);
}
signal(SIGPIPE, handle_sigpipe); // 捕获 SIGPIPE 信号
pid = fork();
if (pid == -1)
{
perror("fork");
exit(1);
}
if (pid == 0)
{
// 子进程:写端写入数据
close(pipefd[0]); // 关闭读端
char buffer[128];
sprintf(buffer, "子进程写入的数据\n");
write(pipefd[1], buffer, strlen(buffer) + 1); // 写入数据
close(pipefd[1]); // 关闭写端
exit(0);
}
else
{
// 父进程:关闭读端
close(pipefd[1]); // 关闭写端
close(pipefd[0]); // 关闭读端
wait(NULL); // 等待子进程结束
}
return 0;
}
运行结果:
解释:
- 父进程关闭了读端。
- 子进程尝试写入数据时,由于读端已关闭,
write()
会触发SIGPIPE
信号,导致子进程被终止。
4.3 五种特征
-
血缘关系限制:只能用于具有共同祖先的进程。
-
流式服务:数据无明确分割,按顺序传输。
-
生命周期随进程:进程退出后,管道释放。
-
内核同步与互斥:内核保证管道操作的同步与互斥。
-
半双工通信:数据单向流动,双向通信需两个管道。
🌴特征1:血缘关系限制
含义:
- 匿名管道只能用于具有共同祖先的进程之间的通信,通常是父子进程。
- 管道的文件描述符在创建时由父进程持有,子进程通过
fork()
继承父进程的文件描述符表,从而共享管道。原因:
- 匿名管道的文件描述符只在创建它的进程及其子进程中有效。
- 非亲缘进程无法共享匿名管道的文件描述符,因此无法使用匿名管道进行通信。
🌴特征2:流式服务
含义:
- 数据在管道中没有明确的分割,按顺序传输。
- 读取数据时,数据会连续地从管道中流出,直到读取完毕。
- 写入数据时,数据会连续地写入管道,直到写入完毕。
特点:
- 数据传输是连续的,没有固定的报文边界。
- 读取数据时,可能一次读取多个数据块,直到管道中的数据被读取完毕。
- 写入数据时,可能一次写入多个数据块,直到数据全部写入管道。
🌴特征3:生命周期随进程
含义:
- 匿名管道的生命周期与创建它的进程相关。
- 当所有引用管道的进程退出或关闭文件描述符后,管道会被销毁。
特点:
- 管道的文件描述符在所有进程中关闭后,管道会被释放。
- 如果父进程或子进程退出,管道的文件描述符会被关闭,管道会被销毁。
🌴特征4:内核同步与互斥
含义:
- 内核负责管理管道的同步与互斥,确保数据的正确传递。
- 当多个进程访问同一管道时,内核会自动处理同步与互斥问题,防止数据竞争和不一致。
特点:
- 同步:确保数据按顺序传输,读取操作在写入操作之后进行。
- 互斥:确保同一时间只有一个进程可以对管道进行读写操作,防止数据冲突。
🌴特征5:半双工通信
含义:
- 数据在管道中只能单向流动,即从写端到读端。
- 如果需要双向通信,必须创建两个管道,每个管道负责一个方向的数据传输。
特点:
- 单向通信:数据只能从写端流向读端,不能反向传输。
- 半双工:数据可以在两个方向上传输,但不能同时进行。需要两个管道来实现双向通信。