【Linux】进程间通信——system V 共享内存、消息队列、信号量

需要云服务器等云产品来学习Linux的同学可以移步/–>腾讯云<–/官网,轻量型云服务器低至112元/年,优惠多多。(联系我有折扣哦)

文章目录

  • 写在前面
  • 1. 共享内存
    • 1.1 共享内存的概念
    • 1.2 共享内存的原理
    • 1.3 共享内存的使用
      • 1.3.1 创建
      • 1.3.2 控制
      • 1.3.3 关联
      • 1.3.4 去关联
      • 1.3.6 实战——实现server和client端的通信
      • 1.3.7 共享内存的特点
  • 2. 消息队列(不常用)
    • 2.1 消息队列的概念
    • 2.2 消息队列的使用
      • 2.2.1 获取消息队列
      • 2.2.2 控制消息队列
      • 2.2.3 发送和接收数据
  • 3. 信号量
    • 3.1 信号量的概念扫盲
    • 3.2 内核中信号量的相关数据结构
    • 3.3 信号量的PV操作
    • 3.4 信号量相关函数
      • 3.4.1 申请信号量
      • 3.4.2 控制信号量
      • 3.4.3 信号量的操作PV

写在前面

在上一篇文章中我们讲了一种进程间通信的方式管道,管道通信的本质是基于文件的,也就是说OS没有为此做过多的设计,但是system V IPC是操作系统特地设置的一种通信方式。提供的通信方式有以下三种

  • system V 共享内存
  • system V 消息队列
  • system V 信号量

1. 共享内存

1.1 共享内存的概念

我们在上一篇文章中讲到:要实现进程间通信,就一定要让不同的进程看到同一份资源

在匿名管道/命名管道中,我们让不同进程看到同一份资源的方式是让不同进程打开同一个文件,使用对文件的读写来实现进程间通信,那么除此之外,我们还有其他方法

让不同进程能够使用同一块物理内存,就是共享内存的核心思想

1.2 共享内存的原理

由于进程具有独立性,内核数据结构包括对应的代码、数据和页表都是独立的,为了实现进程间通信需要以下过程:

1. OS申请一段空间

2. 将申请好的物理内存空间映射到一个进程地址空间

3. 将同一块物理内存空间映射到另一个需要通信的进程中

4. 通信结束之后取消进程和物理内存的映射关系,然后释放内存

  • 我们把OS申请的空间叫做共享内存
  • 进程和共享内存建立映射关系叫做挂接
  • 取消进程和共享内存之间的映射关系叫做去关联
  • 释放内存叫做释放共享内存

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第1张图片

对共享内存的理解

在C语言中,我们可以使用malloc在物理内存上申请空间,并把申请的空间经过页表映射到进程地址空间中,返回进程地址空间的指定地址,但是对于共享内存的通信方式,需要被专门设计。因为在同一时间,可能会有很多进程需要使用这种方式进行通信,所以一定会同时存在很多的共享内存,所以需要被管理起来,因此需要被专门设计

1.3 共享内存的使用

1.3.1 创建

我们使用shmget系统调用来创建共享内存

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第2张图片

头文件: 
#include 
#include 
函数原型: int shmget(key_t key, size_t size, int shmflg);
参数解释:
    key:是一个保证共享内存编号唯一性的标识符,为了让相同的进程能够看到同一个共享内存
    size:创建的共享内存的大小
    shmflg:创建共享内存的选项,通常我们使用两个:IPC_CREAT和IPC_EXCL
返回值:如果调用成功就返回一个合法的共享内存描述符shmid,如果调用失败就返回-1同时设置错误码

shmflg的选项含义:

  • IPC_CREAT:如果对应key的共享内存不存在就创建,如果存在就获取对应的shmid
  • IPC_EXCL:这个选项不能单独使用,和IPC_CREAT配合使用,如果不存在就创建,存在就出错返回

key的形成方式

我们使用一个特定的函数ftok来形成一个唯一的key

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第3张图片

头文件:
#include 
#include 
函数原型:
key_t ftok(const char *pathname, int proj_id);
参数解释:
	pathname:这是一个指向用于生成键值的路径名的C字符串指针。通常会选择一个已经存在的文件作为这个路径名,因为它可以确保唯一性。通常情况下,可以选择程序中的某个文件作为路径名,这样就可以确保不同的程序使用不同的路径名生成不同的键值。
	proj_id:这是一个整数值,用于进一步区分不同的 IPC 对象。这个值在给定路径名的范围内必须唯一。通常情况下,可以使用与程序相关的整数值作为 proj_id,以确保不同的程序使用不同的 proj_id 生成不同的键值。
返回值: 如果调用成功就返回对应的key值,调用失败就返回-1,同时设置错误码

对key和shmid的理解

key是在OS层面的,给OS看的标定共享内存的标识符,shmid是应用层的,是给我们看的,标定共享内存的标识。key和shmid的关系就像是inode和fd的关系

举个例子:

/*comm.hpp*/
#include 
#include 
#include 
#include 
#include 
#include 

#define PATHNAME "."  // 使用当前目录作为项目目录
#define PROJ_ID 0x66  // 随机的项目id
#define MAX_SIZE 4096 // 创建的共享内存大小

key_t getKey() // 封装获取key的函数
{
    key_t k = ftok(PATHNAME, PROJ_ID);
    if (k == -1)
    {
        std::cerr << errno << " : " << strerror(errno) << std::endl;
        exit(1);
    }
    return k;
}

int getShmHelper(key_t key, int flags) // 封装通过key来获取shmid的函数
{
    int shmid = shmget(key, MAX_SIZE, flags);
    if(shmid < 0)
    {
        std::cerr << errno << " : " << strerror(errno) << std::endl;
        exit(2);
    }
    return shmid;
}

int getShm(key_t key) // 用于找到已经创建的共享内存的shmid,所以传入的选项只有IPC_CREAT,不关心以前是否创建
{
    return getShmHelper(key, IPC_CREAT);
}

int createShm(key_t key) // 用于创建,所以传入的选项中有IPC_EXCL,表示如果遇到冲突就创建失败
{
    return getShmHelper(key, IPC_CREAT | IPC_EXCL | 0666); // 0666表示创建的共享内存的权限
}
/*server.cc*/
#include "comm.hpp"

int main()
{
    key_t k = getKey();
    printf("key:0x%x\n", k);
    int shmid = createShm(k);
    printf("%d\n", shmid);
    return 0;
}

/*client.cc*/
#include "comm.hpp"

int main()
{
    key_t k = getKey();
    printf("key:0x%x\n", k);
    int shmid = getShm(k);
    printf("%d\n", shmid);
    return 0;
}

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第4张图片

1.3.2 控制

上述的代码编译出来的程序第一次运行没有任何问题,但是如果再次运行server就会发现:

image-20240119234652904

这是因为创建的共享内存没有被释放,所以我们在使用完共享内存后需要释放

补充:查看IPC资源

我们可以通过命令ipcs系列指令来查看进程间通信相关信息

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第5张图片

  • 删除共享内存

    ipcrm -m + shmid

    【Linux】进程间通信——system V 共享内存、消息队列、信号量_第6张图片

当然除了命令删除之外,还可以使用系统调用来对共享内存进行删除/控制:shmctl

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第7张图片

头文件:
#include 
#include 
函数原型:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
参数解释:
	shmid:要控制的共享内存的shmid
	cmd:要执行的命令,包括:IPC_STAT,IPC_SET,IPC_RMID,IPC_INFO,SHM_INFO,SHM_STAT,SHM_LOCK,SHM_UNLOCK.这里我们先不关注其他的,只关注IPC_RMID指令,这个指令是用来释放对应的shmid的
	buf:在其他指令中,有一些是需要获取到一些信息的,buf作为输出型参数来保存相关信息
返回值:对于释放共享内存来说,0表示成功,-1表示失败

使用shell脚本监视共享内存的情况

while :; do ipcs -m ; echo "##########################################"; sleep 1; done
#include "comm.hpp"

int main()
{
    key_t k = getKey();
    printf("key:0x%x\n", k);
    int shmid = createShm(k);
    printf("%d\n", shmid);

    sleep(5);
    shmctl(shmid, IPC_RMID, nullptr); // 这里不需要获取信息,传入nullptr即可
    return 0;
}

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第8张图片

1.3.3 关联

在本节开始,我们说过有一个过程叫做把物理内存和进程地址空间关联起来,我们会使用一个系统调用关联:shmat,这里的at取attach的意义

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第9张图片

头文件:
#include 
#include 
函数原型:
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数解释:
	shmid:需要关联的shmid
	shmaddr:关联到进程地址空间的地址,我们绝大多数时间是不指定的,所以传nullptr即可
	shmflg:关联的选项,默认为0,表示读写权限
返回值:
	关联成功返回共享内存映射到进程地址空间中的起始地址,失败返回-1并设置错误码
void *attachShm(int shmid)
{
    void *mem = shmat(shmid, nullptr, 0);
    if((long long)mem == -1L) // 这里由于我们的机器是64位的,所以一个地址占8个字节,所以需要转成long long类型判断是否正确关联
    {
        std::cout << errno << " : " << strerror(errno) << std::endl;
        exit(3);
    }
    return mem;
}

1.3.4 去关联

有关联,那么对应的就有去关联的操作,去关联使用的系统调用是shmdt

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第10张图片

头文件:
#include 
#include 
函数原型:
int shmdt(const void *shmaddr);
参数解释:
	shmaddr:需要去关联的进程地址空间
返回值:
	如果调用成功就返回0,否则就返回-1,同时设置错误码
void detachShm(void* start)
{
    if(shmdt(start) == -1)
    {
        std::cerr << errno << " : " << strerror(errno) << std::endl;
    }
}

1.3.6 实战——实现server和client端的通信

/*comm.hpp*/
#pragma once
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define PATHNAME "."  // 使用当前目录作为项目目录
#define PROJ_ID 0x66  // 随机的项目id
#define MAX_SIZE 4096 // 创建的共享内存大小

key_t getKey() // 封装获取key的函数
{
    key_t k = ftok(PATHNAME, PROJ_ID);
    if (k == -1)
    {
        std::cerr << errno << " : " << strerror(errno) << std::endl;
        exit(1);
    }
    return k;
}

int getShmHelper(key_t key, int flags) // 封装通过key来获取shmid的函数
{
    int shmid = shmget(key, MAX_SIZE, flags);
    if(shmid < 0)
    {
        std::cerr << errno << " : " << strerror(errno) << std::endl;
        exit(2);
    }
    return shmid;
}

int getShm(key_t key) // 用于找到已经创建的共享内存的shmid,所以传入的选项只有IPC_CREAT,不关心以前是否创建
{
    return getShmHelper(key, IPC_CREAT);
}

int createShm(key_t key) // 用于创建,所以传入的选项中有IPC_EXCL,表示如果遇到冲突就创建失败
{
    return getShmHelper(key, IPC_CREAT | IPC_EXCL | 0666); // 0666表示创建的共享内存的权限
}

void delShm(int shmid)
{
    if(shmctl(shmid, IPC_RMID, nullptr) == -1)// 这里不需要获取信息,传入nullptr即可
    {
        std::cerr << errno << " : " << strerror(errno) << std::endl;
    }
}

void *attachShm(int shmid)
{
    void *mem = shmat(shmid, nullptr, 0);
    if((long long)mem == -1L) // 这里由于我们的机器是64位的,所以一个地址占8个字节,所以需要转成long long类型判断是否正确关联
    {
        std::cerr << errno << " : " << strerror(errno) << std::endl;
        exit(3);
    }
    return mem;
}

void detachShm(void* start)
{
    if(shmdt(start) == -1)
    {
        std::cerr << errno << " : " << strerror(errno) << std::endl;
    }
}
/*server.cc*/
#include "comm.hpp"

int main()
{
    key_t k = getKey();                     // 通过共同的pathname和proj_id构建一个相互通信的进程之间的key
    int shmid = createShm(k);               // 通过创建的key创建一段共享内存
    char *start = (char *)attachShm(shmid); // 将这段共享内存和当前进程地址空间关联

    // 使用共享内存通信
    while (true)
    {
        std::cout << "client say# " << start << std::endl; // 这里可以直接读取通信信息,因为地址相同
        struct shmid_ds ds;
        shmctl(shmid, IPC_STAT, &ds); //    获取shmid的相关信息
        printf("获取属性:size:%d,pid:%d,myself:%d", ds.shm_segsz, ds.shm_cpid);
        sleep(1);
    }

    delShm(shmid); // 使用完之后去关联
    delShm(shmid); // 谁创建的共享内存谁来释放
    return 0;
}
/*client*/
#include "comm.hpp"

int main()
{
    key_t k = getKey();                     // 通过共同的pathname和proj_id构建一个相互通信的进程之间的key
    int shmid = getShm(k);                  // 通过创建的key获取指定的共享内存
    char *start = (char *)attachShm(shmid); // 将这段共享内存和当前进程地址空间关联

    // 使用共享内存通信
    const char *message = "hello server,我是另一个进程,正在和你通信"; // 通信信息
    pid_t id = getpid();
    int count = 1;
    while (true)
    {
        sleep(5);
        snprintf(start, MAX_SIZE, "%s[pid:%d][消息编号:%d]", message, id, count++); // 直接讲通信信息写到start中即可
    }

    delShm(shmid); // 使用完之后去关联
    return 0;
}

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第11张图片

1.3.7 共享内存的特点

共享内存的生命周期是随OS的,而不是随进程的,这是所有System V进程间通信的共性

共享内存的优点:共享内存是所有进程间通信速度是最快的,因为共享内存是被双方所共享,只要写入对方就能立即看到,能大大减少数据的拷贝次数。

但是综合考虑管道和共享内存,考虑键盘输入,和显示器输出,对于同一份数据:共享内存有几次数据拷贝,管道有几次数据拷贝

管道:需要通过键盘输入到自己定义的缓冲区char buffer[],将数据拷贝到buffer中,调用write接口在把buffer里的数据拷贝到管道里,

另一进程也有定义buffer缓冲区,调用read读取把数据从管道里读取到buffer里,在把数据显示到显示器上:

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第12张图片

共享内存:通过映射关系

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第13张图片

共享内存的缺点:不给我们进行同步和互斥的操作,没有对数据做任何保护。客户端和服务端没做保护,如果想做保护要用到信号量,对共享内存进行保护,写完通过读端进行读取。

2. 消息队列(不常用)

2.1 消息队列的概念

消息队列是OS提供的内核级队列,消息队列提供了一个从一个进程向另外一个进程发送一块数据的方法,每个数据块都被认为是有一个类型,接收者进程接收的数据块可以有不同的类型值

 struct msqid_ds {
     struct ipc_perm msg_perm;     /* Ownership and permissions */
     time_t          msg_stime;    /* Time of last msgsnd(2) */
     time_t          msg_rtime;    /* Time of last msgrcv(2) */
     time_t          msg_ctime;    /* Time of last change */
     unsigned long   __msg_cbytes; /* Current number of bytes in
                                                queue (nonstandard) */
     msgqnum_t       msg_qnum;     /* Current number of messages
                                                in queue */
     msglen_t        msg_qbytes;   /* Maximum number of bytes
                                                allowed in queue */
     pid_t           msg_lspid;    /* PID of last msgsnd(2) */
     pid_t           msg_lrpid;    /* PID of last msgrcv(2) */
 };

消息队列数据结构的第一个成员是msg_perm,它和shm_perm是同一个类型的结构体变量,ipc_perm结构体的定义如下:

struct ipc_perm {
    key_t          __key;       /* Key supplied to msgget(2) */
    uid_t          uid;         /* Effective UID of owner */
    gid_t          gid;         /* Effective GID of owner */
    uid_t          cuid;        /* Effective UID of creator */
    gid_t          cgid;        /* Effective GID of creator */
    unsigned short mode;        /* Permissions */
    unsigned short __seq;       /* Sequence number */
};

2.2 消息队列的使用

2.2.1 获取消息队列

msgget

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第14张图片

2.2.2 控制消息队列

msgctl

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第15张图片

2.2.3 发送和接收数据

msgsndmsgrcv

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第16张图片

3. 信号量

关于信号量的知识,我们将会在后面多线程的地方详细讲解,这里先进行一些概念的扫盲

3.1 信号量的概念扫盲

  • 信号量的本质是一个计数器**,通常用来表示公共资源中,资源数的多少问题。信号量主要用于同步和互斥的。

  • 公共资源:能被多个进程同时访问的资源,访问没有保护的公共资源可能会导致数据不一致问题。要让不同的进程看到同一份资源是为了通信,通信是为了让进程间实现协同,而进程之间具有独立性,所以为了解决独立性问题要让进程看到同一份资源,但是会导致数据不一致的问题。

  • 临界资源:被保护起来的公共资源

  • 临界区:进程要使用资源一定是该进程有对应的代码来访问这部分临界资源,这段代码就是临界区,但是多个进程看到同一份资源是少数情况,大部分申请自己的资源用自己的代码区访问。

  • 非临界区:不访问公共资源的代码。

如何保护公共资源:互斥&&同步

互斥:由于各进程要求共享资源,而且有些资源需要互斥使用,因此各进程间竞争使用这些资源,进程的这种关系为进程的互斥

原子性:要么不做、要么做完两态的这种情况。比如支付转账

如果用全局的整数来替代信号量?

全局的整数在父子关系的进程上都看不到,要发生写时拷贝,而不同的进程更看不到,所以进程间想看到同一个计数器得让进程看到同一个计数器。

为什么要信号量?

当我们想要某种资源的时候可以通过信号量进行预;,共享资源被使用的方式:作为一个整体使用;划分成为一个一个的资源部分

3.2 内核中信号量的相关数据结构

struct semid_ds {
    struct ipc_perm sem_perm;  /* Ownership and permissions */
    time_t          sem_otime; /* Last semop time */
    time_t          sem_ctime; /* Last change time */
    unsigned long   sem_nsems; /* No. of semaphores in set */
};

信号量数据结构的第一个成员也是ipc_perm类型的结构体变量,ipc_perm结构体的定义如下:

struct ipc_perm {
    key_t          __key; /* Key supplied to semget(2) */
    uid_t          uid;   /* Effective UID of owner */
    gid_t          gid;   /* Effective GID of owner */
    uid_t          cuid;  /* Effective UID of creator */
    gid_t          cgid;  /* Effective GID of creator */
    unsigned short mode;  /* Permissions */
    unsigned short __seq; /* Sequence number */
};

3.3 信号量的PV操作

我们知道信号量本质上就是一个临界资源的计数器,有进程要使用这个临界资源,就会导致可用的临界资源数量减少,使用完之后归还临界资源会导致可用的临界资源增多

  • P操作:向OS预定临界资源,会导致现有临界资源减少
  • V操作:向OS归还临界资源,会导致现有临界资源增加

假设信号量为sem,那么P操作相当于sem++,V操作相当于sem--,注意这里的++ 和 - -操作都是原子的

如果信号量的初始值是1就代表了访问公共资源作为一个整体来使用。二元信号量提供互斥功能

3.4 信号量相关函数

3.4.1 申请信号量

semget

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第17张图片

3.4.2 控制信号量

semctl

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第18张图片

3.4.3 信号量的操作PV

semop

【Linux】进程间通信——system V 共享内存、消息队列、信号量_第19张图片


关于system V标准的进程间通信的思考

我们可以发现,共享内存、消息队列、信号量接口相似度非常高,获取与删除,都是system V标准的进程间通信。

OS如何管理:先描述,在组织,对相关资源的内核数据结构做管理,对于共享内存、消息队列、信号量的第一个成员都是ipc_perm:

struct ipc_perm {
    key_t          __key;    /* Key supplied to shmget(2) */
    uid_t          uid;      /* Effective UID of owner */
    gid_t          gid;      /* Effective GID of owner */
    uid_t          cuid;     /* Effective UID of creator */
    gid_t          cgid;     /* Effective GID of creator */
    unsigned short mode;     /* Permissions + SHM_DEST and
                                           SHM_LOCKED flags */
    unsigned short __seq;    /* Sequence number */
};

虽然内部的属性差别很大,但是维护它们的数据结构的第一个成员确实一样的,都是ipc_perm类型的成员变量,都可以通过key来标识唯一性。这样设计的好处:在操作系统内可以定义一个struct ipc_perm类型的数组,此时每当我们申请一个IPC资源,就在该数组当中开辟一个这样的结构。((struct shmid_ds*)perms[0],强转,此时就可以访问其他剩下的属性)


本节完…

你可能感兴趣的:(Linux,linux,服务器)