Linux学习进程间通信——匿名管道 | 命名管道

Posted 一只大喵咪1201

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux学习进程间通信——匿名管道 | 命名管道相关的知识,希望对你有一定的参考价值。

🐱作者:一只大喵咪1201
🐱专栏:《Linux学习》
🔥格言:你只管努力,剩下的交给时间!

从今天开始,Linux的代码就切换在VScode上写了,总算告别VIM了,而且编程语言也开始使用C++了。

进程间通信——匿名管道 | 命名管道

🌞进程间通信

什么是进程通信:

进程间通信是两个或者多个进程之间进行通信,行为如下:

  • 数据传输:一个进程需要将它的数据发送给另一个进程。
  • 资源共享:多个进程之间共享同样的资源。
  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
  • 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

为什么要有进程通信:

  • 在操作系统中,进程是独立运行的程序,它们之间需要相互协作完成任务。进程间通信的目的是为了实现进程之间的数据共享、协作和同步,从而提高系统的效率和可靠性。

说白了就是,很多场景下需要多个进程协同工作来完成要求。


两个进程cat和grep协同工作,将log.txt文件中带有hello的文字显示出来。

如何进行进程通信:

当前主要是通过三种策略来实现进程间通信的:

  1. 管道:通过文件系统通信。
  • 匿名管道
  • 命名管道
  1. System Ⅴ:聚焦在本地通信。
  • 共享内存
  • 消息队列
  • 信号量
  1. POSIX:让通信可以跨主机。
  • 共享内存
  • 消息队列
  • 信号量
  • 互斥量
  • 条件变量
  • 读写锁

每一种策略下都有很多种通信方式,在这篇文章中本喵详细讲解管道策略的通信方式。

进程通信的本质:

我们知道,进程是相互独立的,所以进程之间的通信成本肯定不低。

为了进程在通信的时候,既能满足进程之间的独立性,又能够到达通信的目的,那么进程之间通信的地点就不能在两个进程中。

  • 一个进程将自己的数据交给另一个进程,并且还要等待另一个进程的应答,这样一来,这个进程将不独立了,受到了另一个进程的影响,与进程的独立性矛盾。

所以,两个进程进行通信的地点必须是由第三方提供的,第三方只能是操作系统。操作系统提供的这个地点被我们称为:公共资源

公共资源有了,还必须让要通信的进程都看到这一份公共资源,此时要通信的进程将有了通信的前提。之后就是进程通信,也就是访问这块公共资源的数据。

之所以有不同的通信方式,是因为公共资源的种类不能,如果公共资源是一块内存,那么通信方式就叫做共享内存,如果公共资源是一个文件,也就是struct file结构体,那么就叫做管道

🌞管道

首先我们来回忆一下文件系统。


父进程打开一个文件,操作系统在内存上创建一个struct file结构体对象,里面包含文件的各种属性,以及对磁盘文件的操作方法。

  • 每个struct file对象中还有一个内核缓冲区,这个缓冲区中可以存放数据。

当子进程创建的时候,父进程的文件描述符表会被子进程继承下去,所以此时子进程在相同的fd处也指向父进程打开的文件。

  • 文件描述符表一个进程维护一个,但是struct file结构体对象在内存中只有一个,由操作系统维护。

此时,父子进程将看到了同一份公共资源,也就是操作系统在内存中维护的struct file对象,并且父子进程也都和这份资源建立了连接。

此时父子进程通信的基础有了,它们就可以通信了。

  • 父进程向文件中写内容,写完后继续干自己的事,并不破坏父进程的独立性。
  • 子进程向文件中读内容,读完后继续干自己的事,并不破坏子进程的独立性。

这样一读一写,父子进程将完成了一次进程间通信。

而我们又知道,对文件进行IO操作时,由于需要访问硬盘,所以速度非常的慢,而且我们发现,父子间进行通信,磁盘中文件的内容并不重要,重要的是父进程写了什么,子进程又读到了什么。

  • 此时操作系统为了提高效率,就关闭了内存中struct file和硬盘中文件进行IO的通道。
  • 父进程写数据写到了struct file的内核缓冲区中。
  • 子进程读数据从struct file的内核缓冲区中读取。

此时,父子间通信仍然正常进行,并且效率还非常的高,而且还没有影响进程的独立性。而这种不进行IO的文件叫做内存级文件

这种由文件系统提供公共资源的进程间通信,就叫做管道


进程A和B就通过管道建立起了连接,并且可以进程进程之间的通信。而管道又分为匿名管道和命名管道。

⭐匿名管道

  • 匿名管道:顾名思义,就是没有名字的文件(struct file)。
  • 匿名管道只能用于父子进程间通信,或者由一个父进程创建的兄弟进程之间进行通信。

现在我们知道了匿名管道就是没有名字的文件,通过管道进行通信时,只需要通信双方打开同一个文件就可以。

我们通过系统调用open打开文件的时候,会指定打开方式,是读还是写。

  • 当父进程以写方式打开一个文件的时候,创建的子进程会继承父进程的一切。
  • 此时子进程也是以写的方式打开的这个文件。

既然是通信,势必有一方在写,一方在读,而现在父子双方都是以写的方式打开,它们怎么进行通信呢?

  • 父进程以读和写的方式打开同一份文件两次。


此时的管道文件分为写端和读端,并且写端和读端各会返回一个文件描述符fd。所以父进程的文件描述符表中,和管道文件有关的文件描述符fd就有两个。

这样一来,创建子进程后,父子进程都可以对管道进行读和写,它们就可以进行通信了,上面的问题就解决了。

之所以命名为管道,那么就有和管道类似的性质。在生活中,我们对水管,它的流向只能是单向的,管道也一样,通过管道建立的通信只能进行单向数据通信

  • 是因为通过内存级文件通信的方式具有这种特点,才命名的管道。
  • 而不是先命名管道才设计的内存级文件通信方式。


如上图,假设父进程对管写,子进程对管道进行读。

  • 为了防止父进程对管道进行误读,以及子进程对管道进行误写,破坏通信规则。
  • 将父进程的读端关闭,将子进程的写端关闭,使用系统调用close(fd)。

此时,父子进程之间的单向数据通信就建立起来了,下一步就可以进行通信了。

如果想进行双向通信,可以建立两个管道。

建立管道的系统调用pipe

上面都是理论上的,具体到代码中是如何建立管道的呢?既然是操作系统中的文件系统提供的公共资源,当然是用系统调用来建立管道了。

  • 形参:int pipefd[2]是一个输出型参数,是一个数组,该数组只有两个元素,下标分别为0和1。
  • 下标为0的元素表示的是管道读端的文件描述符fd。
  • 下标为1的元素表示的是管道写端的文件描述符fd。

使用系统调用pipe,直接就会得到两个fd,并且放入父进程的文件描述符表中,不用打开内存级文件两次。

  • 返回值:int类型的整数,对管道创建情况进行反馈。
  • 返回0,表示管道创建成功。
  • 返回-1,表示管道创建失败,并且会将错误码自动写入errno中。

那么,父进创建管道以后,得到的两个文件描述符是多少呢?是3和4吗?我们代码中来看。

#include <iostream>
#include <cerrno>
#include <cstring>
#include <unistd.h>

int main()

    int fds[2];
    int ret = pipe(fds);
    if(ret < 0)
    
        std::cerr<<errno<<":"<<strerror(errno)<<std::endl;
    
    std::cout<<"fds[0]: "<<fds[0]<<std::endl;
    std::cout<<"fds[1]: "<<fds[1]<<std::endl;

    return 0;

当管道创建失败的时候,进行报错,并且显示错误码和错误信息。


可以看到,创建管道后返回的两个fd值,果然是3和4,因为0,1,2分别被stdin,stdout,stderr占用。

知道了如何使用系统调用创建管道以后,接下来就创建子进程,然后关闭不需要的端口了,原理已经清楚,直接看代码。


为了方便,我们让父进程进行读,子进程进行写。

  • 子进程第一件要做的事情就是关闭读端。
  • 父进程在创建好子进程以后,第一件要做的事情就是关闭写端。

此时在代码层面上, 父子双方就已经建立了连接了,接下来就是通信数据了。

通信代码及演示

子进程通信代码:

将上面子进程通信代码处的sleep(5)换成如上图所示代码。每隔一秒钟,子进程向父进程发生一个字符串,包括子进程的pid,以及发送次数。

  • 为了保证通信的正确,使用snprintf来严格控制发送数据,将数据写入到buffer中。
  • 向管道中写入,使用的是系统调用write。

父进程通信代码:

  • 向管道中读取,使用的是系统调用read。

父子进行通信现象:


每隔一秒,父进程读取一次管道中的数据。

匿名管道的读写特征

  1. 读慢,写快

  • 子进程循环不停向管道中写入数据。
  • 每写入一次会打印一句写入完成,并且打印出子子进程的pid。

  • 父进程只读取一次
  • 读取完成后阻塞不动,1000s后再读取一次

此时就模拟出了,向管道中写入非常快,而从管道中读取则很慢,每隔1000读取一次。

  • 子进程在快速的多次写入以后就不动了,因为管道此时已经被写满了,所以子进程状态是S+,处于阻塞状态。
  • 父进程由于在sleep,也处于是阻塞,读取一次管道数据后就没有再读取。

根据上面现象可以看出,管道中的数据没有被读取端读走时,写入端在将管道写满以后会停止写入,为了防止管道中的数据被覆盖。

结论:写入快,读取慢,write调用阻塞,直到有进程读走数据

  1. 读快,写慢

  • 子进程写入一次后延时1000s,阻塞不动。

  • 父进程快速读取,没有时间间隔。
  • 在读取之前打印开始读取,读取之后打印读取结束。

此时就模拟出,读取快,写入慢的场景。

  • 子进程向管道中写入一次后延时阻塞不动。
  • 父进程将子进程一次写入到数据完整读取,如上图中红色框所示。
  • 父进程在进行第二次读取的时候,只打印了开始读取,没有读取结束,阻塞在了读取处,如上图中绿色框所示。

根据上面现象可以看出,写入端没有向管道中写入数据的时候,读取端会阻塞,等待数据写入然后再读取。

结论:写入慢,读取块,read调用阻塞,即进程暂停执行,一直等到有数据来到为止。

  1. 写入端关闭,读取端未关闭

  • 子进程每隔一秒向管道中写入一次数据。
  • 写入十次以后停止写入,并且关闭子进程的写端。

  • 父进程不停的从管道中读取数据。
  • 当read返回值为0的时候,表示读到了文件结尾,也就是管道中数据读完了,并且不会再有了。
  • 此时停止读取,并且关闭读端。

  • 子进程写入十次后,关闭写端。
  • 父进程中read的返回值为0,并且不会阻塞在read处。

根据上面的现象,当管道的写入端关闭时,读取端也就没有等待的打开的必要了,所以read就会返回0,表示管道的写入端已关闭。

结论:管道写端对应的文件描述符被关闭,则read返回0

  1. 写入端未关闭,读取端关闭

  • 子进程隔一秒向管道中写入一次,一只重复。

  • 父进程读取10次,然后关闭读端。
  • 再进行进程等待,并且查看子进程的退出信号。

  • 父进程读取10次以后,关闭读端。
  • 进程等待子进程,子进程的进程结束信号是13.
  • 信号13是一个管道信号,具体是什么以后再说。

根据上面现象,当管道的读取端关闭后,操作系统会自行结束管道的写入端,因为此时写入端的存在已经没有意义了。

结论:管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,让write进程退出

总结一下管道的读取特征:

场景特征
读取慢,写入快写入端阻塞在write处
读取快,写入慢读取端阻塞在read处
读取端关闭操作系统终结写端
写入端关闭读取端read返回0

管道之所以有这样的读取特征,其实是为了对管道中的数据进行保护,这种方式称为互斥,后面本喵会详细讲解这一概念。

匿名管道本身也有它自己的特征,如下:

  • 管道的生命周期随进程的结束而结束,当所有进程都关闭该管道的文件描述符时,管道被销毁。
  • 管道可以用来进行具有血缘关系的进程之间进行通信,常用于父子进程通信。
  • 管道是半双工的通信方式
  • 管道是面向字节流的(在网络部分讲解)。
  • 管道有互斥与同步机制对共享资源进行保护(以后讲解)。

⭐命名管道

  • 命名管道:顾名思义,有名字的管道(内存级文件)。

根据前面的学习,我们知道,父子进程间使用匿名管道的方式进行通信,是通过子进程继承父进程的方式来实现,而且匿名匿名管道常用于父子进程直接,或者由血缘关系的进程直接。

那么,如果两个进程毫无关系呢?此时就不能继承了,那这两个进程如何建立连接呢?

还是采用管道的方式,但是这个管道是有名字的管道,这样一来,两个进程就可以打开同一个管道文件建立连接。


还是这张图,此时内存中的struct file在磁盘上有对应文件的,如上图中的fifo.ipc文件。

  • 指令:mkfifo 文件名
  • 功能:创建命名管道文件

如上图所示,此时就创建了一个命名管道,可以看到,文件类型是p,而且该文件还有inode,说明在磁盘上是真实存在的。

当磁盘中有了命名管道文件以后,两个进程将可以通过这个管道文件进行通信了,步骤和匿名管道非常相似。

  • 一个进程以写方式打开管道文件。
  • 另一个进程以读端方式打开管道文件。

此时两个进程将建立了连接,然后将可以进行通信了。

我们知道,进程间通信的前提是,要通信的进程能够看到同一份公共资源,那么命名管道是如何做到这一点的呢?

  • 让不同的进程打开指定路径下同一个管道文件。

路径 + 文件名 = 唯一性

所以说,命名管道是通过利用这种唯一性来让要通信的进程都看到这块内存级文件的。

命名管道的系统调用mkfifo/unlink

创建管道文件:
可以在shell中通过命令的方式创建管道文件,两个进程直接去使用它。也可以像文件一样,在进程中创建管道文件,此时就需要用到系统调用。

  • 第一个形参:管道文件的名字
  • 第二个形参:创建管道文件的权限
  • 返回值:0表示创建成功,-1表示创建失败。


为了使用方便,将创建管道文件的步骤封装在一个函数中。

  • 虽然管道文件属于公共资源,并且是由操作系统维护的。
  • 但是它也得有人去创建。通信双方必须有一方创建管道文件,

此时就有了这样一个管道文件,结果和使用命名mkfifo的结果是一样的。


再次运行程序,就会报错,管道文件已经存在,所以说,如果管道文件已经存在了,就没有必要再使用系统调用mkfifo。

删除管道文件:

向管道文件中写如数据,这些数据是不会IO到磁盘中的。


管道文件的大小为0,说明此时文件中是没有内容的。


向管道文件中疯狂写入数据。


让程序开始疯狂向管道文件中写入内容,再查看管道文件,发现文件的大小没有变化。

和匿名管道一样,向命名管道写文件时,不会和磁盘进行IO,而是将数据写到了struct file结构体的缓冲区中,数据写入了内核中。

  • 这样看来,命名管道文件我们能不能看到不重要。
  • 可以在使用完管道文件以后,再将管道文件删除。

  • 形参:要删除的管道文件名称(路径加名字)
  • 返回值:删除成功返回0,失败返回-1。


在管道文件的维护方,当使用完毕后,将管道文件删除。

此时在完成通信以后,我们也不会看到管道文件,它的创建与删除都由通信的某一方来维护。

通信代码及演示

我们创建两个进程进行通信,一方叫做sever,另一方叫做client,sever负责创建管道文件,并且从管道中读取数据,client负责向管道中写入数据。

sever.c代码:


创建好管道文件以后,使用系统调用open以写的方式打开文件,再通过系统调用read读取管道中的数据。

client.c代码:


在server.c创建好管道文件以后,再使用open以写方式打开管道文件,再通过write将从键盘上获取的数据写入到管道文件中。

运行效果:


client输入什么,sever就输出什么,此时两个无关的进程就成功进行了通信。

  • 必须先运行sever,再运行client,因为sever要创建管道文件,只有管道文件创建了以后才有进行通信的前提。

有了匿名管道的基础,命名管道就很简单了,不同之处只在于需要创建管道文件和打开管道文件,而匿名管道的pipe系统调用直接就将管道文件创建好并且打开了。其他的操作都一样。

命名管道读写特征

命名管道和匿名管道在那4个方面的读写特征是完全一样的,也是存在互斥机制的。除此之外还有一些其他的特点。

  1. 读端要打开,写端未打开

  • 在读端代码中,在open操作前后分别打印一句话。
  • 只执行server(读端),不执行client(写端)。
  • 只有读端要打开管道文件,写端不打开。

根据现像,此时server不再执行了,阻塞在了这里。

结论:当读端要打开管道,而写端没有打开时,会在读端的open处阻塞

  1. 写端要打开,读端未打开

  • sever端在创建文件后进行1000s延时,阻塞在这里。

  • 在写端open前后各打印一句话。


根据现象,写端执行到open打开管道文件的时候阻塞了。

结论:当写端要打开管道,而读端没有打开时,会在写端的open处阻塞

总的来说,进行通信的双方,一方没有打开管道文件,另一方在打开的时候就会阻塞在open处。

除了读写时的特征,命名管道本身也有特征:命名管道的生命周期随内核
我们可以看到,管道文件是可以直接在磁盘上存在的,和进程无关,这一点和命名管道不一样,其他的特征都一样。

匿名管道和命名管道的区别:

  • 匿名管道由pipe函数创建并打开。
  • 命名管道由mkfifo函数创建,打开用open,删除用unlink函数。
  • FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。

🌞进程池(小练习)

现在对管道已经有了一定的认识,下面喵给大家用匿名管道写一个进程池的小项目。

  • 如上图所示,一个父进程创建多个子进程,父进程和每个子进程之间通过管道相连接。
  • 子进程按照父进程发生过来的命令码执行相应的认为。
  1. 创建子进程及对应管道,并且维护通信关系

  • 创建子进程和管道

在循环中,每次都先创建匿名管道,然后再创建子进程,让子进程继承父进程对匿名管道读写端的文件描述符fd。

  • 子进程处理

子进程要做的第一件事情就是关闭对匿名管道写端的文件描述符fd。然后再执行具体的代码(后面再讲解具体怎么执行)。当每一个子进程执行完代码后,关闭对应的读端文件描述符fd,再进程退出。

  • 父进程保存子进程信息

父进程在创建好子进程后,第一件要做的事情就是关闭对应管道的读端文件描述符fd,然后再保存子进程的相关信息,方便后面进行控制。

要保存的子进程信息:

  • 首先就是子进程的pid,方便后面进行进程等待等操作。
  • 再就是和子进程通信管道的写端描述符fd,方便后面进行写数据。
  • 其次就是子进程的名字信息,这个是我们自己为了观察现象而设置的。
  • 还有就是子进程的编号,也就是计数器。

为了方便描述子进程的信息,我们创建一个结构体来描述子进程。


然后将子进程的信息对象放在vector中,这样就组织了起来,方便我们进行管理。

先描述再组织的思想。

  1. 父进程控制子进程
  • 任务制定

先制定一批让子进程执行的任务。


本喵这里粗略写了五个任务,如上图所示,每一个任务实际上就是一个函数。

为了方便管理这些任务,同样需要将它们组织起来。

//定义函数指针
typedef void(*func)();

将函数指针类型typedef为func。


然后将这些任务都放在vector中管理起来,此时vector的每一个元素都是一个任务的函数指针,而元素的下标就是子进程要接收的任务码

  • 分配任务

之所以让多个进程来执行任务,就是为了充分利用系统资源,提高效率,如果在一个子进程完成任务的期间,其他子进程在等待那就失去了意义。

  • 为了同时调度所有子进程,并且完成多种任务,采取随机指定子进程和随机指定任务的方式。
//获取随机数种子
#define MakeSeed() srand((unsigned int)time(nullptr) ^ getpid() ^ 0x123456 ^ rand() % 123)

首先生成随机数种子,种种子的方法很多,本喵只是随意写了一下。

  • 父进程给指定子进程发送任务码


使用系统调用write向匿名管道中写入命令码,对应的管道的写端文件描述符从类对象中获取,在创建的时候就保存了。

命令码就是存放函数指针的vector的下标,是int类型的整数,要严格控制写入管道的字节数,防止通信发生意外。

  • 任务执行完毕

如果执行的任务次数是有限次的,当任务被这些子进程执行完毕以后,需要关闭所有的写端描述符fd。这里存在潜在问题,一会儿详细讲解,但是不影响我们使用。

  1. 子进程执行任务


这部分代码在维护父子进程通信关系时出现过,现在本喵介绍下子进程执行任务的具体流程。

  • 接收命令码


使用系统调用read从匿名管道中读取父进程发来的命令码,并且严格检查是否是4个字节,如果是说明接收正确,返回命令码,如果不是,返回-1。

  • 执行任务

根据接收到的命令码,从存放函数指针的vector中找到对应的函数指针,调用对应的函数。

  1. 回收子进程资源


当所有任务被完成以后,父进程关闭所有管道的写端,子进程接收到的命令码就会是-1,然后子进程就会退出。

当父进程关闭写端以后就会进程等待,每等待成功一个子进程都会打印一句话,当所有子进程被等待成功后父进程退出。

  1. 潜在问题
  • 问题描述

上面在关闭所有的写端描述符时,本喵提到存在潜在问题,下面来分析一下:

  • 父进程的文件描述符表中存在一个管道的文件描述符,如上图所示的3。
  • fork出子进程后,会继承父进程的文件描述符表,再加上自己的文件描述符fd,此时的文件描述符表中就有两个fd,如上图所示的3和4。

  • 当父进程创建多个子进程时,就会创建多个管道,每增加一个子进程就会增加一个管道以及文件描述符fd。
  • 子进程又会继承父进程的文件描述符,尤其是最后一个创建的子进程,文件描述符表中有很多的fd,但是只有最后一个是属于自己和父进程通信的。

此时问题就来了,当父进程关闭一个写端文件描述符fd的时候,这个管道被关了码?

  • 很显然没有被关,因为子进程的文件描述符表中仍然有fd执行父进程关闭的管道。

我们的程序中,之所以没有出问题,是因为将父进程的所有写端被关闭时,所有的子进程对应那个管道的read会读取到0,然后子进程就会退出,它维护的文件描述符表也就销毁了,当所有文件描述符表被销毁后,就没有fd指向管道了,管道也就被回收了。

  • 解决问题

为了避免这个问题,每个子进程在创建后,不仅要关闭写端描述符,还要关闭从父进程继承下来的文件描述符fd。


专门创建一个vector,用来存放要被子进程删除的从父进程继承下来的写端fd。

  • 在父进程保存子进程信息的时候,将父进程的写端fd放入到专用的删除vector中。
  • 在子进程中,关闭vector中所有内容所对应的文件描述符fd,此时子进程的文件描述符表中就只剩下自己和父进程通信所用管道的fd了。

至此,我们进程池的代码就完美了。

⭐效果展示

本喵这里是创建了5个子进程,需要完成10次随机任务。


也可以用命名管道实现,只是需要由某一个进程负责创建命名管道文件和删除。要通信的其他进程也不用fork,需要自己写具体的进程,有兴趣的小伙伴可以自行尝试。

⭐附源码

#include <iostream>
#include <cassert>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <cstdlib>
#include <string>
#include <vector>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>

/****************************************用到的定义***************************************************/
#define PROCESS_NUM 5
//获取随机数种子
#define MakeSeed() srand((unsigned int)time(nullptr) ^ getpid() ^ 0x123456 ^ rand() % 123)

//定义函数指针
typedef void(*func)();

class subInfo

public:
    //构造函数
    subInfo(pid_t subid,int wfd)
    :_subid(subid)
    ,_wfd(wfd)
    
        char nameBuffer[1024];
        snprintf(nameBuffer,sizeof(nameBuffer),"子进程(%d)[pid(%d),fd(%d)]",_num,_subid,_wfd);
        _name = nameBuffer;
        _num++;
    
public:
    static int _num;//子进程数量
    std::string _name;
    pid_t _subid;//子进程pid
    int _wfd;//子进程对于管道写端fd
;

//域外初始化static变量
int subInfo::_num = 0;

/****************************************子进程要完成的任务***************************************************/
void downLoadTask()

    std::cout<<"我是子进程,pid:"<<getpid()<<", 执行任务:下载任务"<<std::endl;
    std::cout<<std::endl;
    sleep(1);


void ioTask()

    std::cout<<"我是子进程,pid:"<<getpid()<<", 执行任务:IO任务"<<std::endl;
    std::cout<<std::endl;
    sleep(1);


void flushTask()

    std::cout<<"我是子进程,pid:"<<getpid()<<", 执行任务:刷新任务"<<std::endl;
    std::cout<<std::endl;
    sleep(1);


void netTask()

    std::cout<<"我是子进程,pid:"<<getpid()<<", 执行任务:上网任务"<<std::endl;
    std::cout<<std::endl;
    sleep(1);


void dealTask()

    std::cout<<"我是子进程,pid:"<<getpid()<<", 执行任务:处理任务"<<std::endl;
    std::cout<<std::endl;
    sleep(1);   


void loadTaskToMap(std::vector<func>& funcMap)

    funcMap.push_back(downLoadTask);
    funcMap.push_back(ioTask);
    funcMap.push_back(flushTask);
    funcMap.push_back(netTask);
    funcMap.push_back(dealTask);


/****************************************进程池核心代码***************************************************/
//子进程接收任务码
int recvTask(int rfd)

    int code = 0;
    ssize_t ret = read(rfd,&code,sizeof(int));
    if(ret==

Linux进程间通信

目录

进程间通信介绍

进程间通信的概念

进程间通信又称IPC(Inter-Process Communication),指多个进程之间相互通信,交换信息。

进程间通信目的

  • 数据传输: 一个进程需要将它的数据发送给另一个进程
  • 资源共享: 多个进程之间共享同样的资源。
  • 通知事件: 一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。
  • 进程控制: 有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

进程间通信分类

管道:匿名管道pipe、命名管道
System V IPC:System V 消息队列、System V 共享内存、System V 信号量。
POSIX IPC:消息队列、共享内存、信号量、互斥量、条件变量、读写锁。

进程间通信的本质

进程间通信的本质就是让不同的进程看到同一份资源。
在我们的生活中打电话、发邮件、用QQ聊天都叫做通信,通信的本质是传递信息。但是传递信息这种说法并不是很直观,站在程序员的角度,通信的本质其实叫做传递数据。
进程间能直接相互传递数据吗?
比如说父进程把数据给子进程,子进程处理完数据再交给父进程,这种操作在进程这里是不可能做到的。因为进程具有独立性,所有的数据操作都会发生写时拷贝。所以即使父子进程之间也无法直接相互传递数据,更别谈两个进程之间毫无关系了。
所以进程间通信直接两个进程是不可能通信的,一定要通过中间媒介的方式来进行通信。
因为进程之间是相互独立的,具有独立性,发生写时拷贝背后的含义就是两个进程毫无关系,所以要进行通信的前提条件是必须要想办法让不同的进程看到同一份资源。 所谓公共资源其实就是内存空间。这份内存资源通常是由OS提供的。

管道

什么是管道

管道是Unix中最古老的进程间通信的形式。我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”。

管道通信一共有两种通信方案,一种叫做匿名管道,一种叫做命名管道。这两种管道底层原理是基本一样的。

匿名管道

匿名管道的原理

匿名管道最典型的特征是供具有血缘关系(常见于父子)的进程进行进程间通信。所有的通信方式,尤其是进程间通信,首要任务必须是保证不同的进程看到同一份资源。
1.父进程创建管道
首先让父进程以读和写的方式打开同一个文件,可以理解为以读方式打开一次,以写方式打开一次。相当于父进程打开一个文件,它打开这个文件并不是只打开一次,而是以读方式打开和以写方式打开,这样的话以读方式打开文件返回一个文件描述符,以写方式打开文件返回一个文件描述符,这两个文件描述符指向同一个文件。这样就相当于有了读端有了写端,这个过程可以称之为创建管道的过程。什么叫做创建管道一句话概括:分别以读方式和以写方式打开两次同一个文件。

2.父进程fork出子进程
父进程fork创建子进程。子进程是一个独立的进程,有自己独立的pcb、地址空间、文件描述符表,代码和父进程共享,数据写时拷贝这些都没有错。结构确实子进程都是私有一份的,但是结构里面填的数据是以父进程为模板的。
所以曾经父进程提前打开的文件,子进程的3号和4号文件描述符也指向父进程刚刚打开的文件。
至此完成了进程间通信的第一个核心任务,保证不同的进程看到同一份资源。
而这份同样的资源指的通常是系统提供的一段内存区域。这段内存区域现在可以简单的理解成父进程可以通过3或4往管道当中读写,对应读写的数据就在这个文件的缓冲区里,子进程就可以直接通过读写从缓冲区中把数据读到子进程。
这里管道其实就是文件,只不过这个文件不需要在磁盘上把数据进行持久化保存。

3.父子进程各自关闭不需要的文件描述符
但是管道不管是匿名管道还是命名管道,只能进行单向数据通信。也就意味着要么父进程写子进程读,要么子进程去写父进程去读。总之一个管道只能进行单向数据通信,如果想双向通信可以建立多个管道。
管道只能进行单向通信,所以就要决定让谁读让谁写。
如果想让父进程读,就关闭父进程的写端,如果想让子进程读,就关闭子进程的写端。
如果想让父进程写,就关闭父进程的读端,如果想让子进程写,就关闭子进程的读端。

让父子进程各自关闭它们不需要的文件描述符来达到构建单向信道的功能。
为什么父子进程迟早要关掉一个,曾经要打开呢?
1.如果父进程只以读方式打开,子进程继承下去的文件描述符对应打开的文件也只是读方式,两个读不能通信。如果只以写方式打开,fork之后子进程被打开的文件是可写的,父子进程都只能写,这样不能通信。所以不打开rw,子进程拿到的文件打开方式必定和父进程一样,无法通信。
2.灵活的控制父子进程完成读写通信,到底是父进程读还是子进程写,完全取决于你的场景,所以把读写端都打开,你需要关哪个自己决定。
为什么一定要关闭呢?
即便是不关闭此时父进程写,子进程读,通信管道也在啊,为什么一定要关呢?
这样虽然可以不过建议是一定要关的,一方面是在语义上证明了管道单向通信这样的特性,另一方面主要是为了防止误操作。

pipe函数

pipe接口用于创建匿名管道。

#include <unistd.h>
int pipe(int pipefd[2]);

pipe函数的参数是一个输出型参数,输出型参数说人话就是我不想给你什么,我想通过调用你拿回来什么。
也就是说这个参数会通过调用pipe,拿到打开的管道文件的描述符。
这个数组一共有两个元素,意味着它一次拿到两个文件描述符,两个fd,一个是read,一个是write。
pipefd[0]:管道读端的文件描述符
pipefd[1]:管道写端的文件描述符
pipe函数调用成功时返回0,调用失败时返回-1。

pipe创建匿名管道实例

子进程向父进程写入5条数据,父进程从匿名管道中读取数据。

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#include <stdlib.h>

int main()

    int pipe_fd[2] = 0;   // 创建管道
    if(pipe(pipe_fd) < 0)
    
        perror("pipe");
        return 1;
    
    printf("%d, %d\\n", pipe_fd[0], pipe_fd[1]);
    
    pid_t id = fork();  // 父进程fork出子进程
    if(id < 0)
    
        perror("fork");
        return 2;
    
    else if(id == 0)    // child write
    
        close(pipe_fd[0]);   // 子进程写把不需要的读文件描述符关掉
        const char* msg = "hello parent, nice to meet you!"; 
        int i = 0;
        for(i = 0; i < 5; ++i)
        
            // 每隔1s向管道中写数据
            write(pipe_fd[1], msg, strlen(msg)); 
            sleep(1);
        

        close(pipe_fd[1]);  // 子进程发送完数据把不需要的写端关闭
        exit(0);
    
    else        // father read
    
        close(pipe_fd[1]);   // 父进程读把不需要的写文件描述符关掉
        
        char buffer[64];
        while(1)    // 父进程一直读,直到读完子进程发送的数据
        
            buffer[0] = '\\0';
            ssize_t size = read(pipe_fd[0], buffer, sizeof(buffer) - 1);
            if(size > 0)
            
                buffer[size] = '\\0';
                printf("father get msg from child# %s\\n", buffer);
            
            else if(size == 0)
            
                printf("pipe file close, child quit!\\n");
                break;
            
            else
            
                printf("read error!\\n");
                break;
            
        

        int status = 0;
        if(waitpid(id, &status, 0) > 0)
        
            printf("child quit, wait success!\\n");
        
        close(pipe_fd[0]);
    
    return 0;

结果:

管道读写规则

1.当没有数据可读时

  • O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
  • O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。

2.当管道满的时候

  • O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
  • O_NONBLOCK enable:调用返回-1,errno值为EAGAIN

3.如果所有管道写端对应的文件描述符被关闭,则read返回0。
4.如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出。
5.当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性。
6.当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性。

管道的特性

1.管道自带同步与互斥机制。 如果父进程是读端,子进程是写端。如果管道里面没有消息,父进程(读端)需要等待,等待子进程向管道内部写入数据。
如果管道里面写端已经写满了,不能继续写入,子进程(写端)就会等待,等待父进程把数据读走,管道内部有空闲空间再写入数据。
2.管道是单向通信的。 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道。
3.管道是面向字节流的。
4.管道只能保证具有血缘关系的进程进行通信,常用于父子。
5.管道可以保证一定程度的数据读取的原子性。
6.进程退出,曾经打开的文件也会被关掉,管道也是文件,管道的生命周期随进程。
管道的几种情况:
1.读端不读,写端一直写,写端会阻塞。
2.读端一直读,写端不写,读端会阻塞。
3.读端一直不读并且关闭了,写端一直写,写端被OS发送13号信号SIGPIPE杀掉。
4.读端一直读,写端不写并且关闭,读取到0,代表文件结束。

命名管道

命名管道的原理

磁盘上有各种各样的文件,每个文件都有唯一的路径。要让两个毫不相关的进程进行进程间通信,第一件事是要先保证这两个进程看到同一份资源。因为路径本身具有唯一性,文件本身就具有保存数据的能力,所以让进程1以读方式打开文件,进程2以写方式打开同一个文件。打开以后内存当中一定会包含对应的struct_file结构体以及该文件对应的内存缓冲区。所以此时进程2可以向文件中写数据,进程1也可以读取数据。磁盘文件的路径本身具有唯一性,可以让不同的进程分别以读和写的方式分别打开同一个文件,这样它们就能看到同一份资源了,就能进行数据通信了,这就是命名管道的原理。

1.命名管道也是管道,它也遵守管道的面向字节流,同步互斥,只能单向通信,生命周期随进程等等这些特点它都有,唯一和匿名管道不同的是它可以让不同的进程通信。
2.普通文件需要将数据刷新到磁盘上,持久化存储。但是管道文件不需要把数据刷新到磁盘。

创建一个命名管道

命名管道可以从命令行上创建,命令行方法是使用下面这个命令:

mkfifo filename


命名管道也可以从程序里创建,相关函数有:

#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *filename, mode_t mode);

第一个参数表示要创建的命名管道文件。以路径的方式给出,则将命名管道文件创建在指定路径下,以文件名的方式给出,则将命名管道文件默认创建在当前路径下。
第二个参数表示给要创建的命名管道设置权限。
返回值成功返回0,失败返回-1。

命名管道的打开规则

如果当前打开操作是为读而打开FIFO时

  • O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
  • O_NONBLOCK enable:立刻返回成功

如果当前打开操作是为写而打开FIFO时

  • O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
  • O_NONBLOCK enable:立刻返回失败,错误码为ENXIO

用命名管道实现server&client通信

server接受数据,client写数据。
server.c

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define FIFO "./fifo"

int main()

    int ret = mkfifo(FIFO, 0644);   // 创建命名管道文件
    if(ret < 0) 
        perror("mkfifo");
        return 1;
    
    
    int fd = open(FIFO, O_RDONLY);  // 以只读方式打开管道文件
    if(fd < 0) 
        perror("open");
        return 2;
    
        
    char buffer[128];
    while(1) 
        buffer[0] = '\\0';
        // 读取客户端发来的数据
        ssize_t s = read(fd, buffer, sizeof(buffer)-1);
        if(s > 0) 
            buffer[s] = '\\0';
            printf("client# %s", buffer);
         else if(s == 0) 
            printf("client quit...\\n");
            break;
         else 
            perror("read");
            return 3;
        
    

    close(fd);
    return 0;

client.c

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

#define FIFO "./fifo"

int main()
 
    int fd = open(FIFO, O_WRONLY);  // 以只写方式打开管道文件
    if(fd < 0) 
        perror("open");
        return 1;
    
        
    char buffer[128];
    while(1) 
        printf("Please Enter# ");
        fflush(stdout);
        buffer[0] = '\\0';
        // 从标准输入读数据
        ssize_t s = read(0, buffer, sizeof(buffer)-1);
        if(s > 0) 
            buffer[s] = '\\0';
            // 将从键盘读到的数据写到管道中
            write(fd, buffer, strlen(buffer));
         else if(s == 0) 
            break;
         else 
            perror("read");
            return 2;
        
    

    close(fd);
    return 0;

运行结果:

匿名管道与命名管道的区别

  • 匿名管道由pipe函数创建并打开。
  • 命名管道由mkfifo函数创建,打开用open
  • FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。

system V共享内存

共享内存的原理

OS在物理内存当中申请一块空间,这部分空间称为共享内存(其实就是一段内存空间)。OS把申请好的共享内存通过页表映射到进程A和进程B的共享区中(堆、栈之间)。这样这两个进程就可以使用各自的虚拟地址以及它自己的页表映射到同一块物理内存,这样两个不同的进程就看到了同一份资源。这就是共享内存的原理。

换言之,共享内存的建立有三个核心步骤:
1.申请共享内存
2.不同的进程分别挂接对应的共享内存到自己的地址空间(共享区)
3.双方就看到了同一份资源。即可以进行正常通信了。

共享内存数据结构

操作系统内部提供了通信机制的(IPC),ipc模块。在系统当中可能有很多进程进行通信,因此OS中可能存在大量的共享内存,操作系统要提供数据结构管理共享内存。
共享内存数据结构:

struct shmid_ds 
struct ipc_perm shm_perm; /* operation perms */
int shm_segsz; /* size of segment (bytes) */
__kernel_time_t shm_atime; /* last attach time */
__kernel_time_t shm_dtime; /* last detach time */
__kernel_time_t shm_ctime; /* last change time */
__kernel_ipc_pid_t shm_cpid; /* pid of creator */
__kernel_ipc_pid_t shm_lpid; /* pid of last operator */
unsigned short shm_nattch; /* no. of current attaches */
unsigned short shm_unused; /* compatibility */
void *shm_unused2; /* ditto - used by DIPC */
void *shm_unused3; /* unused */
;

其中shmid_ds结构体中套了一个结构体ipc_perm,在这个结构体中记录一个key用于标识系统中共享内存的唯一性。

struct ipc_perm

	__kernel_key_t	key;
	__kernel_uid_t	uid;
	__kernel_gid_t	gid;
	__kernel_uid_t	cuid;
	__kernel_gid_t	cgid;
	__kernel_mode_t	mode; 
	unsigned short	seq;
;

共享内存的创建

ftok函数

#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);

ftok函数使用由给定的路径名(必须引用现有的、可访问的文件)和 proj_id 的最低有效 8 位生成一个 key_t 类型的 System V IPC key值。
成功时,返回生成的 key_t 值。 失败时返回 -1。
该函数调用成功时返回的key值用于shmget系统调用的第一个参数。

shmget函数

申请共享内存的接口:

#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);

参数说明:

  • key:共享内存在系统当中的唯一标识。
  • size:想要申请的共享内存的大小。这个大小理论上可以任意去指定,但建议是4KB的倍数大小。

因为系统在分配共享内存的时候,是以4KB为基本单位的,即便你要4097,操作系统在分配的时候是4096+4096即8kb。但是你仍然只能用4097,就会浪费了4095个字节。所以建议4kb的整数倍,这样就不存在共享内存空间浪费的问题。

  • shmflg:表示创建共享内存的方式。

其中这个flg有两个重要选项:

选项作用
IPC_CREAT如果没有与key相等共享内存就创建,如果有就获取
IPC_EXCL如果存在与key相等共享内存就会出错,如果单独使用是没有任何意义的

如果这两个选项同时被设置,如果这个共享内存已经存在的话,这个函数就会出错返回。
它最大的意义在于如果此时调用shmget成功,一定得到的是全新的共享内存。
返回值:
成功时,返回一个有效的共享内存标识符。 失败时,返回-1。

共享内存的释放

在Linux中可以使用ipcs -m查看共享内存相关信息。

shmid vs key
key:是一个用户层生成的唯一键值,核心作用是为了区分共享内存的“唯一性”,不能用来进行IPC资源的操作。类似于文件的inode号。
shmid:是一个系统给我们返回的IPC资源标识符,用来进行操作IPC资源。类似于文件的fd。

1.可以使用ipcrm -m shmid命令释放指定id的共享内存资源。
2.可以使用系统调用shmctl。

#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

shmctl函数用于System V 共享内存控制。
第一个参数shmid:共享内存id
第二个参数cmd:如何控制共享内存
第三个参数buf:获取或设置所控制共享内存的数据结构
第二个参数cmd常用的选项:

选项作用
IPC_STAT从相关的内核数据结构中复制信息将 shmid 放入 buf 指向的 shmid_ds 结构中。调用者必须对共享内存有读权限。
IPC_SET将 buf 指向的 shmid_ds 结构的某些成员的值写入与此共享内存段关联的内核数据结构,同时更新其 shm_ctime 成员。
IPC_RMID标记要销毁的段。 该段实际上只有在最后一个进程将其分离后才会被销毁(即,当关联结构 shmid_ds 的 shm_nattch 成员为零时)。 调用者必须是段的所有者或创建者,或者具有特权。 buf 参数被忽略。

想要释放共享内存可以选第三个选项IPC_RMID。
返回值:
操作成功时返回 0,出错时返回 -1。

关联共享内存和去关联共享内存

shmat函数

shmat() 将由 shmid 标识的共享内存段附加到调用进程的地址空间。
如果 shmaddr 为 NULL,则系统选择一个合适的(未使用的)地址来附加该段。

#include <sys/types.h>
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);

第一个参数:共享内存创建完以后的id值。
第二个参数:想要把共享内存挂接到进程地址空间共享区的哪个虚拟地址上。如果设置成NULL,操作系统选择一个合适的地址。
第三个参数:关联共享内存时设置的某些属性。设置为0表示默认读写权限。
返回值:

成功时 shmat() 返回关联的共享内存段的地址,失败返回(void *) -1。

shmdt函数

#include <sys/types.h>
#include <sys/shm.h>
int shmdt(const void *shmaddr);

shmdt() 将位于由 shmaddr 指定的地址的共享内存段从调用进程的地址空间中分离出来。 要分离的段当前必须使用 shmaddr 附加,该值等于附加的 shmat() 调用返回的值。
返回值成功返回0,失败返回-1。

用共享内存实现server&client通信

头文件comm.h

#pragma once
#include <stdio.h>

#define PATH_NAME "/home/beichuan/upup/shm"
#define PROJ_ID 0x6666
#define SIZE 4096

server.c

#include "comm.h"
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

int main()

    // 创建key值
    key_t k = ftok(PATH_NAME, PROJ_ID);
    if(k < 0) 
        perror("ftok");
        return 1;
    
    
    // 创建共享内存
    int shmid = shmget(k, SIZE, IPC_CREAT | IPC_EXCL | 0644);  // 如果共享内存不存在,创建,存在出错返回
    if(shmid < 0) 
        perror("shmget");
        return 2;
    
    
    // 将当前进程和共享内存进行关联
    char* start = (char*)shmat(shmid, NULL, 0);
    
    // 使用共享内存进行通信
    while(1) 
        printf("%s\\n", start);
        sleep(1);
    

    // 将当前进程和共享内存去关联 
    shmdt(start<

以上是关于Linux学习进程间通信——匿名管道 | 命名管道的主要内容,如果未能解决你的问题,请参考以下文章

Linux进程间通信

Linux进程间通信

Linux进程间通信

Linux_Centos进程间通信_管道(匿名管道_命名管道)

linux进程间通信:匿名管道&&命名管道

[linux] 详解linux进程间通信

(c)2006-2024 SYSTEM All Rights Reserved IT常识