进程间通信

一、管道

就像现实中的人一样,各进程之间一定会出现需要信息交换的场景(如数据传输,资源共享等),这就是进程间通信。

进程间通信有许多种方法,管道是其中之一。虽然管道在进程间通信中的地位不高,但依旧能帮助我们学习进程间通信原理。

管道是内核级结构,之前使用的 | 指令就是管道。

如何通过管道进行进程间通信

进程之间是具有独立性的,哪怕是父子进程,其在进行写时操作后也会彻底分流。那么,就父子进程而言,父子进程间是如何通过管道进行通信的呢?

在父进程fork子进程的时候,父进程的数据也会被拷贝到子进程中。但是,子进程在创建的时候files_struct结构体和struct_file结构体都会拷贝过去(注意是浅拷贝)。不同的是,子进程中的struct_file依旧会指向内存中的文件内核缓冲区,这个被父子进程共同指向的文件内核缓冲区就是实现父子进程间通信的关键。子进程可通过这个缓冲区直接读取父进程中的数据。

而这个依靠struct file、父子进程之间的血缘关系以及内存中的文件内核缓冲区,且磁盘中没有标识,这是典型的匿名管道。

匿名管道

父子进程在 fork 时,会继承并共享同一个内核级的管道缓冲区,通过继承下来的文件描述符收发数据,这就是匿名管道通信。管道属于内核资源,不需要路径和文件名,只能在亲缘进程间使用,并且通信是单向的

pipe函数

pipe函数是 Linux/Unix 中创建匿名管道的核心系统调用,作用是让内核在内存中生成一个匿名管道缓冲区,并返回两个文件描述符(fd),分别对应管道的读端和写端,为后续父子进程通信铺路。

#include <unistd.h> int pipe(int fd[2]);
说明
参数fd

输出型数组,长度为 2:

fd[0]:管道读端(只能读,写会报错)

fd[1]:管道写端(只能写,读会报错)

返回值

成功:返回 0

失败:返回 -1,同时设置errno(如EMFILE表示文件描述符耗尽)

#include <stdio.h> #include <unistd.h> #include <string.h> #include <sys/wait.h> int main() { int fd[2]; // 1. 创建匿名管道:内核生成缓冲区,返回读/写端fd if (pipe(fd) == -1) { perror("pipe failed"); return 1; } // 2. fork创建子进程:子进程继承fd[0]/fd[1],指向同一管道缓冲区 pid_t pid = fork(); if (pid == -1) { perror("fork failed"); return 1; } if (pid == 0) { // 子进程:读数据 // 3. 关闭无用的写端(单向通信,子只读) close(fd[1]); char buf[1024]; // 4. 从读端fd[0]读取管道数据(无数据则阻塞) ssize_t n = read(fd[0], buf, sizeof(buf)-1); if (n > 0) { buf[n] = '\0'; printf("子进程读到:%s\n", buf); } // 5. 关闭读端 close(fd[0]); } else { // 父进程:写数据 // 3. 关闭无用的读端(单向通信,父只写) close(fd[0]); const char *msg = "Hello from parent (匿名管道通信)"; // 4. 向写端fd[1]写入管道数据 write(fd[1], msg, strlen(msg)); // 5. 关闭写端 close(fd[1]); // 等待子进程结束 wait(NULL); } return 0; }

在使用pipe函数时,如果有一端不使用,为了防止误操作我们建议将不需要使用的端口关闭(close)掉。

上图详细直白说明了文件描述符与管道之间的关系,接下来我们将在内核的角度深入了解管道。

站在内核角度看管道

上图中,两个进程的 f_inode 都指向同一个管道 inode(共用同一个管道),inode 关联 “数据页”,这就是管道的内核缓冲区,是通信数据的实际存储位置(管道)。

两进程的 f_op 则是分别指向管道读写函数write/read ,而不是普通文件的读写函数。

private_data指向管道的核心管理结构 pipe_inode_info ,里面记录了缓冲区地址、读写指针、等待队列等。

进程通信的本质

两个进程的 struct file 指向同一个 inode,而 inode 关联同一个内核缓冲区,因此它们操作的是同一块内存。
对于父子进程,这是通过 fork 继承父进程的 struct file 指针实现的;对于命名管道,是通过 open 同一个 FIFO 文件,让内核分配指向同一 inode 的 struct file。

二、匿名管道的特征

匿名管道 5 种特性

1.单向通信

所谓的单项通信就是数据需要严格通过写( fd[1] )端到读( fd[0])端,不能倒转,方向固定。

2.仅用于有血缘关系的进程

什么是具有血缘关系的呢,就是进程之间通过创建(fork)追溯能建立联系的进程,最常见的就是父子进程。为什么必须要求具备血缘关系呢?是因为匿名管道没有磁盘可见的名称,无法被无关进程通过路径名寻址,只能通过fork()继承文件描述符来共享

3.面向字节流

字节流是指数据以连续的字节序列形式传输,没有固定的 “消息边界”,就像水流一样连续不断。

字节流带来的问题

1、消息边界消失

也就是写端多次写入的内容,读端可能一次全部读完;写端一次写入的大段数据,读端也可能分多次读取。例如将“hello world”字符串拆分为字符流的时候,因为读端是将缓冲区中的字节流截断读入,就容易导致上面的问题。

2、粘、拆包问题

粘包:多个小的写操作被内核合并,导致读端收到 “粘在一起” 的字节;

拆包:一个大的写操作被拆分成多次读操作返回,导致一个完整消息被拆成多段。

这会让应用层无法直接按 “消息” 为单位处理数据。

解决方案:

1、长度前缀法

发送数据时,先发送固定长度的 “长度字段”(比如 4 字节整数),表示后续实际消息的字节长度;接收方先读取这个长度值,再按长度精准读取消息内容,彻底避免粘包 / 拆包。

2、分割符号法

特殊分隔符作为消息的结束标记;接收方逐字节读取数据,直到遇到分隔符,就认为拿到一个完整消息。

4、生命周期随进程

管道的内核缓冲区由内核管理,其生命周期绑定在持有它的文件描述符(fd)上。这个跟智能指针很像,每个管道创建时都会记录有多少个进程绑定,当其绑定的进程数量为0的时候,该管道就会被销毁。

5. 自带互斥与同步机制

互斥:内核保证同一时间只有一个进程能对管道缓冲区进行写操作,避免数据混乱。

内核给每个管道配一把写互斥锁(mutex):进程要写管道,必须先拿到这把锁,没拿到就暂停(睡眠),等锁释放后再抢;如果单次写的数据量 ≤ 64KB(Linux 默认PIPE_BUF),内核保证这一次写操作 “要么全写完,要么全不写”,不会被其他进程打断(完全原子);若超过 64KB,则无法保证;

读操作无需互斥锁:多个进程同时读不会乱,内核通过原子操作更新读指针,避免重复读取同一字节。

同步:协调读 / 写进程的操作节奏,实现 “有空再读、有位再写”,避免读空管道、写满管道导致的无效循环,以及进程无限阻塞。

内核给管道维护「读等待队列」「写等待队列」,结合缓冲区 “已用字节数”“读写进程数” 来控制:

读空管道的读进程会进入读等待队列睡眠,直至写进程写入数据被内核唤醒;写满管道(默认 64KB)的写进程会进入写等待队列睡眠,直至读进程读走数据腾出空间被内核唤醒;所有写端关闭时读进程读完缓冲区剩余数据后 read () 返回 0(EOF),所有读端关闭时写进程写管道会触发内核发送SIGPIPE信号(默认终止进程,进程捕获则 write () 返回 - 1 且报错 EPIPE)。

互斥同步这里先简单了解一下,后面会再讲清楚的。

匿名通道的4种情况

读空管道:当子进程写得慢,父进程尝试读取时管道为空,父进程会进入阻塞状态,直到子进程写入数据后才被唤醒继续读取。

写满管道:当子进程写得快,而父进程不读取,导致管道缓冲区(默认 64KB)被写满,子进程会进入阻塞状态,直到父进程读取数据腾出空间后才被唤醒继续写入。

写端关闭,读端读取:当所有写端关闭后,读端会先读完管道中剩余的数据,之后再次调用read()时,会返回0(EOF,end of file标识符),表示管道已无数据可读,读到了 “文件结尾”。

读端关闭,写端写入:当所有读端关闭后,写端继续向管道写入数据,内核会向写进程发送SIGPIPE信号(默认终止进程);如果进程捕获了该信号,write()会返回-1,并设置errnoEPIPE(管道损坏错误)。