【Linux】进程间通信 --- 管道 共享内存 消息队列 信号量

简介: 【Linux】进程间通信 --- 管道 共享内存 消息队列 信号量

等明年国庆去西藏洗涤灵魂,laozi不伺候这无聊的生活了9cac50a14cf74109984b2bda8efdf3d0.jpeg


一、进程间通信

1.什么是通信?(IPC)


1.

通过之前的学习我们知道,每个进程都有自己独立的内核数据结构,例如PCB,页表,物理内存块,mm_struct,所以具有独立性的进程之间如果想要通信的话,成本一定是不低的。


2.

a.数据传输:一个进程需要将它的数据发送给另一个进程

b.资源共享:多个进程之间共享同样的资源。

c.通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。

d.进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。比如在gdb调试时,用的就是gdb进程控制被调试的进程。


2.为什么要有通信?(多进程协同)


有时候单独进程的工作是无法满足需求的,所以我们是需要多进程协同完成某种业务内容的。

例如:cat file | grep ‘hello’,我们将cat进程打开的file的文件内容通过匿名管道将数据资源传输给grep进程,让grep进程进行数据的检索,这个过程就是一个多进程协同的场景。


3.如何进行通信?

1.

如何进行进程间通信这个问题不是我们要考虑的,而是十多年前的大佬们需要考虑的问题,我们能够做的就是站在巨人的肩膀上学习。


2.

目前主流的通信标准有两套,一套是POSIX,一套是System V标准,POSIX可以让通信过程跨主机实现,比如客户端和服务器之间的通信,System V聚焦于本主机内部的多进程之间的通信,例如在某个网页上通过微信或QQ登录授权等。

本主机通信现在来看已经是一套比较陈旧的标准了,用的不是很多。而POSIX更为常见,跨主机通信是比较常用的。


3.

而管道是一种非常古老的通信方式,他并不属于上面这两套标准,它本身是一种基于文件系统的通信方式。

标准会被迭代,但不会被推翻,例如1G,2G通信方式,他们的标准是没有错的,只不过我们不去用他们而已了。


二、基于文件系统的管道通信

1.通信的本质问题(重点:让不同的进程看到同一份资源)


1.

两个进程通信的前提应该是不同的进程先看到同一份资源,把这个资源当作临界资源,让两个独立的进程通过这个临界空间进行通信,通信需要介质吧,不能你让两个进程通信就他就通信啊,这不能用嘴说啊。而是一个进程向这个公共资源发送数据,另一个进程从公共资源里面拿数据,这就完成了两个进程之间的通信。


2.

另一点是这个临界空间一定是由内核开辟并且提供给两个独立的进程的,如果由任意一个进程来提供这份空间,那么这个空间一定是属于提供的进程的,另一个进程是看不到这份空间的,这就无法满足通信的前提:让不同的进程看到同一份资源,更别说通信了。


3.

两个不同的进程看到一份公共的资源,如果这个资源是由文件系统提供的,那我们将其称为管道通信,如果是由内核中System V通信模块提供的,要注意的是OS中不仅仅只有进程管理,文件系统,驱动管理,内存管理等,他还有许许多多的模块,比如终端处理,以及我们现在所学的通信模块,如果提供的是一大块内存,我们称之为共享内存通信机制,如果是一个计数器,我们称为信号量的机制,如果是一个队列,我们称为消息队列的机制。


4.

所以说来说去,我们要学很多的通信机制,但他们的本质都是一样的,让不同的进程看到同一份资源,这个资源提供的方式将他们分成了不同的通信机制。



2.匿名管道

2.1 匿名管道实现IPC的原理(父进程打开内核级文件,fork创建子进程)


1.

进程间通信要快,他们通信的level是内存→内存的,而不是内存→磁盘→内存,因为只要访问外设,速度就一定会降下来。如果是第二种方式,那就不算进程间通信了,他仅仅只是文件操作而已,进程间通信是不会这么去干的,效率太低了,一个进程写到磁盘文件上,另一个进程去磁盘文件读取,这会访问两次外设,所以通信的level一定是内存→内存的。


2.

我们是如何让两个进程看到同一份资源的呢?在匿名管道这里,我们通过fork创建子进程,让子进程继承父进程的文件描述符表,这样子进程中会有一个指向匿名管道文件的文件描述符,并且父进程也会有这样的文件描述符,当然是在fork之前,父进程要打开一个管道文件,这样子进程在继承的时候也才能够看到这份公共资源。


3.

在文件系统中我们通常用路径+文件名的方式来标识一个文件,但匿名管道是一个内存级文件,它并不存在于磁盘当中,所以我们是无法通过上面的方式来标定匿名管道,那我们是通过什么样的方式来标定的呢?其实是通过一个存储文件描述符的fd数组来标定匿名管道的读写端的。

7357224246d140a2b3090eea279356da.png



4.

匿名管道一般而言,我们只能用来单向数据通信,如果你想双向通信,那就创建两个匿名管道就行。并且管道一般是用来传输资源的,也就是用于通信中数据的传输。

所以在创建匿名管道的过程中,父进程首先要以两种方式打开管道文件,以便于继承下去的子进程能够继承2种打开方式,这样在最后关闭文件描述符的时候,一个关闭读端另一个关闭写端,用剩余的文件描述符完成两个进程间的通信。我们建议关闭不用的文件描述符,如果你不关闭当然是可以的,但是如果被不小心用了,那么就有可能发生不可预料的错误,所以我们还是建议关闭进程不用的文件描述符,下面图片示例了父进程写,子进程读的情况,如果反过来当然也是可以的。

所以目前来看,匿名管道只能用来进行父子进程之间的通信。3c6466d2b7984f229eed3fb0761161cd.png


3c6466d2b7984f229eed3fb0761161cd.png

2.2 用匿名管道来实现IPC( int pipe(int pipefd[2]); )


1.

由于管道文件并非磁盘文件而是一个内存级的文件,所以我们不应该用open这样的接口打开管道文件,而是有专门的系统调用来创建管道文件,这个系统调用就是pipe,他的参数是一个输出型参数,用于修改形参pipefd[ ]对应的外面的数组的内容,一般我们自己创建一个int类型的大小为2的数组,用于存放打开的管道的读写文件描述符,pipe接口调用成功时会返回0,调用失败时会返回-1,并且errno错误码会被设置。

fds[0]的文件描述符代表读端,fds[1]代表写端,这可以帮助我们判别通过哪个文件描述符来对管道进行读取还是写入。

    // 第一步: 创建文件,打开读写端
    int fds[2];
    // int pipe(int pipefd[2]);
    // pipe参数是输出型参数,pipe函数内部会填充pipe文件的fd到fds数组里面,修改pipe外面的fds数组内容
    int n = pipe(fds);
    assert(n == 0);
    // pipefd[0]-->read   pipefd[1]-->write
    // 0-->嘴巴 读书       1-->钢笔 写字
    // std::cout<<"fds[0]:" << fds[0] << std::endl;
    // std::cout<<"fds[1]:" << fds[1] << std::endl;


d39411ec5dbc4cd595bcca883249e4a7.png


2.

子进程向管道进行写入的代码如下,我们调用了snprintf( )将要写入到管道的信息进行格式化,这步我们也可以通过C++的string来实现,但string的使用太简单并且没有C语言的接口更加灵活,所以我们用snprintf( )来进行字符串的格式化,像C语言的格式化输出这类接口都会自动添加\0作为字符串的标识,因为我们知道在C语言中字符串的末尾是要有\0来进行标识的,所以实际写入到buffer数组里面的字符串末尾会自动被添加\0,但在系统层面中,并没有你说的什么\0标识这些东西,我系统只认二进制数据,你的字符串什么的在我看来和其他数据无任何区别,全都是机器码而已。

所以,在向fds[1]指向的文件进行write( )写入的时候,写入的大小调用strlen就可以,系统层面才不会管你有没有\0呢,那是你语言级的规定,和我系统有什么关系?所以我们只需要将有效数据写入到管道里面即可。


snprintf实际是要比sprintf更加安全的,所以我们使用了snprintf,因为snprintf能够防止缓冲区溢出,如果发生缓冲区溢出时,snprintf()函数会自动截断字符串,并在缓冲区的末尾添加一个空字符以符合C语言对字符串的规定。

    // 第二步: fork
    pid_t id = fork();
    assert(id >= 0);
    if (id == 0)
    {
        close(fds[0]);
        // 子进程的通信代码,进行写入
        const char *msg = "我是子进程,我正在给你发消息";
        int cnt = 0;
        while (true) // 子进程不断向管道文件写入
        {
            char buffer[1024]; // 只有子进程能看到!!!
            snprintf(buffer, sizeof buffer, "child-->parent say: %s[%d][%d]", msg, cnt++, getpid());
            write(fds[1], buffer, strlen(buffer)); // 只需要将有效字段写入到文件即可,这是系统调用接口,\0是语言级的规定。
            // sleep(50);                              // 每隔1s写一条信息
            // std::cout << "count: " << cnt << std::endl; // 写端写满的时候,继续写会发生阻塞,等待读端进行读取
            // break;
        }
        close(fds[1]); // 子进程退出文件描述符表都会被清理,fd肯定也会的,不过你也可以手动去关一下,无所谓
        std::cout << "子进程关闭自己的写端" << std::endl;
        // sleep(10000);
        exit(0); // 退出码设置为0
    }


3.

下面是父进程作为读取的通信代码,在调用read读取时,我们将读取的大小最大设置为sizeof buffer -1,这是为了在读取的数据超过缓冲区大小时,我们仍然能够在缓冲区中预留出最后一个位置放\0,这样做的目的其实是在读取时,将管道中的数据当作字符串来处理,所以我们会在读取到数据后,手动的在其末尾处添加\0,将其看作一个字符串。

这是父进程在读取管道中的信息时这么认为的,他将管道中的数据看作字符串处理。

read( )的返回值是读取到的字节数大小,这个返回的字面值的大小对应的下标刚好是char buffer[ ]中最后一个数据的下一个位置,所以我们可以直接利用这个返回值来给buffer中数据的末尾添加\0,进行字符串化的处理。在定义buffer时,我们通常将其类型定义为char,因为在使用read和write系统接口时,操作的数据都是以byte为单位的。

6ecadcd1fcea4ad9b13ead6bef2471d9.png

    // 父进程的通信代码,进行读取
    close(fds[1]);
    while (true)
    {
        // sleep(10000);
        sleep(2);
        char buffer[1024];
        // 系统调用只认纯二进制,语言会做各种各样的处理,但我们不关心底层细节,防御式编程,都给你预留出来一个空间放\0
        std::cout << "AAAAAAAAAAAAAAAA" << std::endl;
        // 如果管道中没有了数据,读端再读,默认会直接阻塞当前正在读取的进程!进程等待管道文件,PCB会被放在文件的等待队列中,有数据之后会重新投入运行队列
        ssize_t s = read(fds[0], buffer, sizeof buffer - 1); //-1是为了将读取到buffer的内容进行处理,预留一个位置放\0
        std::cout << "BBBBBBBBBBBBBBBB" << std::endl;
        if (s > 0)
        {
            buffer[s] = 0;
            // 写入的一方没有考虑\0,他也不需要考虑\0,他认为他发送的就是一个个正常的二进制码,读取的一方读到的就是二进制,但将其作为字符串处理,加了\0
            std::cout << "Parent Get Message# " << buffer << " |parent pid: " << getpid() << std::endl;
        }
        else if (s == 0)
        {
            // 写端不写了,读端读到管道文件的结尾
            std::cout << "read: " << s << std::endl;
            break;
        }
        break;
        // 细节: 父进程可没有sleep,一直进行读取
    }
    close(fds[0]);
    std::cout << "父进程关闭读端" << std::endl;
    int status = 0;
    n = waitpid(id, &status, 0); // 设置为0阻塞式等待
    assert(n == id);
    std::cout << "pid->" << n << "终止信号:" << (status & 0x7f) << std::endl;
    return 0;


2.3 匿名管道的四种读写规则

1.

如果管道中没有了数据,此时读端继续读取的话,读端的默认行为是:直接阻塞当前正在读取的进程! 进程等待管道文件,此时PCB会被放在管道文件的等待队列中,当管道中重新出现数据时,PCB会被重新投入到运行队列中,将数据从内核拷贝到用户层,只要没有数据,该进程就会一直阻塞等待

2d11168b7df34217b795b540569947af.gif


如果一直不写入,则父进程一直阻塞等待。

image.gif


2.

管道文件有固定大小的缓冲区,所以当写端写满,读端却不读取的时候,此时如果继续写入,则也会造成写端进程阻塞,等待读端进行读取。

image.gif


3.
当父进程也就是读取端每隔2s读取一次,写端疯狂写入数据,那么read在读取的时候,不会按照你所想的那样一行行的读取出来,而是直接读取指定大小的数据,全部读出来,1023字节


image.gif


4.

写端关闭文件描述符后,读端读取到的字节数为0,也就是一个EOF信号,表示读端已经读到文件结尾了。由于写端文件描述符已经被关闭,则不可能有新的数据再写入。所以此时读端进程只能选择进程退出,以此避免永久阻塞.

永久阻塞就是父进程一直不退出,死循环调用无法停下来。

image.gif


5.

当读取端关闭的时候,你再去写有意义吗?写入端是主动的,读取端是被动的,当没有进程读取的时候,写入端作为主动的一方肯定不会傻傻的等着,此时OS会给写进程发信号来终止写端进程。我们可以通过父进程的waitpid的第二个参数status获取子进程的退出信息,包括退出码和终止信号等信息,如果进程提前终止则退出码是几当然就没有意义了,只不过这个退出码是默认写成了0,但我们并不关心这个退出码。所以我们打印输出status的低7位对应的数字信号是多少,看他是被几号信号杀死的。

7cd2b5ed44804c8baf40cd333644ddb5.png


d1608e7792b740c188cea50c80e89dcc.gif


6.

a.写端写的慢,读端读的很快,则读端会阻塞下来,等待写端重新写入数据。

b.读端读的慢,写端写的很快,则写端写满缓冲区时,写端会发生阻塞,等待读端重新读取数据。

c.如果写端关闭,读端读到0。

d.读端关闭,OS会给写端发信号,终止写端进程。


2.4 管道的五大特征


1.管道的生命周期随进程,如果父子进程都退出,则管道也会被OS释放。

2.管道可以用来具有血缘关系的进程间的通信,常用于父子进程之间的通信。

3.
管道是面向字节流的(网络),通过管道传输的数据被看作成字节组成的序列,他不会对数据作任何格式化的处理,只是简单的将字节序列从一个进程传递到另一个进程,文本,二进制数据等都被管道看作成字节序列进行传输。

4.管道是半双工的,属于单向通信的特殊概念。

5.管道是具有同步与互斥机制的,这是对共享资源进行保护的一种方案。


2.5 基于管道的进程池设计(父进程控制多个子进程,使其完成特定的task)


1.

我们可以让父进程创建出多个子进程,通过打开多个对应的管道文件和每个子进程建立通信的前提,然后我们可以通过管道的读写规则其中的一条,也就是当写入端写入很慢时,读取端进行阻塞等待。那我们就可以以这样的方式来让父进程控制每个子进程。

当父进程向某个子进程发送command code时,也就是对应的命令码,每个命令码对应一个需要子进程完成的任务,当父进程没发送command code的时候,其他未接收到命令码的子进程则一直进行阻塞等待即可。

4139d27cc9014746b4ff89756d8f13fa.png


2.

下面是大概的框架,我们需要循环创建出5个子进程,让父进程控制这5个子进程完成某些特定的任务。

实现的关键是,在for循环中,我们需要保存每个子进程的pid以及父进程和当前子进程通信信道的写端描述符fds[1],这样方便父进程后续给指定的子进程通过管道发送command code,子进程的读端fds[0]接收到命令码之后,要处理command code对应的任务。

3afed8100c4642f9b680aa978961a060.png



3.

在创建子进程的过程中,我们需要维护子进程的pid以及管道的写端,为了更好的分清是哪个子进程在执行任务,我们还可以多维护子进程的name,以上这些我们可以写一个类来封装实现,这个类命名为subEndPoint意为子进程的另一端。

创建子进程并且将某些信息维护到一个类之后,紧接着带来的问题就是我们需要让子进程完成任务,所以我们可以通过函数指针数组来存储子进程需要完成的任务有哪些,但在有了C++基础之后数组我们肯定不用自己实现,用vector即可,函数指针写起来并不简洁,所以我们可以typedef一下函数指针类型为func_t,多增加了一个函数为loadTaskFunc( )将子进程需要完成的函数load到vector<func_t> funcMap里面,另外loadTaskFunc( )函数参数为输出型参数,用于将函数外边的funcMap内容进行修改。

f18c916334574b7e95fd7a5e4b84f6ca.png


f1b5a60bdcf14a7585085d856b1f135e.png


4.

在创建子进程后,我们需要让子进程完成某个任务,那么任务应该从哪里来呢?当然是要从管道里进行读取,父进程会选择某个子进程,并往父进程和这个子进程通信的信道里面发送command code,所以子进程在读取command code之后,需要完成对应的任务,这个任务也好完成,因为所有的任务都加载到了funcMap数组里面,通过命令码在数组中对应的下标然后回调任务函数,子进程便完成了任务。

另外需要注意的一点是,在recvTask里面,读取时如果读取到0,那其实就是EOF信号,说明此时管道的写端已经关闭了,那读端就没有必要读下去了,所以我们让recvTask返回-1,如果command code为-1,子进程也就不干了,不给你完成任务了,你写都不写了,我又收不到command code,我还等你干嘛啊,我直接退出了就,等你父进程回收我就好了。

这里容易混淆的一个点是,只有当一个管道的所有wfd关闭之后,读端才会读到0,也就是EOF文件结尾信号。只要还有wfd没有关闭,就算wfd不往管道中写数据,你读进程也必须进行阻塞等待,read( )函数此时是无法被执行的,进程不会向下继续执行自身代码,因为他现在并不处于R状态,而是S状态,只有管道中有数据时,PCB才会被唤醒,代码才会继续执行。

另外的一点是,在if语句的外部的代码就是父进程所执行的for循环,我们需要实例化出sub对象,然后将sub对象尾插到out指向的数组里面去,这里的out就是输出型参数,用于改变函数外面的subs数组内容。

07bfe3935b9044f3abd7ff6959b5d6b0.png

5.

下面是父进程发送任务的代码,我们该怎么给具体的一个子进程发送任务呢?答案很简单,我们有vector< subEp > subs数组,这个数组里面的元素是类实例化的对象,每个对象包含了子进程和他与父进程通信信道的wfd,所以通过subs数组就能挑选出要执行任务的子进程,并能够向通信信道里面发送command code。至于发送什么任务呢?这个也很简单,任务不都在vector<func_t> funcMap数组里面吗?这个数组里面存放的都是指向任务函数的函数指针,我们只需要发送小于数组大小的各个下标即可,这个下标其实就是对应的command code,子进程可以直接通过command code作为下标调用funcMap里面的具体任务函数即可。


发送任务和挑选子进程,我们都可以通过funcMap和subs数组的index来实现,但我们为了保证负载均衡,也就是让各个子进程都能分担到任务,而不至于让一个子进程执行多次任务,其他子进程闲的没事干,所以下面采用了随机数法挑选index,并且利用模运算刚好可以满足下标合法性的要求,让模出来的数字subIdx和taskIdx都小于各自对应数组的大小。为了增加生成的随机数的随机性,我们又设置了随机数生成器的种子srand,因为rand()生成的随机数并不是真正的随机序列,而是通过某种算法得到的伪随机数,所以我们又搞出来srand(),并且加了time(nullptr)时间戳,又加了一堆的异或运算,模运算等等,瞎加了一堆运算,让随机数种子更为随机一些,这样保证rand()生成的数字序列是真的较为随机的数字序列。


我们又调整了一下负载平衡函数的内部细节,我们多增加了count参数,代表父进程需要让子进程完成多少个任务,如果count为0,则让父进程永远控制子进程,让子进程永远去不停的完成任务,如果count大于0,则count是多少,子进程就需要完成多少个任务。


eff659a2fbe9490a8e86c72f89dc0439.png

6.

最后这一部分是主函数和父进程回收子进程的代码,回收子进程又是通过maps数组来实现的。结尾是一个监控脚本,可以用来观察生成的进程个数以及进程状态等等信息。


c512e2c81ff748c7908c6626886c842f.png


7.

其实上面的代码中有一个隐含的问题,只不过这个问题并不影响我们的程序运行。当创建多个子进程时,从第二个子进程开始每个子进程都会继承父进程之前打开的wfd,这就会导致,某一个管道的写端文件描述符不是只有一个的,如果尝试每关一个文件描述符,就等待回收一个子进程,则程序一定会崩溃,比如你关了第一个文件描述符后,子进程的read是不会读取到0的,而是会继续阻塞等待,因为第一个管道的wfd并没有全部关闭,只有全部关闭的时候,read才会读取到0,读取到0的时候,我们才会让子进程退出,你父进程才可以通过waitpid回收子进程。所以第一个子进程并不会退出,那么调用waitpid就会阻塞,代码卡在第一次循环,无法继续执行for循环,程序会崩溃。好在我们并不是这么做的,我们是先将所有的写端文件描述符关掉了,然后去统一回收子进程,这样是没有问题的。你从上到下关闭wfd,右边的子进程其实是从下到上逐个退出的,因为只有右边最后子进程对应的管道的写端只有一个wfd,所以右边最后一个子进程会先退出,当他退出之后,对应的文件描述符表struct files_struct也会被回收,那么这个进程所继承的所有的wfd就都会被关闭,所以道理相同,倒数第二个子进程此时read会读到0,依次的所有子进程就可以正常退出。

42c594fd66d040f5a0a6d36387f53308.png

8.

如果不想碰到上面那样的问题:就是在创建子进程的时候,继承了父进程的wfd。

我们其实可以在创建子进程的接口里面做调整。在fork之前,我们可以定义一个vector< int > deleteFd,这个数组用于存储父进程的wfd,然后在子进程代码内部,我们先做判断,如果这个vector不为空,那就在子进程内部关闭对应的wfd,如果是这样的话,在创建子进程函数调用结束之后,所有的子进程都不会有继承下来的wfd了,因为我们在创建的每个子进程代码内部将继承下来的wfd全都关闭了。

ea137106e90c4c3394e910eae6fce418.png

3.命名管道

3.1 命名管道实现IPC的原理(文件名标定唯一性的管道文件,linux文件系统只是一棵多叉树,不是森林,不允许存在同名文件。)


3.命名管道

3.1 命名管道实现IPC的原理(文件名标定唯一性的管道文件,linux文件系统只是一棵多叉树,不是森林,不允许存在同名文件。)

91070f3e36354a8694864dd574c265e1.png



当我们将一段循环打印hello world的脚本运行结果重定向到这个管道文件named_pipe时,右边终端可以看到named_pipe的文件大小一直是没有变化的,始终保持0byte不变。这是为什么呢?


6c1f3fbf4e3f418f9df25a72c0a60dbf.png


2.

命名管道是如何实现IPC的呢?其实当mkfifo创建管道文件后,如果此时进程2也打开这个管道文件,OS会检测到这个文件已经被打开了,内核中是有对应的struct file{}结构体的,OS此时是不会再重新创建另一个struct file{}的,所以两个进程会打开同一个管道文件,通过这个管道文件来进行IPC。

和匿名管道相同的是,在进行数据传输时,也是内存→内存级别的,不会和磁盘有任何关联,因为struct file{}内部是有自己的内核缓冲区的,两个进程通过这个内核缓冲区就可以完成IPC。

OS可以检测到文件是管道文件,那么当你向其内核缓冲区写入数据时,OS是不会刷新数据到磁盘上的,因为你不需要IO过程,你作为管道文件就是应该完成IPC工作的,OS也不需要对你进行刷新数据的处理。

d109cb1fe6824884b9fe4d436b002b5b.png


3.

那么我们是通过什么样的方式来让不同的进程看到同一份资源的呢?

可以让不同的进程打开指定名称(路径+文件名)的同一个文件,指定名称其实是通过路径+文件名来标识的,路径+文件名是具有唯一性的(因为linux的文件系统目录是一棵多叉树,他只有一个root,所以在这个root下面是不能存在两个文件名相同的文件的,否则在查找文件时,linux是不知道该去哪条路径找这个文件的)这也是问什么叫做命名管道的原因,我们是通过命名+路径来标识当前这个唯一性的命名管道文件的!

而匿名管道是通过什么方式来标定同一份公共资源的呢?他其实是通过继承的方式,父进程打开一份内核级匿名管道文件,这个内核级文件的地址被放到文件描述符表里面,此时fork创建子进程,子进程通过自己的文件描述符表中的内核级匿名管道文件地址就可以找到这份公共资源!


3.2 用命名管道来实现IPC( int mkfifo(const char *pathname, mode_t mode); )


1.

mkfifo既是一个指令,也是一个系统调用,它可以用于创建出命名管道文件。unlink用于删除某个文件,其实unlink的效果和rm一样,都是删除某个文件,但是unlink不仅仅是一个指令,他也是一个系统调用。所以在写代码时,我们常用unlink和mkfifo配合使用来实现命名管道。

mkfifo的参数也好理解,即在pathname路径下创建指定名称的管道文件,并设置管道文件的权限。unlink参数和mkfifo的第一个参数相同,这里不过多介绍。

1aa715138d0f4c3db87de95eafe391a9.png


2.

通过这两个接口我们就可以实现命名管道文件的创建和删除。实现起来并不困难,主要是我们做了很多的查错处理。一般预料之中的事情,我们用assert断言检查,预料之外的事情用if语句判断检查。

3037d6b5e53e45a4bbdac48e7b4d8064.png



3.

下面是服务端server的文件代码,我们将创建管道文件和销毁管道文件的工作交给server来做,客户端无须知道管道文件的存在,只需要和server通信即可。

通信的代码其实就是回顾了文件操作,只不过其中又掺杂了管道的知识内容。

服务端在读取的时候,将读取到的内容进行字符串化处理,所以我们将读取到的字节数s对应下标的位置的字符改为\0,这样就成功对管道中读取的数据进行字符串化处理了。

并且当read读到的s大小为0时,我们也就不让server再继续读了,因为此时client客户端已经关闭了wfd,读端此时就会读到EOF信号,也就是0.此时我们也让server退出break,不再继续读取管道中的数据,如果出现预料不到的情况,也就是进入else语句,那我们就输出一条错误信息并break出循环。

最后在服务端进程退出之前,关闭掉rfd,并将路径在/tmp/mypipe.server的管道文件进行删除,然后再进程退出。

dec2554f3a714361aaaa5d0470d0775c.png

4.

下面是客户端的代码,我们想让信息一条一条的显示到显示器上,所以我们采用了fgets函数,为了给buffer最后一个位置预留出\0,在fgets读取大小那里我们控制其为sizeof(buffer) - 1,但如果你知道fgets的使用细节,其实这里可以不用-1,因为fgets自身最多也只会读取size-1大小,它会自动预留出\0的位置。

但如果你不关心这些函数的具体使用细节,你也可以进行防御式编程,就是不管你fgets是怎么做的,在读取stdin输入的字符串时,我始终只读取-1的大小,这样永远是不会出错的,这样的方式称为防御式编程。

另外,在测试的时候,我们不想在client的while循环内部作判断,即输入某个字符串或字符时,client停止向管道写入,break出循环,紧接着server也会由于read到0而退出循环,这样的代码可以实现但是没什么必要,因为在管道进程池设计那里我们已经实现过了,就是那个createSubProcess()函数的第三个参数count,所以在这个地方不带要去写了。

我们在测试的时候直接ctrl+c终止client进程即可,则其对应的文件描述符表也会被OS回收掉,自然写端fd会被关闭掉,那么紧接着server端进程也会跟着退出。所以client实际执行的代码是不会执行到close(wfd);的,因为我们提前终止了client进程。

7c2586c099d64b6c89f8656b518a8d94.png


5.

上面的代码写的很好,没啥问题,帮我们回顾了系统级的文件操作,还运用了管道读写的四种规则其中之一,但有两个细节还需要说明一下,细节控上线……

第一个细节:读端进程打开管道文件之后,是不会继续向后执行代码,只有当写端进程也打开管道文件时,读端server进程才会继续向后执行代码。从运行server的结果可以看出,server只打印出begin,并没有打印出end,所以在server的read( )接口执行后,server进程会阻塞,只有当client进程也打开管道文件的时候,server进程才会重新投入运行队列当中,才会继续向下执行代码。

第二个细节:键盘输入时多输入了\n回车,这样在写入到管道中的数据末尾会多一个\n字符,server读取进行打印的时候,如果多输出了endl,则输出到显示器上的结果会多一个空行,所以在写入的时候,我们可以将stdin输入的那个回车符置为\0,如果是strlen(buffer),则对应下标位置是字符串末尾的\0,-1对应的正好是换行符\n的位置,我们将其置为0即可。

image.gif



在ctrl+c终止client进程后,可以看到server也正常退出了,并且我们在显示/tmp/mypipe.server文件的时候,这个文件也确实被我们删除了

b3a7e1c8d42243cbb58ee5d07f330cf0.png


三、System V 共享内存

1.共享内存实现IPC的原理


1.

实现进程间通信的第一个前提就是如何让不同的进程看到同一份资源,匿名管道我们是通过子进程继承父进程打开的资源,命名管道是通过两个进程都打开具有唯一性标识的命名管道文件,而共享内存其实是通过OS创建一块shm,然后通过MMU将shm的地址分别映射到两个进程的各自地址空间当中,那么两个进程就可以通过这份虚拟起始地址来进行进程间通信。

在应用层也就是用户层,我们只能操作虚拟地址,但内核中会有MMU进行虚拟地址的映射,所以进程在IPC时,只需要操纵虚拟地址即可,从虚拟地址中读取或向虚拟地址中进行写入,这样就完成了共享内存式的IPC

007d218dddb043c583259d5089006bd2.png



2.

malloc可以做到在物理内存开辟一块空间,并将这块空间的物理地址通过页表映射到进程地址空间,那么进程就可以通过malloc的返回值,这个返回值其实就是经过MMU处理的,其实就是虚拟地址,通过这个虚拟地址就能够访问到对应物理内存上开辟的空间。

今天我们所认识的shm和malloc开辟出来的空间区别是挺大的,因为malloc出来的空间是无法让另外一个进程看到的,这份空间只独属于调用malloc的进程。

而shm共享内存是一种通信方式,所有想IPC的进程都可以使用shm,所以如果其他进程也想申请shm,那当然也是可以的,自然OS中一定可能会存在很多的共享内存。


3.

所以通过让不同的进程,看到同一份物理内存块的方式,就叫做共享内存!



2.共享内存实现IPC

2.1 通过接口来深层理解共享内存(a.先描述,再组织。b.宏观层面的解耦,内核层和用户层是两套机制)


1.

shmget()是用于获取或创建共享内存的接口。

第一个参数叫做key,这个参数是用来在内核中唯一性标识共享内存的。

第二个参数是共享内存的大小,一般建议将开辟的共享内存大小设置为4KB的整数倍,内存划分内存块的基本单位是Page,大小刚好是4KB,所以建议将大小设置为4KB的整数倍,如果你设置成4097什么的,有点浪费内存,因为实际内核会开辟8KB大小的空间。

第三个参数是标志位,关于标志位的认识在open的时候我们就遇到过了,可以利用或运算一次性传多个标志位,传不同的标志位可以让函数的功能发生细节的变化。


2.

并且标志位其实就是宏,通过宏的大小在底层中将位图结构中指定的比特位 置为1这样的方式来实现不同的函数功能。从man手册可以看到标志位shmflg的样例,其实最为常用的就是IPC_CREAT IPC_EXCL mode_flags这三个标志位。

IPC_EXCL不能单独使用,必须配合IPC_CREAT,两者配合使用时,如果共享内存存在则会报错,不存在就会创建,所以这两个标志位配合起来的意思就是创建一个全新的共享内存。

只使用IPC_CREAT标志位传参时,如果key对应的共享内存并不存在,则会创建一个新的共享内存,如果key对应的共享内存已经存在,则会获取这个共享内存段,并且会检查使用者是否具有访问这个段的权限,如果没有则会报permission denied,如果有访问的权限,则shmget会成功返回共享内存标识符。

值得注意的是,这个shared memory identifier和文件描述符file descriptor是完全不同的两个体系,两者没有丝毫的关联。从这个地方也可以看出一个点,就是System V标准的通信方式并不常用,因为它和文件毫无关联,在进行IO时这样的通信方式是行不通的。而POSIX网络通信标准却不一样了,它能够和文件产生耦合,故而POSIX标准被使用广泛,后面我们会学到POSIX,这里就简单提一嘴。


4864ab1beac145089778bad008e22627.png

f368f6c8dcf641cf9914868e24515b11.png


3.

还有一个点我们是没有说到的,就是那个key,其实在上面谈论关于shmget参数的时候,我们说到了不少key,但是key是什么我们却没有谈到。key可以通过pathname和proj_id两个参数在内部进行算法的处理最终会得到一个key值,然后ftok会将这个key进行返回。

在malloc或new创建空间后,如果我们想要释放这个空间,在底层开辟堆空间的起始位置还有一个cookie数据,这个数据其实记录了空间的个数,在delete或free空间时,编译器依靠的就是这个cookie数据来确定到底要释放多大的空间。从这样的一个例子其实就可以看出内核对于开辟的空间是需要进行某种唯一性标识的,在内存里面,这样的堆空间可不止一个,每个较大的堆空间都会有各自的cookie数据,OS通过这样的方式进行堆空间的描述,然后通过某种数据结构将你所申请的所有堆空间组织起来,这不就是我们所说的OS的管理方式:先描述再组织吗?

那共享内存呢?道理当然也是相同的!,OS中运行的进程可不止一个,那内存中的共享内存也当然不止一个了,这么多的共享内存,操作系统该怎么样对他们进行管理呢?答案是:先描述,再组织,OS除了给共享内存开辟一段物理内存块之外,还要对共享内存进行描述,即为创建出共享内存的数据结构对象,这就是先描述。这个数据结构对象包含了共享内存的所有相关属性,其中就包括了我们所说的key,这个key是什么不重要,重要的是能够在内核中唯一性的标识共享内存,最后再通过链表或数组等等结构管理这个数据结构对象,这就是再组织。

所以我们所说的key其实就是方便OS管理共享内存的一种标识符,这个标识符是多少根本不重要,只要这个key是唯一的就够了,他就具有唯一性标识共享内存的能力了,那在内核中只要每个共享内存都拥有自己独立的key,OS就可以通过key来标识每个共享内存,这就能够进行对应的管理。所以,共享内存 = 物理内存块 + 共享内存的相关属性(在内核中唯一性标识shm)

bc7c95e36480468cb721b08e733cd516.png



4.

实际上这个key存在的位置也不难找到,他就在内核创建的数据结构对象struct shmid_ds{}结构体内部。内核就是通过key来唯一性标识每个共享内存的。

这里也会延申出一个问题,既然我们都已经拥有key这个能够唯一性标识共享内存的标识符了,我们还要shmid干嘛呢?其实这是为了方便宏观层面上的解耦,你内核层和我用户层用的是两套机制,内核层用key来进行标识,用户层用shmid来进行标识,当内核层如果进行某些代码改动什么的是不会影响用户层的,这就是解耦带来的好处。

道理和fd与inode相似,我们用户层使用的是fd来操纵文件,你内核层用的是inode来唯一性标识文件,用户层和内核层用的是两套机制,互不影响,进行宏观层面的解耦。

e7c7be5e2204477ab31fd3a7d690093a.png


5.

下面我们再说一下,如何查看IPC资源,通过ipcs -m/q/s就可以看到共享内存,消息队列,信号量等IPC资源的使用情况了,如果要删除某一申请的资源,可以通过指令ipcrm -m/q/s +上层用的id编号进行删除,下面进行代码实现的时候,我们也可以通过shmctl接口来进行资源的回收,其实回收资源的本质就是将资源的使用权归还给操作系统。

22e43da0521c4381800157760eaf9f3f.png

2.2 通过代码来实现共享内存IPC

1.

用共享内存来实现IPC的步骤主要还是集中在让不同的进程看到同一份资源,这一步其实是我们主要进行的工作,也是学习时的重点所在,至于通信其实是捎带的工作,因为通信无非就是用文件操作或一些系统调用接口来进行通信,所以难度还是落在了前面的工作上。

还是老套路,我们将方法放到comm.hpp里面,client和server直接调用就可以了。创建共享内存首先需要创建出key,我们可以提供一个接口来获取key,对于client和server在创建共享内存时,要求不一致所以我们对获取共享内存的接口进行封装,如果是server端创建全新的shm,那所传的标记位就是IPC_CREAT|IPC_EXCL|0600,如果是client获取server创建的shm,那所传的标记位只需要IPC_CREAT就够了,所以我们将shmget()接口进行封装,getShm和createShm调用时传不同的参数即可。

在linux的代码中,由于不便于调试,所以我们要做好查错处理,检查每个函数的返回值或代码中的执行逻辑等等,gdb调试难搞啊,能打印出来错误信息是最好的了,所以查错处理很重要。

c5bb908eadd640dbac20c02be7474ea0.png


2.

上面的步骤做完之后,一块儿共享内存就创建好了,但是那是物理地址我们无法直接使用,所以还需要进行挂接,我们把进程地址空间中的某个地址经过MMU映射后,与共享内存块的物理地址产生关联这样的行为叫做进程挂接。

进程挂接可以用shmat来实现,shmat会返回一个虚拟起始地址,这个地址映射到的就是我们上面所申请的共享内存。这样两个进程才算真正关联起来。

当我们不想IPC,想要终止进程间通信的时候,我们应该先去关联,然后再释放共享内存空间。去关联其实就是将shmat返回的虚拟地址进行释放,说白了就是修改页表,回收虚拟地址空间中的虚拟地址start,这个start就是共享内存映射的进程地址空间的起始地址,回收是较为形象的说法,在OS中,所有的回收其实都是将空间的使用权从进程归还给操作系统,shmdt便可以帮助我们回收虚拟地址,将进程去关联。

e7c2d1661b694775900b9954dc204045.png



1b15be5c9eee42c2a2dc866dd05573d5.png



3.

在去关联之后,还剩最后一步就是释放共享内存,释放共享内存可以通过shmrm -m +id的方式进行释放,除此之外,我们还可以通过接口shmctl进行共享内存的释放,这个接口是对共享内存的控制,我们可以通过传不同的cmd来控制共享内存,一般来说这个接口常被用作删除共享内存。

所以我们所传的标志位基本都是IPC_RMID,IPC_STAT用于从内核中获取共享内存的所有属性,将属性信息放到buf缓冲区里面,一般情况下,第三个参数我们都设置为nullptr。06cf665d0d494abdb95b9cf9293d5ad3.png


ceb0b1eda19e40c59591f950e134f770.png


4.

下面是server端的代码,共享内存的开辟和释放等工作交给server来做,下面的代码也存在一个问题就是在测试的时候,我们还是用ctrl+c来终止两个进程,这样的方式会导致代码后面的去关联以及释放共享内存的代码执行不到,所以正确的做法是将循环改造一下,可以在client那里指定发送某个字符时,如果server读到那么就让server跳出循环,client发送那个指定字符后也跳出循环,这个逻辑并不难判断,所以也就没有实现,这个逻辑在基于管道的进程池那里我们其实就已经做过了,这里就不做了。

693f4a236e7c40e687b402857e00863f.png


下面是client端的代码,获取server申请的共享内存是客户端应该做的,我们将pid,message,cnt等信息搞成一个字符串,将这个混在一块的字符串发送给server,发送的方式很简单,我们完全不需要什么write这样的接口,直接通过snprintf和shmat挂接成功后返回的虚拟地址就可以做到将信息发到共享内存,经映射后又会直接到server进程的虚拟地址处,server也不需要用read读取消息什么的,还得再定义缓冲区这样的方式太繁琐了,server直接可以printf打印,地址start被我们看作了一个存储字符串的数组,所以printf直接打印即可,非常的方便和省事。

其实省事主要还是因为shmat接口,他直接返回了虚拟地址,server和client进程又可以直接通过语言级的文件操作接口对共享内存进行读写,这相比于管道真是省事省空间啊。


ea8fb29530ba46b7afa04e754a2142da.png


2.3 共享内存的优点和缺点(管道和shm分别数据拷贝次数的面试题)

1.

共享内存的优点:所有进程间通信中速度最快的,只要向shmat返回的虚拟地址写入数据,另一个进程直接就可以通过他自己的shmat返回的虚拟地址读取到共享内存中的数据,效率非常的高,因为共享内存能大大减少数据的拷贝次数。


2.

综合考虑管道和共享内存,考虑键盘输入和显示器输出,管道和共享内存分别有几次数据拷贝呢?如果细算的话其实是6次和4次,如果不细算的话是4次和2次。

有一种说法,喜欢把缓冲区分为内核缓冲区和程序缓冲区,程序缓冲区指的是语言级别你所能见到的所有能够存放数据的空间,这些都可以叫做程序缓冲区,是一种笼统的叫法。

管道由于要调用read和write接口,则必须定义buffer,在读端和写端分别都定义出一个buffer,实际数据会先从stdin到buffer里面,再从buffer到pipe的内核级缓冲区中,然后再从内核级缓冲区到读端的buffer中,最后再从读端的buffer拷贝到stdout的用户级缓冲区,这样算就是4次。

共享内存无须调用read或write接口,shmat会直接返回虚拟地址,所以只需将stdin的数据拷贝到虚拟地址里面,然后MMU会将虚拟地址进行映射,另一端的进程可以直接通过虚拟地址看到左边进程映射到shm的数据,所以另一端进程也只需要将虚拟地址的数据拷贝到stdout的缓冲区即可,这样算就是2次。

但我们知道键盘输入的缓冲区实际上是先到内核标准输入缓冲区中的,cin或scanf等标准输入都是从内核标准输入缓冲区中拿数据的。并且在输出时,printf或cout等标准输出其实是先将数据输出到内核标准输出缓冲区的,然后才是将数据输出到stdout也就是显示器文件内部的用户级缓冲区。所以如果把这两步考虑上,那么管道和共享内存将各自增加两次的数据拷贝。


我们下面所说的是一般场景,不排除有的场景下,对于shm也要创建buffer这种情况,所以在分析具体拷贝数据的次数时,要先说明是一般场景还是特殊场景。


4f62b2cdcb1b4f67a2f135b7833db1a8.png


3.

共享内存的缺点:不进行同步与互斥的操作,没有对数据做任何保护! 如果读端读完写端写的某条消息后,此时若管道无新写入数据,则读端自动会阻塞在那里,不会继续读取已经被读取过的数据


766a628a1ffb41b787ab4f01594d9336.gif


4.

如果想要让共享内存能够进行同步与互斥,我们可以让管道和共享内存配合起来进行IPC,进程1向共享内存写入数据后,再随便向pipe写一个字符或者其他东西,什么都可以。进程2在读取的时候不要先去shm里面读,先去pipe里面读,如果pipe有数据则进程2不会阻塞,此时就让进程2去shm里面读取数据,如果pipe没有数据,则进程2就会阻塞,说明进程1没有向pipe里面写,那就让进程2阻塞着,只有pipe中有数据的时候,进程2才可以从shm里面读取数据,这样做变相的保护了数据,使得共享内存能够进行同步与互斥。

e5cf1c67951a4bd387f4befca14bc890.png


2.4 共享内存的内核数据结构

1.

下面是OS给用户暴露的一部分shm的内核数据结构,因为OS要进行管理,所以实际在底层中其结构更为复杂,里面的key被封装到ipc_perm结构体里面,ipc_perm又被封装到shmid_ds{}结构体内部。


70d49683680e4aeca048e262ca781e44.png


2.

我们可以通过shmctl的第三个参数来获取内核数据结构中共享内存的部分属性进行查看,例如下面server进程代码中我们可以获取key值,创建共享内存的进程的pid,以及当前server进程的pid等等,若想要查看其余属性,通过对象+ .成员访问操作符便可以查看。

5390942697434de2ab04a6a54db5b1ae.png

3.

另外多说的一个细节是关于shm大小的问题,OS分配内存时是按照4KB的整数倍来进行分配的,实际内核分配空间时会向上进行取整,所以如果你将大小设置为4097,则底层内核分配的空间大小是8KB,但8KB中除你申请的4097字节外,其余字节均不可用。

所以内核给你的,和你能用的,是两码事,给你分配2 Page,但你只能用4097byte的大小。

720f6dfcb55a418e90d8e1a2f18245f8.gif


























































相关文章
|
1月前
|
消息中间件 存储 供应链
进程间通信方式-----消息队列通信
【10月更文挑战第29天】消息队列通信是一种强大而灵活的进程间通信机制,它通过异步通信、解耦和缓冲等特性,为分布式系统和多进程应用提供了高效的通信方式。在实际应用中,需要根据具体的需求和场景,合理地选择和使用消息队列,以充分发挥其优势,同时注意其可能带来的复杂性和性能开销等问题。
|
1月前
|
消息中间件 存储 Linux
|
2月前
|
消息中间件 Linux API
Linux c/c++之IPC进程间通信
这篇文章详细介绍了Linux下C/C++进程间通信(IPC)的三种主要技术:共享内存、消息队列和信号量,包括它们的编程模型、API函数原型、优势与缺点,并通过示例代码展示了它们的创建、使用和管理方法。
40 0
Linux c/c++之IPC进程间通信
|
2月前
|
Linux C++
Linux c/c++进程间通信(1)
这篇文章介绍了Linux下C/C++进程间通信的几种方式,包括普通文件、文件映射虚拟内存、管道通信(FIFO),并提供了示例代码和标准输入输出设备的应用。
36 0
Linux c/c++进程间通信(1)
|
4月前
|
开发者 API Windows
从怀旧到革新:看WinForms如何在保持向后兼容性的前提下,借助.NET新平台的力量实现自我进化与应用现代化,让经典桌面应用焕发第二春——我们的WinForms应用转型之路深度剖析
【8月更文挑战第31天】在Windows桌面应用开发中,Windows Forms(WinForms)依然是许多开发者的首选。尽管.NET Framework已演进至.NET 5 及更高版本,WinForms 仍作为核心组件保留,支持现有代码库的同时引入新特性。开发者可将项目迁移至.NET Core,享受性能提升和跨平台能力。迁移时需注意API变更,确保应用平稳过渡。通过自定义样式或第三方控件库,还可增强视觉效果。结合.NET新功能,WinForms 应用不仅能延续既有投资,还能焕发新生。 示例代码展示了如何在.NET Core中创建包含按钮和标签的基本窗口,实现简单的用户交互。
77 0
|
6月前
|
监控 Linux 应用服务中间件
探索Linux中的`ps`命令:进程监控与分析的利器
探索Linux中的`ps`命令:进程监控与分析的利器
136 13
|
5月前
|
运维 关系型数据库 MySQL
掌握taskset:优化你的Linux进程,提升系统性能
在多核处理器成为现代计算标准的今天,运维人员和性能调优人员面临着如何有效利用这些处理能力的挑战。优化进程运行的位置不仅可以提高性能,还能更好地管理和分配系统资源。 其中,taskset命令是一个强大的工具,它允许管理员将进程绑定到特定的CPU核心,减少上下文切换的开销,从而提升整体效率。
掌握taskset:优化你的Linux进程,提升系统性能
|
5月前
|
弹性计算 Linux 区块链
Linux系统CPU异常占用(minerd 、tplink等挖矿进程)
Linux系统CPU异常占用(minerd 、tplink等挖矿进程)
190 4
Linux系统CPU异常占用(minerd 、tplink等挖矿进程)
|
4月前
|
算法 Linux 调度
探索进程调度:Linux内核中的完全公平调度器
【8月更文挑战第2天】在操作系统的心脏——内核中,进程调度算法扮演着至关重要的角色。本文将深入探讨Linux内核中的完全公平调度器(Completely Fair Scheduler, CFS),一个旨在提供公平时间分配给所有进程的调度器。我们将通过代码示例,理解CFS如何管理运行队列、选择下一个运行进程以及如何对实时负载进行响应。文章将揭示CFS的设计哲学,并展示其如何在现代多任务计算环境中实现高效的资源分配。
|
5月前
|
存储 缓存 安全
【Linux】冯诺依曼体系结构与操作系统及其进程
【Linux】冯诺依曼体系结构与操作系统及其进程
175 1