上篇文章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 file2. 万物归一的锚点共享同一个 inode3. 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 IPCSystem 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(n0){ 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, (status8)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_opstruct 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 点管道在设计之初只允许进行单向数据通信管道只能用来让具有血缘关系的进程进行进程间通信。但常用于父子进程之间进行进程间通信管道的本质是文件一般文件如果它的进程退出了那么文件也会被系统自动关闭。打开的文件的生命周期是随着进程的管道自己内部实现了进程间的同步管道是面向字节流的读写次数不匹配