[Linux系统编程]进程间通信—管道
进程间通信—管道
- 1. 进程通信介绍
- 1.1 进程通信目的与发展
- 1.2 进程间通信的发展
- 2. 管道
- 2.1 什么是管道
- 2.2 匿名通道
- 2.3 管道的特点
- 2.3.1 管道提供流式服务
- 2.3.2 管道读写操作及模式
- 2.3.3 原子性写入与 PIPE_BUF
- 2.3.4 同步与互斥机制
- 2.3.5 管道的局限性与适用场景
- 2.4 命名管道
- 2.4.1 命名管道的创建
- 2.4.2 匿名管道与命名管道的区别与联系
1. 进程通信介绍
1.1 进程通信目的与发展
进程通信目的:
数据传输:一个进程需要将它的数据发送给另一个进程
资源共享:多个进程之间共享同样的资源。
通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
进程运行的时候是具有独立性,在进行进程间通信的时候,一定需要借助第三方(OS)资源。通信的本质就是”数据的拷贝” (进程A 把数据拷贝至OS,OS将数据拷贝至进程B,OS一定要提供一段内存区域且能被双方进程看到)。因此进程间通信本质就是让不同的进程看到同一份资源(内存、文件内核缓冲区等)。资源由(OS中的哪些模块)提供,就有了不同的通信方式
1.2 进程间通信的发展
2. 管道
2.1 什么是管道
管道是一种在操作系统中用于不同进程间数据传输的机制。 它类似于一个系统内部的“传送带”或“水管”,数据被写入管道后,存放在内核提供的缓冲区里,另一端的进程再从缓冲区中取出数据进行处理。管道一般要求通信的进程有亲缘关系(例如父子进程),因为管道在创建时通常由父进程创建,继承给子进程使用。管道是临时的,其生命周期依赖于进程。如果进程结束,管道也会随之关闭。
管道是Unix中最古老的进程间通信的形式。
我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。
who | wc -l
# wc表示统计有多少行
使用文件方式,来进行数据共享,这叫做管道通信方式
管道虽然用的是文件的方案,其实os一定不会把数据刷新到磁盘(有IO参与,降低效率没必要),因为有些文件只会在内存中存在,不会在磁盘中存在。
2.2 匿名通道
管道只能进行单向通信。匿名通道适合具有血缘关系(如爷孙)的进程进行进程间的通信,常用于父子。
#include <unistd.h>
功能:创建一无名管道
原型
int pipe(int fd[2]); // 父进程调用pipe
// fd[]数组是一个输出型参数
/*
int pipe(int fd[2])
{
fd[0] = open(read);
fd[1] = open(write);
一个进程打开同一个文件,用读方式和写方式打开,用下标0和1把文件描述符保存起来后把两个文件描述符返回。
}
*/
参数
fd:文件描述符数组,其中fd[0]表示读端, fd[1]表示写端
返回值:成功返回0,失败返回错误代码
fd[0]表示读端,读文件描述符
fd[1]表示写端,写文件描述符
父子进程可以通过创建全局缓冲区来完成通信吗?——不行。进程运行的时候具有独立性,写入的时候会有写时拷贝,因此一方对全局缓冲区进行的修改,另一方无法看到。实际上共享资源是通过OS完成的。
管道内部自动提供同步与互斥机制。
互斥:任何时候只能够有一个人去使用某种资源。
同步:一个等另一个。
如果写端关闭,读端就会read返回值0,代表文件结束
如果打开文件的进程退出了,文件也就会被释放掉。
2.3 管道的特点
2.3.1 管道提供流式服务
流式服务指的是管道传输的数据看作是一个连续不断的字节流,没有自动的消息边界。你写进去的数据会像一条连续的水流一样进入管道,而读取数据时需要应用程序自己识别何处是数据的开始和结束。
1、数据是连续的
连续性:
管道中的数据被看作是一个连续不断的字节流,没有明确的“消息”或“包”的边界。设想你有一条水管,你把水通过这个水管输送到另一个地方。水在水管中是连续流动的,你无法从水管中看到“第一股水”和“第二股水”的界限,它们自然地混合成一条连续的流。
没有自然分隔:
如果你需要在传输的数据中保留某种“边界”或“区分”,就需要在写入数据时自行添加特定的分隔符或协议。就像你往水中加入了颜色,如果你希望在接收端分辨出每次加入的颜色,就需要在颜色混合前后做标记(比如在颜色中添加不同的染料),否则接收方看到的只是混合后的连续水流,而不清楚每次注入的分界。
2、如何理解流式服务?
没有单独的信息单元:
在管道中,写入的数据不会自动按照“块”或“消息”来区分。应用程序需要自己知道如何将连续的数据拆分、组合或识别边界。
类比:
想象你用打字机打字,所有的字母连续排列在一起,没有自然的单词间隔(除非你自己按下空格键)。如果你不加空格或标点,接收者就只能看到一长串字符,无法直接识别每个单词的开始和结束。
适用场景:
流式服务适用于数据连续生产、实时传输的场景,比如音频、视频流或者实时日志传输。在这种情况下,数据不断地流过,而接收端会按“时间顺序”处理这些数据。
对比消息队列:
如果你需要的是每次写入的内容都作为一个独立的信息单元处理,那么你可能需要使用消息队列机制(message queue),这种机制会保持每条消息的完整性,而不是把所有数据当作一个连续的流。
2.3.2 管道读写操作及模式
1、读操作(read)
阻塞模式(默认):
当管道没有数据时,调用 read 会让进程暂停,直到有数据写入。
类比:
就像你到水龙头取水,发现暂时没水流出来,那么你就会一直站在那里等待,直到水流开始。
非阻塞模式:
如果设置了 O_NONBLOCK 标志,调用 read 时若无数据,立即返回错误(返回 -1),并设置错误码(errno 为 EAGAIN),表示暂时没有数据,但进程不会等待。
类比:
就像你去取水时,如果发现没有水,就马上离开去做别的事,而不是一直等待。
所有写端关闭的情况:
如果所有写端都关闭了,再调用 read 会返回 0,这表示数据已经全部传输完毕,不会再有新的数据(类似于水管停止供水)。
2、写操作(write)
阻塞模式(默认):
当管道缓冲区满时,调用 write 会让进程暂停,直到有足够空间接收新的数据。
类比:
你想往一个已经装满水的水桶里倒水,倒水的动作会停下来,直到水桶中的水被取走腾出空间。
非阻塞模式:
设置 O_NONBLOCK 后,如果管道满了,write 立即返回错误(返回 -1),并设置错误码(errno 为 EAGAIN),不等待空间释放。
类比:
你发现水桶已满,就不再试图倒水,而是马上停止操作。
所有读端关闭的情况:
如果没有任何进程在读取管道中的数据,写操作会产生 SIGPIPE 信号。默认情况下,收到此信号的进程会终止运行,就像你往一个根本没有接水口的容器中倒水,系统会认为这是一个错误并采取保护措施。
当管道中没有数据或管道中没有空间 对应的进程就会被挂起。
若父进程将读端关闭,子进程写端一直写,会发生什么?——子进程会被OS杀掉。
3、只进行读操作(没有写入)
现象:
如果一个进程只调用 read,但没有其他进程向管道中写入数据,那么调用 read 时会发现管道中没有数据可供读取。即不write一直read,会read阻塞。
结果:
阻塞:默认情况下(未设置非阻塞标志 O_NONBLOCK),read 调用会阻塞进程,即进程会一直等待,直到有数据进入管道后才返回。
原因:
操作系统设计上希望读取操作能获取到数据。如果没有数据,进程进入睡眠状态等待写入数据。
4、只进行写操作(没有读取)
现象:
如果一个进程持续调用 write,而没有其他进程读取管道中的数据,那么随着数据不断写入,管道内部缓冲区会逐渐填满。不read一直write,会write阻塞。
结果:
阻塞:当管道缓冲区满时,默认情况下(阻塞模式)write 操作会阻塞进程,直到有空间可写。
原因:
内核为了保证数据完整性和顺序,当缓冲区没有足够空间时,会让写进程等待,直到其他进程读取了部分数据,从而释放空间。
5、写端写完并关闭后,读端会返回0
现象:
当所有写入端都关闭后(例如写入完毕后进程调用了 close 关闭写端),继续调用 read 的进程会返回 0。即write写完,关闭后,read会返回0(这表示管道中不再有数据,而且以后也不会再有数据,因为写端已经全部关闭。)
结果:
返回0(EOF):这表示管道中不再有数据,而且以后也不会再有数据,因为写端已经全部关闭。
意义:
读端通过返回 0 来判断数据传输已经结束,从而可以结束读取操作或执行其他逻辑。
6、读端关闭后继续写,写方会被杀掉
现象:
如果管道的读端已经关闭,但仍有进程(通常是子进程或写端进程)试图向管道写入数据,操作系统会对写操作做出特殊处理。即read关闭,一直写,写方(child)会被OS直接杀掉(子进程退出,代码没有运行完收到信号后异常退出),写入无意义。
结果:
触发 SIGPIPE 信号:写操作会收到 SIGPIPE 信号。默认情况下,这个信号会导致进程终止。
意义:
这是一种保护机制,防止数据写入一个没有任何接收者的管道,因为写入的数据将无法被使用,所以 OS 会终止写进程以避免资源浪费和逻辑错误。
注意:
写进程可以通过捕获或忽略 SIGPIPE 信号来防止被杀掉,但这样写入的行为通常也就没有实际意义,因为没有读端来处理数据。
2.3.3 原子性写入与 PIPE_BUF
原子写入:
内核保证当一次写入的数据量不大于 PIPE_BUF(PIPE_BUF 是操作系统中定义的一个常量,用于规定在管道进行写操作时能够保证原子性的最大字节数)时,这个写操作是原子性的,即:写入的所有数据会一次性存入管道,其他进程不会看到数据被分割开。
类比:
如果你一次倒入的水量很小(比如用小杯子倒水),那么你倒进去的一整杯水会被完整地接收,不会和别人倒入的水混在一起。
超过 PIPE_BUF 的写入:
如果一次写入的数据量超过了 PIPE_BUF,内核就不再保证一次性写入,数据可能被拆分、与其他进程的数据交叉混合。
类比:
假如你用大水桶一次倒很多水,由于水桶容量和接收速度的限制,你的水流可能和别人的水流混在一起,接收端就难以分辨哪些水是一次性倒入的。
2.3.4 同步与互斥机制
操作系统内核在管理管道时内置了同步和互斥机制,确保数据传输的安全和顺序:
互斥(Mutual Exclusion):
内核保证同一时刻只有一个进程可以对管道的缓冲区进行写操作或读操作,从而避免同时操作导致数据混乱。
类比:
就像一座单车窄桥,一次只能让一辆车通过,避免同时通过时发生碰撞。
同步(Synchronization):
管道的操作自动实现了数据生产者(写数据进程)和消费者(读数据进程)之间的协调:
当管道满时,生产者(写进程)会等待,直到消费者取走一些数据腾出空间。
当管道空时,消费者(读进程)会等待,直到生产者写入数据。
类比:
想象有一位送水员和一位取水员:
如果水桶满了,送水员就得暂停送水,等待取水员把水取走。
如果水桶空了,取水员就得等送水员送水,而不会不断地检查水桶。
2.3.5 管道的局限性与适用场景
局限性:
亲缘关系限制:
管道只能在具有共同祖先(例如父子进程)之间使用,独立的、无关联的进程无法直接共享同一个管道。
单向传输:
一个管道只能实现单向数据传输。如果需要双向通信,就必须创建两个管道,分别用于不同方向的数据传输。
生命周期短:
管道的存在周期与进程相关,进程退出后管道也会自动关闭,不适合需要长期保存数据的场景。
适用场景:
简单的父子进程之间的数据交换,如父进程生成数据,子进程处理数据。
实时流数据传输,比如日志、数据监控或简单的命令传递等。
2.4 命名管道
管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道。
命名管道是一种特殊类型的文件。
命名管道(Named Pipe)和 匿名管道(Anonymous Pipe)都是在操作系统中用于进程间通信(IPC)的一种机制,它们的主要区别在于 是否具有名称 和 使用场景的灵活性,但是它们都基于同样的基本原理:通过一个内核缓冲区进行数据传输。
之前谈到的匿名管道特点如下:
无名称:
匿名管道没有文件路径,它是由进程在内存中创建并使用的临时通信通道。因为没有名称,所以它只能在具有亲缘关系的进程之间(例如父子进程)进行通信。
匿名管道的生命周期通常由创建它的进程决定。当父进程创建管道并 fork() 出子进程时,管道的文件描述符会被子进程继承。管道在父子进程间传递,并随着进程的退出而销毁。
单向通信: 默认情况下,匿名管道是单向的,即只能进行单方向的数据流动。如果需要双向通信,必须创建两个管道。
限制: 仅限于具有共同祖先的进程之间使用。即,它通常是由父进程创建,子进程继承文件描述符后进行通信。不能用于独立进程之间的通信。
匿名管道是通过创建子进程,让不同进程看到同一份资源,而命名管道是通过文件名的方式,让不同进程看到同一份资源。
命名管道(Named Pipe 或 FIFO)有名称:
命名管道在文件系统中有一个与普通文件类似的路径,可以通过该路径访问。与匿名管道不同,命名管道是由文件系统中的特殊文件表示的,因此它不依赖于进程之间的亲缘关系。
独立进程通信:
命名管道允许没有父子关系的进程之间进行通信。只要进程知道命名管道的路径,就可以通过打开该路径来与其他进程进行通信。即使这些进程没有亲缘关系,它们仍然可以通过共享管道路径进行数据交换。
可以双向通信:
虽然命名管道是单向的,但你可以通过创建两个命名管道实现双向通信。每个管道处理一个方向的数据流动。
生命周期与路径相关:
命名管道的生命周期与创建它的路径文件相关。当进程关闭管道时,命名管道本身仍然存在,直到程序显式删除该路径文件。也就是说,命名管道可以在多个进程之间长期存在。
使用场景:
命名管道通常用于没有亲缘关系的进程之间的通信,特别是在不同的程序之间需要传递数据时。
2.4.1 命名管道的创建
命名管道可以从命令行上创建,命令行方法是使用下面这个命令:
创建一个命名管道
$ mkfifo filename
ll中显示的p开头——命名管道
命名管道也可以从程序里创建,相关函数有:
#define p2 "myfifo"
int mkfifo(const char *filename,mode_t mode);
int main(int argc, char *argv[])
{
mkfifo("p2", 0644); // 0644为权限 若mkfifo返回值小于0,则说明创建失败
return 0;
}
典型的应用场景包括:
一个进程(比如服务器)写数据到命名管道,另一个进程(比如客户端)从中读取数据;系统中的守护进程通过命名管道与其他程序进行数据交换。
总结
匿名管道:无名称,生命周期随进程,通常用于父子进程间通信,适合临时、简单的进程间数据传输。
命名管道:有名称,存在于文件系统中,生命周期与路径相关,适合不同进程间的长期或跨程序通信。
进程间通信的意义:让多个进程协同完成某种事情:执行命令、发送字符串等。
2.4.2 匿名管道与命名管道的区别与联系
匿名管道由pipe函数创建并打开。
命名管道由mkfifo函数创建,打开用open
FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。
工作原理相似:
匿名管道和命名管道的核心机制是相同的,都是通过内核提供的缓冲区来存储数据,并且使用文件描述符来读取和写入数据。它们的操作(read 和 write)都是基于内核缓冲区进行的,只不过命名管道有一个路径,而匿名管道则依赖于进程继承机制。
管道类型的选择:
在设计进程间通信时,选择匿名管道还是命名管道通常取决于进程间的关系:
如果进程有亲缘关系(例如父子进程),通常使用匿名管道,因为它简洁高效,且无需额外的文件路径管理;
如果进程没有亲缘关系(例如不同程序),通常选择命名管道,因为它可以跨进程、跨程序使用,并且有一个文件路径可以在进程间共享。
创建命名管道:
if (mkfifo("/tmp/myfifo", 0666) == -1) {
perror("mkfifo");
exit(1);
}
使用 mkfifo() 创建一个命名管道,路径 /tmp/myfifo 会作为管道文件存在。
使用命名管道: 一旦命名管道创建,可以像普通文件一样打开:
int fd = open("/tmp/myfifo", O_WRONLY); // 写入
write(fd, "Hello", 5);
close(fd);
文件拷贝的实现:
$ cat file.txt | grep hello
这里的管道是匿名还是命名的?
匿名管道
bash fork第一次执行cat、fork第二次执行grep;
$ sleep 1000 | sleep 2000 | sleep 3000
# 此处的 sleep 1000 、 sleep 2000 、 sleep 3000具有血缘关系
# 父进程都是bash PPID相同
补充:
为什么Bash() 是默认的 PPID?
当你打开一个终端窗口并登录到系统时,系统通常会启动一个 Shell 进程(通常是 Bash)。这个 Bash 进程会成为你打开的任何其他进程的父进程,也就是说,默认情况下,它会拥有 PPID 为 Bash 进程的所有子进程。
其在 Linux 中,Bash 是 Bourne Again SHell 的缩写,它是一个流行的命令行界面和脚本语言解释器。Bash 是 Linux 系统中最常用的 shell,通常用作用户与操作系统交互的主要界面。Shell 是一个命令行解释器,充当用户与操作系统之间的桥梁。它接受用户输入的命令并将这些命令传递给操作系统执行。Bash 是 Linux 中的默认 shell。在 Linux 系统中,Shell 可以用于:执行命令、编写和执行脚本、管理系统资源、启动和控制进程。
举个例子:
当你在终端输入命令时,Bash 作为父进程会启动该命令(这会创建一个新的子进程)。
假设你运行一个命令 ls,那么 ls 进程的父进程就是 Bash,因此 ls 的 PPID 就是 Bash 的进程 ID。你可以使用 ps 或 top 等命令查看当前进程的 PPID。
$ ps -ef
UID PID PPID C STIME TTY TIME CMD
user 1234 5678 0 10:00 pts/0 00:00:00 bash
user 2345 1234 0 10:01 pts/0 00:00:00 ls
由此可见:
bash 的 PID 是 1234,它的 PPID 是 5678(这表示 bash 是由 PID 为 5678 的进程启动的)。
ls 的 PID 是 2345,它的 PPID 是 1234(表示 ls 是由 bash 启动的)。