1 进程间通讯概述

        因为进程有着独立性的特点,所以进程间的通讯不能像线程那样方便,所以为了满足进程间通讯,才有这些方法的诞生

1-1 进程间通信目的

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

1-2 进程间通信发展

1管道
2 System V进程间通信
3 POSIX进程间通信(这里是轻量级进程间通讯即进程在线程的文章中会详细讲解)

1-3 进程间通信分类

管道
1 匿名管道pipe
2 命名管道
System V IPC
1 System V 消息队列
2 System V 共享内存(详细谈)
3 System V 信号量
POSIX IPC
1 消息队列
2 共享内存
3 信号量
4 互斥量
5 条件变量
6 读写锁

1-4 什么是进程间通讯

        进程间通讯(IPC inter-Process Communication) 是运行在同一台计算机或不同计算机上的多个进程间进行数据交换和通讯的技术,这是因为每个进程都有自己的地址空间,它们无法直接访问彼此的数据,因此需要通过特定的机制来实现通讯

1-5 进程间怎么通讯

        进程通讯的本质:是先让不同的进程,看到同一份资源("内存")。(然后才有通讯的条件)。通讯的资源不是由任何一个进程提供的,而是由OS设置同一的接口,提供的通过系统调用接口来体现。

        从上述的概述中,我相信各位已经对于进程间通讯有什么和有什么用有了一个初步的了解,但是在Linux多年的发展依以来,有很多的进程间的通讯方式已经被不再被频繁使用,而且有一些其实是在线程部分使用会更多点(为什么进程间通讯和线程有关我们放到我们线程部分再解答),所以此文只介绍一些常见和重要有意义的通讯。

2 管道

        什么是管道

        管道就是Unix中最古老的进程间通讯的方式,我们把从一个进程链接到另一个进程的一个数据流称为一个“管道”

3 匿名管道

3-1 系统调用接口

#include <unistd.h>
功能:
    创建⼀⽆名管道
原型:
    int pipe(int fd[2]);
参数:
    fd:⽂件描述符数组,其中fd[0]表⽰读端, fd[1]表⽰写端
返回值:
    成功返回0,失败返回错误代码

demo:

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

using namespace std;

int main()
{
    int fd[2];
    pipe(fd);
    int pid = fork();
    if (pid == 0)
    {
        close(fd[1]);
        while (true)
        {
            sleep(1);
            int buf = 0;
            read(fd[0], &buf, sizeof(buf));
            cout << "子进程通过管道受到父进程的数据:" << buf << endl;
        }
    }
    
    int cnt =1;

    while (true)
    {
        close(fd[0]);
        write(fd[1], &cnt, sizeof(cnt));
        cnt++;
    }
    return 0;
}

3-2 用 fork 来共享管道原理

3-3 站在文件描述符角度-深度理解管道

        其实从这个原理就可以看到,父子之间通过看到同一个资源,就是基于文件系统特殊实现的管道文件,因为文件自带文件缓冲区,所以父子进程同时看到这个文件缓冲区,从而实现通讯,由此可以看出,匿名管道是复用了文件系统的那一套操作并且配上单独的系统调用来实现的

   

3-4 站在内核角度度-管道本质

        但是匿名管道的inode是不会保存该管道的名字的那我们是怎么保证,两个进程打开的是同一个管道文件呢?子进程会继承父进程文件描述符表,这也在一定程度上体现了匿名管道的特性

        所以,看待管道,就如同看待文件⼀样!管道的使用和文件⼀致,迎合了“Linux一切皆文件思
想”。
注意:
        如果递归创建多个子进程,那么在关闭管道的时候要fd的引用计数问题,因为子进程是拷贝的父进程的地址空间所以递归创建的时候,进程的w端不只是有父进程指向,也有可能被其他子进程指向,所以此时关闭的时候你需要倒着关,或者保证子进程的w端只能指向父进程

3-6 管道读写规则

1 当没有数据可读时
        1-1 O_NONBLOCK disable:read调用阻塞,即进程暂停执行,一直等到有数据来到为止。
        1-2 O_NONBLOCK enable:read调用返回-1,errno值为EAGAIN。
2 当管道满的时候
        2-1O_NONBLOCK disable: write调用阻塞,直到有进程读走数据
        2-2O_NONBLOCK enable:调⽤返回-1,errno值为EAGAIN
3 如果所有管道写端对应的文件描述符被关闭,则read返回0,表示文件结尾
4 如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE,进而可能导致write进程退出,否则一直写却没有人读,会让这个写操作没有意义,操作系统不做没有意义的事
5 当要写入的数据量不大于PIPE_BUF时,Linux将保证写入的原子性。
6 当要写入的数据量大于PIPE_BUF时,Linux将不再保证写入的原子性。

3-7 管道特点

1 只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信。通常,一个管道由一个进
程创建,然后该进程调用 fork,此后父、子进程之间就可应用该管道。
2 管道提供流式服务,自带同步机制(按照一定的顺序进行访问),所以才导致了管道的第 3 和第 4 条的管道特性
3 一般而言,进程退出,管道释放,所以管道的生命周期随进程
4 一般而言,内核会对管道操作进行同步与互斥
5 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立起两个管道
注意:

        1 任何一个时刻,一个发,一个收   --- 半双工
        2 任何一个时刻,可以同时发收       --- 全双工

3-8 管道的 4 种情况

1 写满,读快 ---读端就要阻塞(进程)

2 写快,读慢 --- 满了的时候,写端就要阻塞

3 写关,继续读 --- read读到返回值为0,表示文件结尾

4 读关闭,写继续 --- 写端写入,没有任何意义,OS不会做没有意义的事情 ---OS会杀掉进程

4 命名管道

        管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。 如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名
管道。命名管道是一种特殊类型的文件

4-1 创建一个命名管道

4-1-1 系统命令

$ mkfifo filename

4-1-2 系统调用

#include <sys/types.h>
#include <sys/stat.h>

int mkfifo(const char *filename,mode_t mode);

参数:
    filename:管道文件的路径和名称

    mode:创建管道文件的初始权限,注意一定要是8进制输入,否则会错乱

返回值:

    0:创建成功
    
    -1:创建失败

demo 1:

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>


using namespace std;

int main()
{
    mkfifo("./pipe",0x666);
    return 0;
}

注意:

        mkfifo最后一个参数,设置的是文件初始权限,一定要是0开头的3位数字即8进制数据传入否则就会权限错乱

传入:0666

传入:666

这个时候pipe的文件权限就出现错乱了

demo 2:

server:

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

using namespace std;

int main()
{
    mkfifo("./pipe",0666);
    int fd=open("./pipe",O_WRONLY);
    int cnt =1;
    while(true)
    {
        sleep(1);
        write(fd,&cnt,sizeof(cnt));
        cout<<"传输一个数据:"<<cnt<<endl;
        cnt++;
    }
    close(fd);
    return 0;
}

注意:
        open的标志位不可以加O_TRUNC因为命名管道不支持,而且也可以不加O_APPEND因为没有什么意义可以不加

client:

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

using namespace std;

int main()
{
    int fd=open("./pipe",O_RDONLY);
    int cnt =0;
    while(true)
    {
        sleep(1);
        read(fd,&cnt,sizeof(cnt));
        cout<<"接收到一个数据:"<<cnt<<endl;
    }
    close(fd);
    return 0;
}

4-2 理解命名管道

        命名管道是通过创建一个特殊的文件,让这个文件的内容来作为进程间看到的同一份资源进行通信

        其实你会发现,匿名管道和命名管道都是复用了文件系统来实现的,只是他们一个是直接使用文件缓冲区,不会创建磁盘文件,但是命名管道是会创建磁盘文件的,但是它们都不会将数据刷新到磁盘

        匿名管道是通过父子进程对于地址空间的拷贝找到同一个管道的,那命名管道呢?命名管道因为会在磁盘上创建文件,并且它的属性中有它自己的名字,所以这也可以复用文件系统路径唯一性来保证进程间访问的是同一个管道

4-3 匿名管道与命名管道的区别

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

4-3 命名管道的打开规则

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

        1-1 O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
        1-2 O_NONBLOCK enable:立刻返回成功
2 如果当前打开操作是为写而打开FIFO时
        2-1 O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
        2-2 O_NONBLOCK enable:立刻返回失败,错误码为ENXIO

4-4 命名管道的特性

        特性匿名管道一样,但是有一点不同命名管道可以用来进行不相关的进程之间的通讯,其他特性这里就不再赘述了。但是注意open再打开管道文件的时候都是默认阻塞的只有如果只有读写端一端开启的话就会阻塞再open只有都打开才会成功返回

5 system V共享内存

        共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据

5-1 什么是system V

        system V是一种标准,Linux内核支持了这种标准,专门涉及了一个IPC通讯模块,通信的接口设计和原理具有相似性。

5-2 共享内存的原理

        因为IPC通信的本质,是让不同的进程看见同一份"资源",而共享内存故名思意,就是让两个进程看到同一个物理空间,即共享内存

5-3 理解共享内存        

        1 这个物理内存通过平坦模式编址然后映射到各个进程的虚拟地址空间的用户共享区中,所以我们访问这个共享的物理只需要通过使用虚拟地址就可以通过页表转换访问真实共享的物理内存

        2 开辟物理内存,将物理内存映射到进程的虚拟地址空间中,这些过程都是由OS来完成的,就是系统调用,我们用系统调用完成上述的操作

        3 当我们取消关联关系的时候,由OS来释放内存

        4 可能同时存在由多组进程,都在使用不同的共享内存来进行通信,所以OS内,有多个共享内存同时存在,所以我们需要也需要通过先描述,再组织,这个方式来管理的。所以我们可以砍一下共享内存管理的数据结构来理解一下他
 

5-4 共享内存的数据结构

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 currentattaches */
    
    unsigned short shm_unused; /* compatibility */
    
    void shm_unused2; / ditto - used byDIPC */

    void shm_unused3; / unused */
};

5-5 共享内存函数

shmget 函数

功能:⽤来创建共享内存
原型
    int shmget(key_t key, size_t size, int shmflg);
参数
    key:这个共享内存段名字,通过ftok这个函数来获取

    size:共享内存⼤⼩

    shmflg:由九个权限标志构成,它们的⽤法和创建⽂件时使⽤的mode模式标志是⼀样的

取值为IPC_CREAT:共享内存不存在,创建并返回;共享内存已存在,获取并返回。

取值为IPC_CREAT | IPC_EXCL:共享内存不存在,创建并返回;共享内存已存在,出错返回。

返回值:成功返回⼀个⾮负整数,即该共享内存段的标识码;失败返回-1

        key值我们一般是通过ftok这个函数来让系统生成的。

     但是为什么要key呢?因为不同的进程,shm来进行通信,所以需要标识内存的唯一性,key来区分,不是内核直接形成的,而是在用户层,构建并传入OS的。为什么要这样呢?因为两个进程是相互独立的他们具有独立性,这就意味着,我们要访问同一个物理内存是需要标识,但是由于独立性,如果让OS来标识,那么注定会有进程拿不到这个id,所以让我们在用户层传入一个自定的key值,然后两个进程传给操作系统相同的key经过运算shmget可以返回相同的id,这样就让两个进程看到同一个物理内存了。

        这些都是共享内存函数中常见的标志位,但是我们往往是组合使用的。

        IPC_CREAT:创建共享内存,如果目标共享内存不存在,就创建,否则打开这个已经存在的共享内存并且返回(这个进经常用来获取已经存在的目标共享内存)

        IPC_CREAT | IPC_EXCL | 权限位 : IPC_EXCL 单独使用往往没有意义,所以经常是和IPC_CREAT一起使用的。如果你要创建的shm不存在,就创建它,如果已经存在,shmget返回就会出错,这个机制注定了用这个标志位,shmget成功返回,一定是一个全新的共享内存。因为共享内存也有权限,并且在这个情况下,只会返回全新的共享内存,所以往往这些标志位都是和权限位例如:0666 (0表示8进制,详情请见上位对于权限位传入的理解)。(所以这个参数常常是用来创建全新的共享内存的)

shmat函数

功能:将共享内存段连接到进程地址空间
原型
    void *shmat(int shmid, const void *shmaddr, int shmflg);
参数

    shmid: 共享内存标识

    shmaddr:指定连接的地址,如果不指定地址设为NULL就可以

    shmflg:它的两个可能取值是SHM_RND和SHM_RDONLY,一般直接设置为0默认设置就可以了

返回值:成功返回⼀个指针,指向共享内存第⼀个节;失败返回-1

        其实这个函数和malloc函数很像,因为堆的使用其实和共享内存虚拟地址空间使用是类似的.因为读写共享内存,并没有用系统调用,共享区属于用户空间,可以让用户直接使用

说明:

        shmaddr为NULL,核心自动选择⼀个地址
        shmaddr不为NULL且shmflg无SHM_RND标记,则以shmaddr为连接地址。
        shmaddr不为NULL且shmflg设置了SHM_RND标记,则连接的地址会自动向下调整为SHMLBA的整数倍。
        公式:shmaddr - (shmaddr % SHMLBA) shmflg=SHM_RDONLY,表示连接操作用来只读共享内存

shmdt函数

功能:将共享内存段与当前进程脱离

原型
    int shmdt(const void *shmaddr);

参数
    shmaddr: 由shmat所返回的指针

返回值:成功返回0;失败返回-1

注意:将共享内存段与当前进程脱离不等于删除共享内存段

shmctl函数

功能:⽤于控制共享内存
原型
    int shmctl(int shmid, int cmd, struct shmid_ds *buf);

参数
    shmid:由shmget返回的共享内存标识码

    cmd:将要采取的动作(有三个可取值)

    buf:指向⼀个保存着共享内存的模式状态和访问权限的数据结构

返回值:成功返回0;失败返回-1

注意:

        进程结束了,如果没有进行删除共享内存,共享内存资源会一直存在,因为共享内存的资源,生命周期随内核!!如果没有显示的删除,即使进程退出了,IPC资源依旧不会被删除。

5-6 共享内存的优缺点

优点:

共享内存是进程间通信中 ,速度最快的方式:

        1 映射之后,读写,直接被对方看到
        2 不需要进行系统调用获取或者写入内容

缺点:

        1 通信双方,没有所谓的“同步机制”,导致数据不一致

        2 共享内存,没有保护机制

6 system V信号量

信号量主要用于同步和互斥的,下面先来看看什么是同步和互斥。这里我们只需要简单了解即可之后在讨论线程的同步和互斥的时候会详细解释信号量

6-1 并发编程,概念铺垫

        多个执行流(进程), 能看到的同一份公共资源:共享资源
        被保护起来的共享资源叫做临界资源
        保护的方式常见:互斥与同步
        任何时刻,只允许一个执行流访问资源,叫做互斥
        多个执行流,访问临界资源的时候,具有一定的顺序性,叫做同步
        系统中某些资源一次只允许一个进程使用,称这样的资源为临界资源或互斥资源。
        在进程中涉及到互斥资源的程序段叫临界区。你写的代码=访问临界资源的代码(临界区)+不访问临界资源的代码(非临界区)
        所谓的对共享资源进行保护,本质是对访问共享资源的代码进行保护

6-2 信号量

特性方面
        IPC资源必须删除,否则不会自动清除,除非重启,所以system V IPC资源的生命周期随内核
理解方面
        信号量是⼀个计数器
作用方面
        保护临界区
本质方面
        信号量本质是对资源的预订机制
操作方面
        申请资源,计数器--,P操作
        释放资源,计数器++,V操作
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐