深入解析进程间通信:管道机制全揭秘
上篇文章:Linux:静态链接与动态链接深度解析
目录
1.进程间通信(IPC)介绍
1.1通信的本质
1.2通信的目的
1.3进程间通信的发展
1.4进程间通信分类
2.管道概念
3.匿名管道深度解析
3.1从文件描述符角度探讨管道
3.1.1利用fork()完成资源共享
3.1.2塑造单向数据流
3.2演示代码
3.2.1匿名管道的四大边界场景
3.3站在内核角度探讨管道
1. 颠覆认知的“一管两表”:两个独立的 struct file
2. 万物归一的锚点:共享同一个 inode
3. C 语言的“多态”魔法:f_op 操作函数集
4. 数据的最终归宿:内核数据页 (Data Page)
4.管道特性总结
1.进程间通信(IPC)介绍
1.1通信的本质
进程间通信(Inter-Process Communication, IPC)指的是两个或多个进程,进行信息相互传递的过程。
而进程是具有独立性的,进程 = 内核数据结构 + 代码和数据,所以,一个进程想把自己的数据发送给另一个进程是一件比较困难的事情。
做法:在后续进行进程间通信时,先让不同的进程,看到同一份资源,再通过操作系统提供的系统调用去访问。
1.2通信的目的
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另个进程的执(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
1.3进程间通信的发展
进程间通信技术随着操作系统的演进,主要经历了以下发展阶段:
管道 -> System V进程间通信 -> POSIX进程间通信
1.4进程间通信分类
- 管道:匿名管道(pipe)和命名管道(FIFO)
- System V IPC:System V消息队列,System V共享内存,System V信号量
- POSIX IPC:消息队列,共享内存,信号量,互斥量,条件变量,读写锁
2.管道概念
管道是Unix中最古老的进程间通信的形式,我们将一个进程链接到另一个进程的一个数据流称为一个“管道”。(详细介绍见下)
管道的宏观原理:创建一种与磁盘无关,但拥有inode属性,ops,缓冲区的特殊的 纯内存级文件且通过父进程产生子进程,此时由于发生浅拷贝指向同一个文件,父子会向同一个显示器进行打印。子进程继承父进程文件描述符表,此时两进程看向同一个缓冲区,如果一个从缓冲区读,一个向缓冲区写,这两个不同的进程,不就完成了通信吗。
我们将这种以文件的形式继承给子进程的通信方案称为管道。
3.匿名管道深度解析
结合上述章节,我们知道了管道实质上是操作系统“伪造”的一个文件,借此作为父子进程之间通信的桥梁。那么匿名管道是什么样的呢?
实际上,被操作系统“伪造”的这个“特殊文件”,就是因为其数据永远不会落盘,仅存活于内存之中,且在文件系统中没有挂载点和文件名,因此被称为匿名。
API 基础:
int pipe(int pipefd[2]);
pipefd:文件描述符数组。
pipefd[0]表示读端。
pipefd[1]表示写端。
3.1从文件描述符角度探讨管道
3.1.1利用fork()完成资源共享
文件造好了,但目前只有创建它的进程能看到,如何让另一个进程也能精确地指向这块纯内存缓冲区呢?答案是:利用进程创建时的继承机制。
当父进程调用pipe()时,内核会同时以“读”和“写”两种方式打开这个匿名文件,当父进程的文件描述符表(fd_array[])中占据两个位置:fd[0]为读端,fd[1]为写端。紧接着,父进程执行fork()创建子进程。在fork()的底层逻辑中,子进程不仅拷贝了父进程的内存数据,更重要的是,它对父进程的进程控制块和文件描述符表(files_struct)进行了一次完美的“浅拷贝”。
这意味着,子进程的fd_array[]数组中,同样有着fd[0]和fd[1],并且它们存放的指针值与父进程一模一样,精准的指向了内核中那同一个特殊的struct file对象。
至此,通信间进程的根本前提达成:无需任何复杂的内存映射,无需额外的同步机制,两个独立的进程已经看向了内核中的同一块缓冲区。
3.1.2塑造单向数据流
虽然父子进程此时都连接着这个特殊的缓冲区,并且都拥有读写两端,但管道在设计之初就注定了它是“半双工(单向通信)”的资源。如果双向同时读写,没有任何偏移量(offset)控制的流式文件一定会发生数据严重错乱。
因此,最后一步需要人为介入,完成管道的方向重塑:
- 假设我们希望父进程读,子进程写入。
- 父进程不需要写,于是调用close(fd[1]),关闭自己的写端
- 子进程不需要读,于是调用close(fd[0]),关闭自己的读端
伴随着两个文件描述符的关闭,一张原本复杂的网状引用图,瞬间变得无比清晰:一条完美的数据流管道诞生了。
3.2演示代码
#include <iostream> #include <unistd.h> #include <string> // 子进程:w void WriteData(int wfd) { int cnt = 1; pid_t id = getpid(); while(true) { sleep(1); std::string message = "hello father process, "; message += "cnt: " + std::to_string(cnt++) + ", my pid is: " + std::to_string(id); write(wfd, message.c_str(), message.size()); } } // 父进程:r void ReadData(int rfd) { char buf[1024] = {0}; while(true) { ssize_t s = read(rfd, buf, sizeof(buf)-1); if(s > 0) { buf[s] = '\0'; std::cout << getpid() << "father process read data: " << buf << std::endl; } } } int main() { // 1.创建管道 int pipefd[2] = {0}; int n = pipe(pipefd); (void)n; // 避免未使用变量的警告 // 2.创建子进程 pid_t id = fork(); if(id == 0) { // 3.形成单向通信的信道 // 子进程:w close(pipefd[0]); WriteData(pipefd[1]); close(pipefd[1]); exit(0); } else { // 3.形成单向通信的信道 // 父进程:r close(pipefd[1]); ReadData(pipefd[0]); close(pipefd[0]); } return 0; }运行结果:
3.2.1匿名管道的四大边界场景
在实际运行中,由于读写速度的差异或进程退出的情况,管道会表现出四种非常经典的边界场景(自带同步与互斥机制):
- 写端很慢,读端很快,以慢的节奏来 -- 父进程等待数据就绪,即等待子进程写入
- 写端很快,读端很慢,该端会把写端写入的数据一次能读上来,全部读上来(极端情况:写端特别快,读端不读,管道会被写满,写端进程会被阻塞)
- 写端不写,close(wfd),读端读完全部数据,read返回0,表示文件结尾
- 写端一直写,读端不读并且close(rfd),操作系统会通过信号杀掉写进程
场景四:
#include <iostream> #include <string> #include <unistd.h> #include <sys/wait.h> // 子进程:w void WriteData(int wfd) { int cnt = 1; char ch = 'a'; // pid_t id = getpid(); while(true) { // sleep(1); // std::string message = "hello father process, "; // message += "cnt: " + std::to_string(cnt++) + ", my pid is: " + std::to_string(id); // write(wfd, message.c_str(), message.size()); sleep(1); write(wfd, &ch, 1); printf("cnt: %d\n", cnt++); // 64kb // sleep(5); // break; } } // 父进程:r void ReadData(int rfd) { char inbuffer[1024]; while(true) { sleep(5); ssize_t n = read(rfd, inbuffer, sizeof(inbuffer)-1); if(n > 0) { inbuffer[n] = '\0'; std::cout << getpid() << " # " << inbuffer << std::endl; sleep(3); break; } else if(n==0){ printf("ReadData: pipe end: %ld\n", n); break; } else{ printf("ReadData: pipe error: %ld\n", n); break; } } } int main() { // 1. 创建管道成功 int pipefd[2] = {0}; int n = pipe(pipefd); (void)n; // 2. 创建子进程 pid_t id = fork(); if(id == 0) { // 3. 形成单向通信的信道 // 子进程:w close(pipefd[0]); WriteData(pipefd[1]); close(pipefd[1]); exit(0); } else { // 3. 形成单向通信的信道 // 父进程: r close(pipefd[1]); ReadData(pipefd[0]); close(pipefd[0]); int status = 0; pid_t rid = waitpid(id, &status, 0); (void)rid; printf("exit code: %d, exit signal: %d\n", (status>>8)&0xFF, status & 0x7F); } // 0->read fd, 1->write fd // 1->笔->写 // std::cout << "pipefd[0]: " << pipefd[0]<< std::endl; // std::cout << "pipefd[1]: " << pipefd[1]<< std::endl; return 0; }3.3站在内核角度探讨管道
在之前的探讨中,我们将管道简化为“指向同一个文件”。但当你真正翻开 Linux 内核源码,或者凝视上面这张内核数据结构交互图时,你会发现内核的设计比这还要精妙得多。
如果说前面的理解是“宏观层面的进程通信”,那么这张图揭示的则是“微观层面的内核对象重塑”。从 VFS(虚拟文件系统)的角度来看,匿名管道的本质是一次面向对象设计的完美实践。
结合内核剖析图,我们可以总结出管道的四大内核本质:
1. 颠覆认知的“一管两表”:两个独立的 struct file
在调用pipe(fd)时,内核并不仅仅是创建了一个文件对象,而是极为严谨地创建了两个独立的struct file对象!
进程 1(写端)对应一个
file结构,它的权限被严格限制为只写(O_WRONLY)。进程 2(读端)对应另一个
file结构,它的权限被严格限制为只读(O_RDONLY)。
为什么要这么做?因为在 Linux 中,文件的读写偏移量(f_pos)、打开模式(f_mode)和阻塞标志(f_flags)等状态是维护在struct file级别的。读端和写端有着完全独立的游标和状态,强制分离成两个file对象,彻底杜绝了读写状态的互相干扰。
2. 万物归一的锚点:共享同一个 inode
既然是两个不同的file对象,如何保证它们操作的是同一个管道呢? 秘密就在f_inode指针上。如图所示,无论是写端的file还是读端的file,它们的f_inode指针最终都指向了内存中的同一个inode节点。
在 Linux 文件系统中,inode才是文件系统资源的真正代表。这个特殊的pipefs类型的inode扮演了“大管家”的角色,它不关联磁盘块,而是直接在内存中分配并管理着一块物理内存。
3. C 语言的“多态”魔法:f_op 操作函数集
这是这张图中最迷人的一部分:定制化的文件操作集(f_op)。 Linux 的 VFS 之所以能“一切皆文件”,靠的就是f_op(struct file_operations)这个函数指针数组,这相当于 C++ 中的虚函数表。
写端
file结构的f_op被内核定向到了管道写操作(pipe_write)。当进程 1 调用write()时,VFS 层最终会回调到这个特定的函数。如果管道满了,该函数会将进程挂起等待。读端
file结构的f_op被定向到了管道读操作(pipe_read)。当进程 2 调用read()时,如果管道为空,该函数会自动将读进程阻塞。
管道自带的同步与互斥机制,正是隐藏在f_op所指向的这两个特定函数内部的!
4. 数据的最终归宿:内核数据页 (Data Page)
所有的宏观通信,最终都要落地到微观的内存字节拷贝。 顺着图中的inode继续往下看,它指向了一块**“数据页”。在较新的 Linux 内核中,这不仅仅是一个简单的页面,而是一个由多个物理内存页组成的环形缓冲区(Ring Buffer)**(默认通常是 16 个页,即 64KB)。
进程 1 的写操作,本质上是将用户态数据拷贝到这个环形缓冲区的尾部;进程 2 的读操作,则是从这个环形缓冲区的头部将数据拷贝回用户态。没有任何多余的磁盘寻址,只有纯粹的高速内存拷贝。
极客总结(内核视角):
所谓管道,在内核态的真实面貌是:VFS 层的一个特殊
inode及其挂载的环形内存数据页。为了满足单向通信的需求,内核精心构造了两个完全独立的struct file对象分别代表读写两端,并通过它们各自重写的f_op函数指针(pipe_read/pipe_write),隐式地接管了数据的同步阻塞逻辑。这一切复杂的内核数据结构流转,对外仅仅暴露出两个朴实无华的int fd。
4.管道特性总结
经过全篇的推演,我们可以将管道的核心特性归纳为以下 5 点:
- 管道在设计之初,只允许进行单向数据通信
- 管道只能用来让具有血缘关系的进程,进行进程间通信。但常用于父子进程之间,进行进程间通信
- 管道的本质是文件,一般文件如果它的进程退出了,那么文件也会被系统自动关闭。打开的文件的生命周期是随着进程的
- 管道自己内部实现了进程间的同步
- 管道是面向字节流的(读写次数不匹配)
