1. 进程间通信介绍
1.1 什么是进程间通信
进程间通信(Inter-Process Communication, IPC)是指不同进程之间进行数据交换或信息共享的机制。由于每个进程拥有独立的地址空间和资源(如内存、文件描述符),操作系统通过内核提供的特殊方法实现进程间的数据传递。
- 核心原因:进程的独立性导致其无法直接访问彼此的数据(例如,一个进程的全局变量对另一个进程不可见)。
- 实现原理:内核作为中介,开辟一块缓冲区(如管道、共享内存),进程将数据从用户空间拷贝到内核缓冲区,再由目标进程读取。
- 本质:让不同进程通过操作系统访问同一份资源(特定形式的内存空间)。
1.2 为什么要进程间通信
IPC的主要目的是解决进程隔离带来的协作障碍,具体需求包括:
- 数据传输:一个进程需将数据发送给另一个进程(如子进程向父进程返回计算结果)。
- 资源共享:多个进程需共享资源(如内存、文件、设备),避免重复占用。
- 事件通知:进程需通知其他进程特定事件(如进程终止时告知父进程)。
- 进程协同:实现进程间的同步、互斥或协同操作(如调试进程控制目标进程)。
- 提高效率:将任务分解为多个并发进程,提升系统整体性能。
1.3 怎么进程间通信
IPC的实现方式主要分为四类(具体技术可能重叠):
(1)管道(Pipe)
- 匿名管道:仅限血缘关系进程(如父子进程),单向通信,通过
pipe()
系统调用创建内核缓冲区。- 特点:半双工、容量固定(64KB)、生命周期随进程。
- 命名管道(FIFO) :通过文件系统路径标识,支持无血缘关系进程通信。
(2)System V IPC
- 共享内存:多个进程直接访问同一块物理内存,速度最快但需同步机制(如信号量)避免冲突。
- 消息队列:内核维护的消息链表,支持异步通信并保留消息边界。
- 信号量:用于进程同步(如资源互斥访问)。
(3)POSIX IPC
- 标准化IPC机制(如POSIX消息队列、信号量),设计更简洁,支持跨主机通信。
(4)其他方式
- 信号(Signal) :内核向进程发送事件通知(如
SIGINT
终止进程),开销最小但不适合大数据传输。 - 套接字(Socket) :支持网络通信(TCP/UDP)和本地通信(Unix域套接字)。
- 文件/内存映射:通过文件或内存映射区域间接交换数据。
关键原理:所有IPC均依赖内核管理的共享资源(如缓冲区、内存区域),进程通过系统调用访问这些资源。
1.4 进程间通信的历史发展
早期阶段(1960s–1970s):
- 管道作为最古老的IPC出现在Unix中,通过
|
符号实现命令间数据流传递。 - 信号用于简单事件通知(如进程终止)。
- 管道作为最古老的IPC出现在Unix中,通过
System V IPC(1980s):
- AT&T在Unix System V中引入共享内存、消息队列和信号量,成为IPC标准。
标准化(1990s–2000s):
- POSIX IPC提供跨平台兼容性,支持更灵活的通信模型。
- 套接字因互联网普及成为跨主机通信主流。
现代发展(2010s至今):
- 高级框架:如D-Bus(桌面通信)、gRPC(远程调用)、MQTT(物联网)简化复杂通信。
- 微内核优化:IPC成为微内核(如QNX)的核心机制,通过消息传递替代系统调用。
驱动因素:多任务需求、分布式计算兴起及操作系统架构演进。
总结
进程间通信是打破进程隔离、实现协作的关键机制,其发展从基础管道扩展至多样化技术,核心始终围绕内核中介的共享资源访问。不同场景需权衡性能、复杂度与通信需求选择合适方式(如管道适合简单血缘进程通信,共享内存适合高性能数据共享)。
1.5 问题拓展
进程间通信是通过操作系统提供的特殊方法实现进程间的数据传递。
可是,在之前的学习中我们知道进程PCB中的有一个 *file的结构体指针指向file_struct结构体,file_struct结构体中存储的核心成员为 fd_array[]
(文件指针数组),数组元素指向file结构体,每个file结构体中都包含缓冲区,详细请看专栏【Linux系统】中的【基础IO下】
如下图:
既然这样,那为什么不能用file结构体中的内核缓冲区,通过系统调用read/write以不同的读写方式打开来进行进程间通信呢?
一、设计目标冲突:文件缓冲区与通信缓冲区的本质差异
数据持久性要求不同
- 普通文件的内核缓冲区(如
struct file
中的缓冲区)必须将数据刷新到磁盘以实现持久化,而进程间通信(IPC)要求数据 " 仅存在于内存 ",且通信完成后立即失效。若强制普通文件缓冲区用于IPC,会导致- 不必要的数据落盘操作,违反通信的临时性需求;
- 磁盘I/O延迟破坏通信实时性
- 管道的缓冲区被设计为“纯内存文件”,数据永不写入磁盘,从根源上规避此问题
- 普通文件的内核缓冲区(如
打开方式限制
- 普通文件若分别以只读或只写方式打开,会因缺少配对操作端而阻塞:
- 只读打开会阻塞直到有进程以写方式打开同一文件
- 只写打开同理
- 管道在创建时即同时以读写方式打开同一文件,确保两端进程可即时通信
- 普通文件若分别以只读或只写方式打开,会因缺少配对操作端而阻塞:
二、性能损耗:内核缓冲区的双重拷贝问题
普通文件的读写流程
write()
仅将数据从用户空间拷贝到内核缓冲区,不直接落盘- 后续由内核异步将缓冲区数据刷新到磁盘
read()
时若数据在内核缓冲区,则直接读取;否则触发磁盘I/O- 问题:通信需两次拷贝(用户→内核→用户),且磁盘I/O引入毫秒级延迟
管道的优化设计
- 数据仅在内核缓冲区中流动,无磁盘交互
- 通过共享同一内存区域,发送方写缓冲区和接收方读缓冲区为同一物理内存,实现零拷贝
三、并发安全:缺乏进程间同步机制
普通文件缓冲区的竞争风险
- 多个进程并发读写同一文件时,内核不自动提供互斥锁或同步机制
- 可能导致数据覆盖或读取不完整(如进程A写入中途,进程B读到部分数据)
管道的进程同步保障
- 通过阻塞式读写实现同步:
- 管道满时写进程阻塞,直到读进程取走数据
- 管道空时读进程阻塞,直到写进程写入数据
- 内核自动管理缓冲区状态,避免并发冲突
- 通过阻塞式读写实现同步:
四、生命周期管理:通信与文件解耦
文件缓冲区的残留风险
- 普通文件关闭后,内核缓冲区数据可能残留并被后续进程读取,导致通信数据泄露;
- 若文件未关闭但通信进程终止,缓冲区成为“僵尸资源”
管道的动态销毁机制
- 匿名管道:随进程结束自动销毁缓冲区
- 命名管道:所有进程关闭后内核立即回收资源
- 读端关闭后继续写入会触发
SIGPIPE
信号终止写进程
五、操作系统的安全隔离原则
普通文件缓冲区允许任意进程通过路径访问,违背了IPC的最小权限原则:
- 管道通过继承文件描述符(匿名管道)或受限访问权限(命名管道)确保仅目标进程可访问缓冲区
- 若直接使用普通文件,恶意进程可能通过路径劫持通信数据
结论:管道是专为IPC优化的内存对象
普通文件的 struct file
内核缓冲区与进程通信需求存在根本性冲突,主要体现在数据持久化、打开方式、性能、并发安全及生命周期管理上。管道通过以下设计实现高效安全的IPC:
- 纯内存存储:规避磁盘I/O
- 双向打开:即时激活读写端
- 阻塞同步:内核自动管理缓冲区状态
- 动态销毁:随进程结束回收资源
2. 管道
2.1 管道本质定义
管道是 Unix/Linux 系统中历史最悠久的进程间通信(IPC)机制,其核心设计为:
- 内存级单向数据流:管道不涉及磁盘存储,数据存在于内核缓冲区(内存区域),仅通过读写操作在进程间传递 。
- 单向通信信道:数据流向固定(如进程 A → 管道 → 进程 B),双向通信需创建两个独立管道 。
- 进程连接桥梁:管道将一个进程的输出直接定向为另一进程的输入,形成"生产者-消费者"模型 。
示例;
ltx@iv-ye1i2elts0wh2yp1ahah:~$ who | wc -l
5
示例解析
who | wc -l
:
who
进程的输出(登录用户列表)通过管道定向至wc -l
进程的输入。wc -l
统计接收到的行数并输出结果(如5
)。- 底层实现:
- Shell调用
pipe()
创建管道;fork()
两次生成who
和wc
进程;- 通过
dup2()
将who
的输出重定向至管道写端,wc
的输入重定向至管道读端 。
2.2 管道原理
内核缓冲区结构
- 环形队列(Ring Buffer) :管道本质是内核维护的固定容量循环队列(默认 4KB),采用先进先出(FIFO)策略管理数据流 。
- 无磁盘交互:数据仅存在于内存缓冲区,进程终止后自动销毁,避免持久化开销 。
文件描述符抽象
- 通过
pipe()
系统调用创建两个文件描述符:fd[0]
:读端,从缓冲区取数据fd[1]
:写端,向缓冲区写数据
- 管道被视为 伪文件(Pseudo-file) :支持标准文件 I/O 操作(
read()
/write()
),但无实体磁盘文件 。
- 通过
单向数据流
- 数据严格从写端流向读端(半双工),双向通信需创建两个独立管道 。、
2.3 管道创建
1. 系统调用入口
函数原型:
int pipe(int fd[2]); // 成功返回0,失败返回-1
内核路径:
fs/pipe.c
→do_pipe2()
→__do_pipe_flags()
。
2. 关键步骤(Linux 5.10+)
- 分配 inode:
- 调用
get_pipe_inode()
在pipefs
中分配新 inode,初始化环形缓冲区 。
- 调用
- 创建文件结构:
为读端(
f0
)和写端(f1
)分配struct file
对象:f0 = alloc_file_pseudo(inode, pipe_mnt, "", O_RDONLY, &pipefifo_fops); f1 = alloc_file_pseudo(inode, pipe_mnt, "", O_WRONLY, &pipefifo_fops);
绑定操作函数集
pipefifo_fops
(含read
/write
方法) 。
- 分配文件描述符:
- 在当前进程的文件描述符表中,寻找两个空闲位置,存储
f0
和f1
的指针 。 - 将描述符值写入用户空间数组
fd[2]
(fd[0]=读端, fd[1]=写端
) 。
- 在当前进程的文件描述符表中,寻找两个空闲位置,存储
- 初始化缓冲区:
- 设置环形队列头尾指针(
head
/tail
),状态为空
。
- 设置环形队列头尾指针(
注:
pipe2()
支持附加标志(如O_NONBLOCK
),扩展默认行为 。
2.4 匿名管道
一、本质定义与核心特性
1. 基本概念
- 内存级通信机制:匿名管道是由内核管理的临时缓冲区(环形队列),仅存在于内存中,不涉及磁盘存储,进程终止后自动销毁
- 单向数据流:数据严格从 写端(
fd[1]
)流向读端(fd[0]
) ,双向通信需创建两个独立管道 - 血缘进程限制:仅限父子进程或兄弟进程(通过
fork()
继承文件描述符)通信
2. 关键特性
特性 | 说明 |
---|---|
临时性 | 生命周期与进程绑定,进程退出后内核自动回收资源 。 |
资源轻量 | 无磁盘文件实体,仅占用内核内存(默认缓冲区 4KB) |
流式传输 | 数据为无格式字节流,需应用层定义消息边界(如分隔符) |
阻塞同步 | 读空时阻塞等待数据,写满时阻塞等待空间 |
二、实现原理与内核机制
1. 核心数据结构
struct pipe_buffer {
struct page *page; // 内存页指针
unsigned int offset; // 当前读写偏移
unsigned int len; // 有效数据长度
};
struct pipe_inode_info {
struct pipe_buffer *bufs; // 环形缓冲区数组
unsigned int head; // 写指针
unsigned int tail; // 读指针
wait_queue_head_t rd_wait; // 读等待队列
wait_queue_head_t wr_wait; // 写等待队列
};
- 环形队列:内核维护固定容量的循环缓冲区(默认 4KB),通过
head
和tail
指针管理读写位置 - 文件抽象:通过虚拟文件系统
pipefs
分配 inode,支持标准文件操作接口(read
/write
)
2. 系统调用 pipe()
流程
- 缓冲区创建:
- 调用
pipe()
时,内核分配pipe_inode_info
结构体,初始化环形队列和等待队列
- 调用
- 文件描述符绑定:
- 返回两个文件描述符:
fd[0]
(读端)绑定至读操作函数集,fd[1]
(写端)绑定至写操作函数集 。
- 返回两个文件描述符:
- 进程继承:
fork()
后子进程复制父进程的文件描述符表,共享同一管道缓冲区 。
3. 同步与阻塞机制
- 自旋锁保护:读写操作前获取自旋锁,防止并发冲突 。
- 等待队列:
- 读空时,进程加入
rd_wait
队列休眠,写操作完成后唤醒; - 写满时,进程加入
wr_wait
队列休眠,读操作释放空间后唤醒
- 读空时,进程加入
三、代码示例
下面我们直接来一段代码示例,让父子进程通过匿名管道通信,以此加深我们对匿名管道的理解
首先创建管道,注意pipe的参数是输出型参数
#include <iostream>
#include <cstdio>
#include <unistd.h>
int main()
{
// 1.创建管道
int fds[2] = {0};
int n = pipe(fds);
if(n < 0)
{
std::cerr << "pipe error" << std::endl;
return 1;
}
std::cout << "fds[0]:" << fds[0] << std::endl;
std::cout << "fds[1]:" << fds[1] << std::endl;
return 0;
}
运行测试一下:
ltx@iv-ye1i2elts0wh2yp1ahah:~/Linux_system/lesson10/TestPipe$ make
g++ -o testPipe testPipe.cc -std=c++11
ltx@iv-ye1i2elts0wh2yp1ahah:~/Linux_system/lesson10/TestPipe$ ./testPipe
fds[0]:3
fds[1]:4
接下来创建子进程,然后分别关闭父子进程的写端和读端,这里我们让父进程来读,子进程来写
// 2. 创建子进程
pid_t fd = fork();
if(fd == 0)
{
// child
// 3. 关闭不需要的读写端,形成通信信道
// father->read, child->write
close(fds[0]);
ChildWrite(fds[1]);
close(fds[1]);
}
// father
// 3. 关闭不需要的读写端,形成通信信道
// father->read, child->write
close(fds[1]);
FatherRead(fds[0]);
waitpid(fd, nullptr, 0);
close(fds[0]);
子进程写
void ChildWrite(int wfd)
{
char buffer[1024];
int cnt = 0;
while(true)
{
snprintf(buffer, sizeof(buffer), "I am child, pid:%d, cnt:%d", getpid(), cnt++);
write(wfd, buffer, strlen(buffer));
sleep(5);
}
}
父进程读
void FatherRead(int rfd)
{
char buffer[1024];
while(true)
{
buffer[0] = 0;
ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
if(n > 0)
{
buffer[n] = 0;
std::cout << "child say:" << buffer << std::endl;
}
else if(n == 0)
{
std::cout << "子进程退出" << std::endl;
break;
}
else
{
break;
}
}
}
这里我们写一条消息就sleep5秒,子进程写得慢,父进程读得快会怎么样呢
- 现象:父进程的
read()
会阻塞,直到管道中有数据可读 。- 当管道为空时,读端会阻塞等待数据。
- 结果:父进程每次读取需等待子进程写入,输出频率与子进程写入频率一致(每5秒一次)。
那如果写得快读得慢呢,或者写端关了读端继续读,读端关了写端继续写会怎么样
1. 写快读慢(写入速度快于读取速度)
行为表现:
当子进程(写端)持续快速写入,而父进程(读端)读取速度较慢时,管道缓冲区(默认64KB)会被写满。此时写端进入阻塞状态(write()
暂停),直到读端取走部分数据腾出缓冲区空间后才能继续写入 。- 示例:
若子进程每秒写入10KB数据,父进程每秒仅读取1KB,约6.4秒后管道写满,子进程的write()
被阻塞;父进程每次读取后,子进程才能继续写入。
- 示例:
底层机制:
管道本质是内核维护的环形队列。当head - tail = buffer_size
时,写操作被阻塞;读操作使tail
后移,唤醒阻塞的写端 。风险提示:
长期阻塞可能导致系统资源浪费或响应延迟,但不会丢失数据(因阻塞机制保证原子性)。
2. 写端关闭后读端继续读
行为表现:
若子进程(写端)关闭管道(close(fds[1])
),父进程(读端)的read()
会返回 0,表示已读取到文件结束符(EOF)底层机制:
写端关闭后,管道的引用计数归零。读端读取完缓冲区剩余数据后,再次调用read()
会立即返回0(而非阻塞),通知进程通信终止 。正确操作:
父进程检测到n=0
后应退出循环并关闭读端,避免资源泄漏 。
3. 读端关闭后写端继续写
行为表现:
若父进程(读端)关闭管道(close(fds[0])
),子进程(写端)的write()
会触发 SIGPIPE 信号(默认行为是终止进程)。- 示例:
父进程意外退出时,子进程下一次write()
将收到信号13(SIGPIPE),进程被强制终止。
- 示例:
底层机制:
操作系统为避免资源浪费,当检测到读端关闭时,会通过信号强制终止写端进程。若写端尝试写入已关闭的管道,首次可能收到 EPIPE 错误(errno=32
),再次写入则触发信号 。
补充:
读端不读且写端持续写
- 行为:管道写满后写端永久阻塞,形成死锁。需外部干预(如杀死进程)解除 。
这里我们就不一一验证了,感兴趣可以自己下来验证
总结:管道的核心特性
场景 | 行为 | 解决方案 |
---|---|---|
写快读慢 | 写端阻塞直到缓冲区有空位 | 优化读端速度或扩大缓冲区 |
读快写慢 | 读端阻塞直到有新数据 | 增加写端频率 |
写端关闭后读端读 | read() 返回0 (EOF) |
检测 n=0 并退出循环 |
读端关闭后写端写 | 触发 SIGPIPE 终止写进程 |
捕获信号或检查 EPIPE 错误 |
读端不读且写端持续写 | 死锁 | 避免循环依赖或设置超时机制 |
以上是站在文件描述符角度来理解管道,如下图所示,注意:代码示例和图中有一个不同,那就是代码示例是父进程读子进程写,图示则相反
我们还可以从内核角度来理解管道本质
2.5 内核角度理解
一、管道的内核本质:内存级环形缓冲区
- 核心数据结构(基于
pipe_inode_info
)
// 内核源码 fs/pipe.c
struct pipe_inode_info {
struct pipe_buffer *bufs; // 环形缓冲区数组(默认16个page,64KB)
unsigned int head; // 写指针(生产者位置)
unsigned int tail; // 读指针(消费者位置)
wait_queue_head_t rd_wait; // 读等待队列
wait_queue_head_t wr_wait; // 写等待队列
unsigned int readers; // 读端引用计数
unsigned int writers; // 写端引用计数
};
- 环形队列:缓冲区以内存页(
page
)为单位组织,通过head
和tail
实现循环写入。 - 无磁盘交互:数据仅存于内存,不刷新到磁盘(区别于普通文件)。
- 文件抽象层
通过
pipefs
虚拟文件系统创建匿名文件:struct file *f = alloc_file_pseudo(inode, pipe_mnt, "", flags, &pipefifo_fops);
返回两个文件描述符:
fd[0]
绑定读操作函数集:.read = pipe_read
fd[1]
绑定写操作函数集:.write = pipe_write
二、管道创建与通信的内核流程
1. 系统调用 pipe()
的完整流程
步骤 | 内核操作 | 用户态表现 |
---|---|---|
1. 分配资源 | 调用 get_pipe_inode() 创建 pipe_inode_info |
int pipefd[2] |
2. 绑定描述符 | 为读写端分别创建 file 结构体 |
返回 fd[0] (读端)、fd[1] (写端) |
3. 血缘进程继承 | fork() 时复制文件描述符表 |
子进程共享同一管道缓冲区 |
2. 数据读写内核路径
写操作(
pipe_write
):while (缓冲区满) { 将当前进程加入 wr_wait 队列; 设置进程状态为 TASK_INTERRUPTIBLE; 调用 schedule() 让出CPU; } 将数据拷贝到 bufs[head] 对应的内存页; head = (head + 1) & (bufs_mask); 唤醒 rd_wait 队列中的进程;
读操作(
pipe_read
):while (缓冲区空) { 加入 rd_wait 队列; TASK_INTERRUPTIBLE; schedule(); } 从 bufs[tail] 拷贝数据到用户空间; tail = (tail + 1) & (bufs_mask); 唤醒 wr_wait 队列中的进程;
关键机制:自旋锁保护
head/tail
修改的原子性
三、四种通信场景的内核行为
1. 写得慢 & 读得快
- 场景:父进程读循环无延迟,子进程每秒写1次(
sleep(1)
) - 内核行为:
- 读进程在
rd_wait
队列休眠(阻塞) - 写操作唤醒读进程后立即返回
- 读进程在
- 性能影响:CPU空转少,但读进程频繁阻塞/唤醒增加上下文切换开销
2. 写得快 & 读得慢
场景:子进程移除
sleep
高速写,父进程每5秒读1次内核行为:
阶段 写进程状态 缓冲区状态 初始 运行态 空 缓冲区满 加入 wr_wait
队列阻塞100%占用 读操作后 唤醒并继续写入 释放部分空间 风险:频繁阻塞唤醒导致吞吐量下降,极端时触发进程挂起
3. 写端关闭后读端继续读
- 内核行为:
- 读操作发现
writers == 0
- 若缓冲区有数据:正常返回数据
- 若缓冲区空:返回 0(EOF)
- 读操作发现
4. 读端关闭后写端继续写
- 内核行为:
- 写操作检查
readers == 0
- 向写进程发送 SIGPIPE 信号(默认终止进程)
write()
返回 -1,errno=EPIPE
- 写操作检查
- 风险:子进程被强制终止,父进程
waitpid
收到信号
四、管道特性的内核实现原理
1. 血缘关系限制
- 本质原因:管道依赖文件描述符继承
- 内核机制:
fork()
时复制父进程的files_struct
- 子进程通过相同的
file->f_inode
访问同一缓冲区
2. 原子性保证
条件:单次写入 ≤
PIPE_BUF
(默认4KB)内核实现:
mutex_lock(&pipe->mutex); // 加互斥锁 copy_page_from_iter(buf, offset, bytes, from); // 原子拷贝 mutex_unlock(&pipe->mutex);
超过
PIPE_BUF
时数据可能被拆分
3. 同步机制
阻塞控制:
条件 读进程行为 写进程行为 缓冲区空 加入 rd_wait
阻塞立即返回 缓冲区满 立即返回 加入 wr_wait
阻塞唤醒机制:通过内核调度器实现读写协同
管道的核心是内存中的环形缓冲区(pipe_inode_info
),通过文件抽象层和等待队列同步机制实现进程间通信。
完整代码示例:
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstring>
void ChildWrite(int wfd)
{
char buffer[1024];
int cnt = 0;
while(true)
{
snprintf(buffer, sizeof(buffer), "I am child, pid:%d, cnt:%d", getpid(), cnt++);
write(wfd, buffer, strlen(buffer));
sleep(5);
}
}
void FatherRead(int rfd)
{
char buffer[1024];
while(true)
{
buffer[0] = 0;
ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
if(n > 0)
{
buffer[n] = 0;
std::cout << "child say:" << buffer << std::endl;
}
else if(n == 0)
{
std::cout << "子进程退出" << std::endl;
break;
}
else
{
break;
}
}
}
int main()
{
// 1.创建管道
int fds[2] = {0};
int n = pipe(fds);
if(n < 0)
{
std::cerr << "pipe error" << std::endl;
return 1;
}
std::cout << "fds[0]:" << fds[0] << std::endl;
std::cout << "fds[1]:" << fds[1] << std::endl;
// 2. 创建子进程
pid_t fd = fork();
if(fd == 0)
{
// child
// 3. 关闭不需要的读写端,形成通信信道
// father->read, child->write
close(fds[0]);
ChildWrite(fds[1]);
close(fds[1]);
}
// father
// 3. 关闭不需要的读写端,形成通信信道
// father->read, child->write
close(fds[1]);
FatherRead(fds[0]);
waitpid(fd, nullptr, 0);
close(fds[0]);
return 0;
}