【Linux】System V 共享内存

一、System V共享内存的原理

共享内存让不同进程看到同一份资源的方式就是,在物理内存当中申请一块内存空间,然后将这块内存空间分别与各个进程各自的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。

共享内存的内核数据结构

在系统当中可能会有大量的进程在进行通信,因此系统当中就可能存在大量的共享内存,那么操作系统必然要对其进行管理,所以共享内存除了在内存当中真正开辟空间之外,系统一定还要为共享内存维护相关的内核数据结构。

共享内存的数据结构如下:

/* Obsolete, used only for backwards compatibility and libc5 compiles */
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 */
};

可以看到上面共享内存数据结构的第一个成员是shm_perm,shm_perm是一个ipc_perm类型的结构体变量,每个共享内存的key值存储在shm_perm这个结构体变量当中,其中ipc_perm结构体的定义如下:

/* Obsolete, used only for backwards compatibility and libc5 compiles */
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;
};

在Linux系统中,key 是一个关键字段,用于标识 System V IPC 对象的唯一性。对于共享内存(Shared Memory),key 的唯一性保证了在系统范围内的IPC对象之间的区分。key 是一个32位的整数值,用于唯一标识一个IPC对象,包括共享内存。开发者在创建IPC对象时可以指定一个 key 值,而系统会根据这个值来唯一地标识该对象。它的特性如下:

  • 唯一性: 每个IPC对象的 key 都应该是唯一的。在系统中,通过不同的 key 值来区分不同的共享内存段。如果两个共享内存段的 key 相同,它们将被视为同一IPC对象。

  • 用户自定义: 开发者可以自己选择 key 的值,通常可以使用 ftok() 函数将文件路径和项目标识符(project identifier)转换为 key 值。这种方法可以确保在不同的程序中使用相同的文件路径和项目标识符生成相同的 key 值,从而在不同的进程间共享同一个IPC对象。

我们待会实现server&client通信就用到了这种方法,pathname和proj_id被server和client进程共用,以便使用 ftok() 来生成出相同的key,唯一确定共享的内存。

const std::string pathname="/home/chen/linux-learning/shm";
const int proj_id = 0x11223344;
  
// 共享内存的大小,建议设计成4096的整数倍
const int size = 4096;
  
key_t GetKey()
{
    key_t key = ftok(pathname.c_str(), proj_id);
    if(key < 0)
    {
        std::cerr << "errno: " << errno 
                  << ", errstring: " << strerror(errno) 
                  << std::endl;
        exit(1);
    }
}

二、共享内存的使用

1. 创建

shmget()系统调用创建shm

创建共享内存我们需要用shmget函数,shmget函数的函数原型如下:
请添加图片描述

shmget的参数说明:

  • 第一个参数key,表示待创建共享内存在系统当中的唯一标识。
  • 第二个参数size,表示待创建共享内存的大小。
  • 第三个参数shmflg,表示创建共享内存的方式。

shmget的返回值说明:

  • shmget调用成功,返回一个有效的共享内存标识符(用户层标识符)。
  • shmget调用失败,返回-1。

注意:
我们把具有标定某种资源能力的东西叫做句柄,而这里shmget函数的返回值实际上就是共享内存的句柄,这个句柄可以在用户层标识共享内存,当共享内存被创建后,我们在后续使用共享内存的相关接口时,都是需要通过这个句柄对指定共享内存进行各种操作

说明一下参数key和shmflg:

  1. 关于传入shmget函数的第一个参数key,需要我们使用ftok函数进行获取

ftok函数的函数原型如下:
请添加图片描述
ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值。将来在用shm实现进程间通信的时候,不同的进程使用相同的pathname和proj_id,通过ftok得到的key都是相同的,把key给到shmget即可获得相同的共享内存句柄。

const std::string pathname="/home/chen/linux-learning/shm";
const int proj_id = 0x11223344;
const int size = 4096;// 共享内存的大小,建议设计成4096的整数倍

key_t GetKey()
{
    key_t key = ftok(pathname.c_str(), proj_id);
    if(key < 0)
    {
        std::cerr << "errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
        exit(1);
    }
    return key;
}

[!Question] 为什么要用户自己设置key,而不是操作系统帮我们做?
ftok函数的作用就是,将一个已存在的路径名pathname和一个整数标识符proj_id转换成一个key值。将来在用shm实现进程间通信的时候,不同的进程使用相同的pathnameproj_id,通过ftok得到的key都是相同的,而且这个key只有这些进程自己知道,把key给到shmget即可获得相同的共享内存的句柄,以使用约定的那块共享内存。


  1. 第三个参数shmflg,常用的组合方式有以下两种:

组合方式作用
IPC_CREAT如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则直接返回该共享内存的句柄
PC_CREAT|IPC_EXCL如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;如果存在这样的共享内存,则出错返回

甚至可以设置权限 (int32位中的最低的9位) 指定授予所有者、组和全局的权限。格式和含义与open(2)的mode参数相同。
请添加图片描述

在命令行中查询共享内存

使用ipcs命令时,会默认列出消息队列、共享内存以及信号量相关的信息,若只想查看它们之间某一个的相关信息,可以选择携带以下选项:

  • -q:列出消息队列相关信息。
  • -m:列出共享内存相关信息。
  • -s:列出信号量相关信息。

ipcs命令输出的每列信息的含义如下:

标题含义
key系统区别各个共享内存的唯一标识
shmid共享内存的用户层id(句柄)
owner共享内存的拥有者
perms共享内存的权限
bytes共享内存的大小
nattch关联共享内存的进程数
status共享内存的状态

[!Attention] 注意:

  • key: 不要在应用层使用,key只用来在内核中标识shm的唯一性! - 类比文件描述符 fd
  • shmid:应用这个共享内存的时候,我们使用shmid来进行操作共享内存。 - 类比 FILE*

2. 释放

如果进程不主动删除创建的共享内存,那么共享内存就会一直存在,直到关机重启(system V IPC都是如此),同时也说明了IPC资源是由Linux内核提供并维护的。

使用命令释放共享内存资源

可以使用ipcrm -m shmid命令释放指定id的共享内存资源

ipcrm -m [shmid]

请添加图片描述

使用shmctl释放共享内存资源

shmctl 用于对共享内存段进行控制操作,可以实现共享内存的删除功能。下面是 shmctl 系统调用的函数原型:

#include <sys/ipc.h>
#include <sys/shm.h>

int shmctl(int shmid, int cmd, struct shmid_ds *buf);
  • 参数说明:

    1. shmid: 共享内存标识符,它是由 shmget 函数返回的标识符,用于唯一标识一个共享内存段。
    2. cmd: 控制命令,表示对共享内存的执行的操作。可以使用以下命令:
      • IPC_STAT: 获取共享内存的状态信息,将共享内存的信息填充到 buf 中。
      • IPC_SET: 设置共享内存的状态信息,使用 buf 中提供的信息。
      • IPC_RMID: 删除共享内存段,释放资源。
    3. buf: 一个指向 struct shmid_ds 结构的指针,用于传递或接收共享内存的状态信息。
  • 返回值说明:

    • 成功时,返回0。
    • 失败时,返回-1,并设置全局变量 errno 来指示错误的原因。

3. 关联

将共享内存连接到进程地址空间我们需要用shmat函数,shmat函数的函数原型如下:

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

void *shmat(int shmid, const void *shmaddr, int shmflg);
  • shmat函数的参数说明

    • 第一个参数shmid,表示待关联共享内存的用户级标识符。
    • 第二个参数shmaddr,指定共享内存映射到进程地址空间的某一地址,通常设置为NULL,表示让内核自己决定一个合适的地址位置。
    • 第三个参数shmflg,表示关联共享内存时设置的某些属性。
  • shmat函数的返回值说明

    • shmat调用成功,返回共享内存映射到进程地址空间中的起始地址。
    • shmat调用失败,返回(void*)-1。

4. 去关联

取消共享内存与进程地址空间之间的关联我们需要用shmdt函数,shmdt函数的函数原型如下:

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

int shmdt(const void *shmaddr);
  • shmdt函数的参数说明
    待去关联共享内存的起始地址,即调用shmat函数时得到的起始地址。

  • shmdt函数的返回值说明

    • shmdt调用成功,返回0。
    • shmdt调用失败,返回-1。

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

在知道了共享内存的创建、关联、去关联以及释放后,现在可以尝试让两个进程通过共享内存进行通信了。

为了让服务端和客户端在使用ftok函数获取key值时,能够得到同一种key值,那么服务端和客户端传入ftok函数的路径名和和整数标识符必须相同,这样才能生成同一种key值,进而找到同一个共享资源进行挂接。这里我们可以将这些需要共用的信息放入一个头文件当中,服务端和客户端共用这个头文件即可。

共用的头文件:comm.h

#pragma once

#include <iostream>
#include <cstdlib>
#include <string>
#include <cstring>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>

const std::string pathname = "/home/chen/linux-learning/shm";
const int proj_id = 0x112233;

const std::string filename = "fifo";

// 共享内存的大小,建议设计成4096的整数倍
const int size = 4096;

key_t GetKey()
{
    key_t key = ftok(pathname.c_str(), proj_id);
    if (key < 0)
    {
        std::cerr << "ftok, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
        exit(1);
    }

    return key;
}

std::string ToHex(int id)
{
    char buffer[1024];
    snprintf(buffer, sizeof(buffer), "0x%x", id);
    return buffer;
}


int CreateShmHelper(key_t key, int flag)
{
    int shmid = shmget(key, size, flag);
    if (shmid < 0)
    {
        std::cerr << "shmget, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
        exit(2);
    }
}

int CreateShm(key_t key)
{
    // 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;
    // 如果存在这样的共享内存,则出错返回
    return CreateShmHelper(key, IPC_CREAT | IPC_EXCL | 0666);
}

int GetShm(key_t key)
{
    // 如果内核中不存在键值与key相等的共享内存,则新建一个共享内存并返回该共享内存的句柄;
    // 如果存在这样的共享内存,则直接返回该共享内存的句柄
    return CreateShmHelper(key, IPC_CREAT);
}

bool MakeFifo()
{
    int n = mkfifo(filename.c_str(), 0666);
    if (n < 0)
    {
        std::cerr << "mkfifo, errno: " << errno << ", errstring: " << strerror(errno) << std::endl;
        return false;
    }

    std::cout << "mkfifo success... read" << std::endl;
    return true;
}

server端负责创建共享内存,server和client挂接到同一块共享内存之后,client先向共享内存写入‘a’,再通过命名管道fifo来通知server写入完毕,server端的read就读到了管道中的数据,开始打印共享内存中的内容。

server.cpp:

#include "comm.hpp"

class Init
{
public:
    Init()
    {
        // 使用管道通信
        bool r = MakeFifo();
        if (!r) exit(1);

        key_t key = GetKey(); //获取key值
        std::cout << "key: " << key << std::endl;
        // sleep(3);

        // key vs shmid
        // key: 不要在应用层使用,只用来在内核中标识shm的唯一性!  类比fd
        // shmid:应用这个共享内存的时候,我们使用shmid来进行操作共享内存   类比FILE*

        shmid = CreateShm(key);
        std::cout << "shmid: " << shmid << std::endl;
        // sleep(5);

        std::cout << "开始将shm映射到进程的地址空间中" << std::endl;
        s = (char*)shmat(shmid, nullptr, 0);
        std::cout << "映射完成" << std::endl;

        fd = open(filename.c_str(), O_RDONLY); // 打开管道,阻塞等待
        std::cout << "fd: " << fd << std::endl;
    }
    ~Init()
    {
        // sleep(5);
        shmdt(s);
        std::cout << "开始将shm从进程的地址空间中移除" << std::endl;

        // sleep(5);
        shmctl(shmid, IPC_RMID, nullptr);
        std::cout << "开始将shm从OS中删除" << std::endl;

        close(fd);
        unlink(filename.c_str());
    }
public:
    int shmid;
    int fd;
    char* s;
};

int main()
{
    Init init;

    // todo
    while (true)
    {
        // wait
        int code = 0;
        ssize_t n = read(init.fd, &code, sizeof(code));
        if (n > 0)
        {
            std::cout << "共享内存的内容:" << init.s << std::endl;
            sleep(1);
        }
        else if (n == 0)
        {
            break;
        }
    }

    return 0;
}

client.cpp:

#include <iostream>
#include <cstring>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <unistd.h>

#include "comm.hpp"

int main()
{
    key_t key = GetKey();
    int shmid = GetShm(key);
    char* s = (char*)shmat(shmid, nullptr, 0);
    std::cout << "attach shm done" << std::endl;
    int fd = open(filename.c_str(), O_WRONLY);

    // sleep(10);
    char c = 'a';
    for (; c <= 'e'; c++)
    {
        s[c - 'a'] = c;
        std::cout << "client write: " << c << " done!" << std::endl;
        sleep(1);

        // 通知server写入完毕
        int code = 1;
        write(fd, &code, sizeof(code));
    }

    shmdt(s);
    std::cout << "detach shm done" << std::endl;

    //sleep(5);
    close(fd);
    std::cout << "管道写端关闭!!" << std::endl;
    //sleep(5);

    return 0;
}

[!Question] 两个问题:

  1. 为什么server一定会等待client把要写进共享内存中的数据写完?
    实际上server端的read系统调用在读取管道数据的时候,是阻塞等待的,就是说server会卡在read来等待client向管道里写数据,管道里有数据write才会读到数据并返回。也就是说管道里没有数据,程序就卡在read这里了。

  1. 不启动client,server就不会继续,为什么?
    请添加图片描述
    因为server要打开管道的读端,但是会阻塞等待,因为要等到client把管道的写端打开,server中的open才会停止阻塞等待并返回。

梳理一下就是:

  • 在创建一个命名管道(FIFO)之后,如果进程1打开了读端,而其他进程迟迟不打>开它的写端,open 函数在进程1中通常会阻塞,等待直到有其他进程打开了端。

  • 在默认情况下,打开一个管道的读端或写端,如果对应的另一端没有被打开,打开操作会一直阻塞,直到另一端被打开为止。这是因为管道的通信是基于两个进程之间的协作的,当一个进程试图打开读端时,它可能期望有其他进程打开相应的写端,并且在没有写端打开的情况下,读端打开可能会被阻塞。

  • 启动了client之后,server正常打印。当client把管道的写端的fd关闭的时候,server这边的read会直接返回0,server中的死循环被break,然后调用init的析构进行资源清理,然后正常退出。

运行之前可以使用以下监控脚本时刻关注共享内存的资源分配情况:

while :; do ipcs -m;echo "###################################";sleep 1;done

请添加图片描述