【Linux 系统篇(三十三)】通信(三):System V 共享内存详解

  
大家好,欢迎来到 huangjin007_ 的博客
⭐ 个人主页:huangjin007_
🔥 文章收录专栏:Linux 内功修炼手册(系统篇)
总会有一些坚持
能从冰封的土地里
培育出十万朵怒放的蔷薇


Linux 系统篇(三十三) ------ System V 共享内存详解

文章目录

  • [Linux 系统篇(三十三) ------ System V 共享内存详解](#Linux 系统篇(三十三) —— System V 共享内存详解)
    • 一、为什么我们需要共享内存?
      • [1.1 什么是共享内存?](#1.1 什么是共享内存?)
      • [1.2 为什么共享内存的速度快?](#1.2 为什么共享内存的速度快?)
      • [1.3 共享内存是不是完美的?](#1.3 共享内存是不是完美的?)
    • [二、System V 共享内存的整体使用流程](#二、System V 共享内存的整体使用流程)
      • [2.1 共享内存的使用流程](#2.1 共享内存的使用流程)
    • [三、共享内存的标识:key 和 shmid 有什么区别?](#三、共享内存的标识:key 和 shmid 有什么区别?)
      • [3.1 key:用于定位 IPC 对象的键值](#3.1 key:用于定位 IPC 对象的键值)
      • [3.2 shmid:共享内存的标识符](#3.2 shmid:共享内存的标识符)
    • 四、shmget():创建或获取共享内存
      • [4.1 函数原型](#4.1 函数原型)
      • [4.2 IPC_CREAT:不存在就创建](#4.2 IPC_CREAT:不存在就创建)
      • [4.3 IPC_CREAT | IPC_EXCL:必须创建一个新的](#4.3 IPC_CREAT | IPC_EXCL:必须创建一个新的)
      • [4.4 共享内存大小有什么要求?](#4.4 共享内存大小有什么要求?)
    • 五、shmat():把共享内存连接到当前进程
      • [5.1 函数原型](#5.1 函数原型)
      • [5.2 为什么通常传入 nullptr?](#5.2 为什么通常传入 nullptr?)
      • [5.3 shmat() 如何判断失败?](#5.3 shmat() 如何判断失败?)
      • [5.4 SHM_RDONLY:以只读方式映射](#5.4 SHM_RDONLY:以只读方式映射)
    • 六、shmdt():解除映射不等于删除共享内存
      • [6.1 函数原型](#6.1 函数原型)
      • [6.2 为什么解除映射不等于删除?](#6.2 为什么解除映射不等于删除?)
    • 七、shmctl():查询、修改和删除共享内存
      • [7.1 函数原型](#7.1 函数原型)
      • [7.2 IPC_STAT:获取共享内存属性](#7.2 IPC_STAT:获取共享内存属性)
      • [7.3 shmid_ds 结构体有什么用?](#7.3 shmid_ds 结构体有什么用?)
      • [7.4 IPC_RMID:删除共享内存](#7.4 IPC_RMID:删除共享内存)
      • [7.5 IPC_SET:修改共享内存属性](#7.5 IPC_SET:修改共享内存属性)
    • 八、实例一:使用共享内存实现进程间通信
      • [8.1 项目结构](#8.1 项目结构)
      • [8.2 先封装共享内存:Shm.hpp](#8.2 先封装共享内存:Shm.hpp)
      • [8.3 FIFO 封装:Fifo.hpp](#8.3 FIFO 封装:Fifo.hpp)
      • [8.4 服务器代码:server.cc](#8.4 服务器代码:server.cc)
      • [8.5 客户端代码:client.cc](#8.5 客户端代码:client.cc)
      • [8.6 Makefile:编译程序](#8.6 Makefile:编译程序)
      • [8.7 运行程序](#8.7 运行程序)
    • [九、运行报错:shmget: File exists 是怎么回事?](#九、运行报错:shmget: File exists 是怎么回事?)
      • [9.1 使用 ipcs 查看共享内存](#9.1 使用 ipcs 查看共享内存)
      • [9.2 如何删除遗留的共享内存?](#9.2 如何删除遗留的共享内存?)
    • 十、实例二:只有共享内存,没有同步会发生什么?
      • [10.1 服务器代码](#10.1 服务器代码)
      • [10.2 客户端代码](#10.2 客户端代码)
      • [10.3 为什么 sleep(1) 不能解决同步问题?](#10.3 为什么 sleep(1) 不能解决同步问题?)
      • [10.4 共享内存本身不会自动提供互斥](#10.4 共享内存本身不会自动提供互斥)
    • 结语:

一、为什么我们需要共享内存?

  在前面学习 Linux 进程间通信时,我们已经接触过匿名管道、命名管道等通信方式。

  这些方式都能让不同进程交换数据,但它们有一个共同特点:数据需要经过内核提供的通信机制进行传递。

  例如,使用管道通信时,通常是一个进程调用 write() 写入数据,另一个进程调用 read() 读取数据。数据在内核管道缓冲区中完成传递。

  这种方式已经很好用了,但如果两个进程需要频繁交换大量数据呢?

  假设客户端要发送一份 100 MB 的数据给服务器。如果每次传输都需要经过内核提供的通信缓冲区,就可能产生额外的数据复制和系统调用开销。

  有没有一种方式,可以让两个进程直接访问同一块内存,而不必每次传递数据都经过内核?

  答案就是:共享内存(Shared Memory)。

1.1 什么是共享内存?

  共享内存的核心思想非常简单:

  操作系统在内核中创建一块内存区域,然后让多个进程将这块内存映射到各自的虚拟地址空间中。

  这样一来,不同进程就能通过各自的指针,访问同一份实际的共享数据。

  我们可以用下面的示意图理解。

  注意,进程 A 和进程 B 中的指针不一定相同,但它们最终访问的是同一块共享内存。

例如:

  • 进程 A 在共享内存中写入 Hello。
  • 进程 B 从共享内存中读取数据。
  • 进程 B 就能读取到 Hello,不需要进程 A 再通过管道把这段数据复制给它。

  这里有一个重要细节:

  共享内存共享的是数据所在的物理内存页,而不是两个进程的整个地址空间。

  每个进程仍然有自己的虚拟地址空间,只不过操作系统通过内存映射,让不同进程的某些虚拟地址指向同一组物理内存页。

1.2 为什么共享内存的速度快?

  我们把管道和共享内存放在一起比较。

  使用管道时,数据通常需要通过内核中的管道缓冲区进行传递。

  使用共享内存时,两个进程建立映射后,可以直接对共享区域进行读写,不必为了每次数据交换都调用系统调用来传递数据。

  因此,共享内存通常是 Linux 进程间通信中速度最快的方式之一,尤其适合频繁交换大量数据的场景。

  不过,需要注意:共享内存并不是完全不需要内核参与。

  创建共享内存、建立映射、解除映射以及删除共享内存等操作,都需要通过系统调用完成。

  它的优势在于:映射完成后,普通的数据读写通常不需要再通过内核通信缓冲区中转。

1.3 共享内存是不是完美的?

  当然不是。

  共享内存虽然快,但也带来了一个新问题:

  如果两个进程同时访问同一块内存,应该怎么办?

  例如,客户端正在修改一段字符串,服务器恰好在读取这段字符串。服务器就可能读到一半新数据、一半旧数据,或者读到不完整的字符串。

  共享内存本身不会自动帮我们解决这些问题。

  所以,我们需要区分两个概念:

  • 通信: 让不同进程能够访问并交换数据。
  • 同步与互斥: 控制不同进程访问共享数据的时机,避免出现并发问题。

  共享内存解决的是第一个问题。第二个问题,需要借助信号量、互斥锁、条件变量或其他合适的同步机制来解决。

  理解了这一点,我们再来看 System V 共享内存的具体实现。


二、System V 共享内存的整体使用流程

  Linux 提供了多种共享内存实现方式,例如 System V 共享内存和 POSIX 共享内存。

  本篇主要介绍 System V 共享内存。

  它通过一组专门的系统调用管理共享内存,主要包括:

函数 作用
ftok() 根据路径名和项目标识生成一个 IPC 键值
shmget() 创建共享内存或获取已有共享内存的标识符
shmat() 将共享内存映射到当前进程的地址空间
shmdt() 将共享内存从当前进程的地址空间中解除映射
shmctl() 查询、修改或删除共享内存

  这些函数看起来不少,但只要把整个流程串起来,就很容易理解。

2.1 共享内存的使用流程

这里有两个非常容易混淆的地方。

  第一,shmget() 返回的不是共享内存地址,而是共享内存的标识符。

  第二,shmat() 才会返回一个指向共享内存的指针。

  可以把它们理解为:

  • shmget():找到或创建一间公共仓库,拿到仓库编号。
  • shmat():打开仓库与当前进程之间的访问通道,得到可以使用的地址。
  • shmdt():关闭当前进程的访问通道。
  • shmctl(..., IPC_RMID, ...):请求删除这间公共仓库。

三、共享内存的标识:key 和 shmid 有什么区别?

  在学习函数之前,我们先解决一个问题。

  如果客户端和服务器是两个完全独立的进程,它们怎么知道应该使用哪一块共享内存?

  System V 共享内存涉及两个重要概念:key 和 shmid。

3.1 key:用于定位 IPC 对象的键值

  key 的类型是 key_t,它用于标识一个 System V IPC 对象。

  客户端和服务器可以根据约定,使用相同的路径名和项目标识,通过 ftok() 生成相同的 key。

例如:

cpp 复制代码
key_t key = ftok(".", 0x66);

其中:

  • "." 表示当前目录。
  • 0x66 是项目标识参数。

  ftok() 的作用是根据路径名和项目标识生成一个 IPC key。

  需要注意,ftok() 不是严格意义上的唯一编号生成器,不同参数组合可能产生相同的 key。因此,它适合用于协作进程之间约定 IPC 对象,不适合用来保证全局唯一性。

3.2 shmid:共享内存的标识符

  调用 shmget() 后,会得到一个共享内存标识符,也就是 shmid。

例如:

cpp 复制代码
int shmid = shmget(key, 4096, IPC_CREAT | 0666);

这里:

  • key 用于定位共享内存对象。
  • 4096 表示请求的共享内存大小为 4096 字节。
  • IPC_CREAT | 0666 表示必要时创建共享内存,并设置相应权限。

  如果操作成功,shmget() 返回一个非负整数,即 shmid。

  后续的 shmat()、shmdt() 所需的地址或参数,以及 shmctl() 的操作,都围绕这个共享内存对象展开。


四、shmget():创建或获取共享内存

4.1 函数原型

cpp 复制代码
int shmget(key_t key, size_t size, int shmflg);

  它的作用是:创建一个新的共享内存段,或者获取一个已经存在的共享内存段的标识符。

参数说明:

参数 含义
key 共享内存的键值
size 请求的共享内存大小,单位是字节
shmflg 创建标志和权限标志
返回值 成功返回非负 shmid,失败返回 -1

4.2 IPC_CREAT:不存在就创建

例如:

cpp 复制代码
int shmid = shmget(key, 4096, IPC_CREAT | 0666);

其中 IPC_CREAT 表示:

  • 如果对应的共享内存不存在,就创建它。
  • 如果已经存在,就获取已有共享内存的标识符。

  这里的 0666 是八进制权限值,表示所有者、所属组和其他用户都具有读写权限,具体还会受到进程的 umask 等因素影响。

  要特别注意:IPC_CREAT 并不意味着每次调用都会创建一块新的共享内存。

  它允许程序在对象不存在时创建,在对象已经存在时复用。

4.3 IPC_CREAT | IPC_EXCL:必须创建一个新的

  如果我们希望创建者明确知道自己创建的是一个新对象,就可以使用:

cpp 复制代码
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);

  此时:

  • 如果共享内存不存在,创建成功。
  • 如果共享内存已经存在,调用失败,返回 -1,通常对应 errno == EEXIST。

  为什么需要这种写法?

  假设服务器负责创建共享内存,而客户端负责使用共享内存。

  如果服务器意外复用了之前遗留的共享内存,里面可能还有上一次程序运行时留下的数据。使用 IPC_EXCL 可以避免创建者不知情地复用已有对象。

  不过,这也意味着程序重新启动时,如果旧共享内存尚未删除,就可能出现创建失败的问题。

  后面我们会专门分析这个错误。

4.4 共享内存大小有什么要求?

例如:

cpp 复制代码
shmget(key, 4096, IPC_CREAT | 0666);

  这里的 4096 表示请求创建的共享内存大小是 4096 字节,也就是 4 KiB。

  如果获取的是已经存在的共享内存,size 不能简单理解成可以任意指定的参数。

  对于已有对象,传入的大小如果大于该共享内存段的实际大小,调用可能因 EINVAL 而失败。

  因此,客户端和服务器应当对共享内存大小达成一致。


五、shmat():把共享内存连接到当前进程

  通过 shmget(),我们拿到了共享内存标识符。

  但是,标识符不是内存地址,不能直接通过它访问数据。

  接下来需要调用 shmat()。

5.1 函数原型

cpp 复制代码
void *shmat(int shmid, const void *shmaddr, int shmflg);

  它的作用是:将指定的共享内存段映射到当前进程的虚拟地址空间,并返回映射地址。

  参数说明:

参数 含义
shmid shmget() 返回的共享内存标识符
shmaddr 希望映射到的地址,通常传 nullptr
shmflg 映射相关标志
返回值 成功返回映射地址,失败返回 (void *)-1

5.2 为什么通常传入 nullptr?

  最常见的写法是:

cpp 复制代码
void *mem = shmat(shmid, nullptr, 0);

  这里传入 nullptr,表示让内核选择合适的映射地址。

  为什么不自己指定?

  因为每个进程都有自己的虚拟地址空间。某个地址在进程 A 中可以使用,在进程 B 中却可能已经被其他内存区域占用。

  所以,让内核选择合适的地址通常更加方便,也更加安全。

  这也解释了前面提到的现象:

  同一块共享内存在不同进程中的虚拟地址可以不同,但它们映射的底层内存是同一份。

5.3 shmat() 如何判断失败?

  这里有一个非常重要的细节。

  shmat() 的返回类型是 void *,失败时返回的是:

cpp 复制代码
(void *)-1

  因此,不能像下面这样判断:

cpp 复制代码
if (mem == nullptr)
{
    // 错误的失败判断
}

  正确写法是:

cpp 复制代码
void *mem = shmat(shmid, nullptr, 0);

if (mem == reinterpret_cast<void *>(-1))
{
    perror("shmat");
    exit(EXIT_FAILURE);
}

  这里的 reinterpret_cast<void *>(-1) 是 C++ 中的显式指针类型转换,用于表达这个特殊的错误返回值。

5.4 SHM_RDONLY:以只读方式映射

  默认情况下,shmat() 建立的是可读写映射。

  如果指定:

cpp 复制代码
void *mem = shmat(shmid, nullptr, SHM_RDONLY);

  那么当前进程会以只读方式访问这块共享内存。

  这适合只需要读取数据、不应该修改数据的进程。


六、shmdt():解除映射不等于删除共享内存

  当进程使用完共享内存后,应该解除当前进程的映射。

6.1 函数原型

cpp 复制代码
int shmdt(const void *shmaddr);

  参数 shmaddr 必须是之前 shmat() 返回的映射地址。

  成功返回 0,失败返回 -1。

例如:

cpp 复制代码
if (shmdt(mem) == -1)
{
    perror("shmdt");
}

6.2 为什么解除映射不等于删除?

  这是共享内存中最容易混淆的知识点之一。

  假设进程 A 和进程 B 都映射了同一块共享内存。

  如果进程 A 调用 shmdt(),只是解除 A 自己的映射。

  进程 B 的映射不会因此自动解除,其他进程仍然可以继续访问这块共享内存。

因此:

  • shmdt():解除当前进程的映射。
  • shmctl(..., IPC_RMID, ...):请求删除共享内存对象。

  两者的作用完全不同。


七、shmctl():查询、修改和删除共享内存

  共享内存创建之后,我们还需要查询它的属性,必要时也需要删除它。

这就要使用 shmctl()。

7.1 函数原型

cpp 复制代码
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

参数说明:

参数 含义
shmid 共享内存标识符
cmd 要执行的操作
buf 用于传递或接收共享内存属性的结构体指针
返回值 成功返回 0,失败返回 -1

7.2 IPC_STAT:获取共享内存属性

例如:

cpp 复制代码
struct shmid_ds ds;

if (shmctl(shmid, IPC_STAT, &ds) == -1)
{
    perror("shmctl");
}

  IPC_STAT 表示查询共享内存的状态信息。

  查询成功后,相关属性会写入 ds 中。

  其中最值得关注的是 struct shmid_ds。

7.3 shmid_ds 结构体有什么用?

  shmid_ds 是内核用于描述 System V 共享内存段属性的数据结构。

  不同 Linux 版本和体系结构中的具体定义可能存在差异,但常见成员包括:

成员 含义
shm_perm IPC 权限和相关标识信息
shm_segsz 共享内存段大小,单位为字节
shm_atime 最近一次连接共享内存的时间
shm_dtime 最近一次解除映射的时间
shm_ctime 最近一次修改共享内存相关属性的时间
shm_cpid 创建共享内存的进程 PID
shm_lpid 最近一次执行相关操作的进程 PID
shm_nattch 当前连接到该共享内存段的映射数量

  这里需要特别说明,shm_nattch 统计的是当前连接数,不是程序中有多少个 shmat() 调用曾经成功过。进程解除映射后,相应的连接数量会发生变化。

  另外,shm_ctime 主要记录共享内存相关属性最后一次修改的时间,并不是普通数据每次发生变化时的时间。

  我们也可以通过 ipcs -m 查看系统中的 System V 共享内存对象。

7.4 IPC_RMID:删除共享内存

  当共享内存不再需要时,可以调用:

cpp 复制代码
if (shmctl(shmid, IPC_RMID, nullptr) == -1)
{
    perror("shmctl");
}

  IPC_RMID 表示请求删除指定的共享内存段。

  这里有一个重要细节:

  如果仍然有进程连接着这块共享内存,Linux 通常会先标记它等待删除,等最后一个连接解除后,再真正释放该共享内存段。

  因此,删除共享内存并不一定意味着调用 IPC_RMID 的那一刻,内存就立即被释放。

7.5 IPC_SET:修改共享内存属性

  除了查询和删除,shmctl() 还支持 IPC_SET。

  它用于修改共享内存的部分管理属性,例如权限信息。

cpp 复制代码
struct shmid_ds ds;

// 先查询当前属性
if (shmctl(shmid, IPC_STAT, &ds) == -1)
{
    perror("IPC_STAT");
}

// 根据需要修改允许修改的属性,例如权限
ds.shm_perm.mode = 0600;

// 将修改后的属性提交给内核
if (shmctl(shmid, IPC_SET, &ds) == -1)
{
    perror("IPC_SET");
}

  需要注意,修改权限涉及相应的权限检查,并不是任何进程都可以随意修改共享内存属性。


八、实例一:使用共享内存实现进程间通信

  理论讲完了,我们动手写一个实际例子。

  假设有两个进程:

  • server:负责创建共享内存,读取共享数据。
  • client:负责获取共享内存,向里面写入数据。

  客户端依次生成 A 到 Z,每个字母写入两次。

例如:

text 复制代码
AA
BB
CC
DD
...
ZZ

  这里我们还使用了前面学习过的命名管道 FIFO。

  为什么要用 FIFO?

  因为共享内存虽然能让两个进程访问同一份数据,却不会自动通知服务器"数据已经写好了"。

  所以,我们先借助 FIFO 发送一个简单的通知,让服务器知道客户端完成了一次写入。

  注意:这个示例主要用于理解共享内存和 FIFO 的配合方式。后面我们还会分析其中存在的同步缺陷。

8.1 项目结构

text 复制代码
shm_demo/
├── Shm.hpp
├── Fifo.hpp
├── server.cc
├── client.cc
└── Makefile

其中:

  • Shm.hpp:封装共享内存的创建、获取、映射和销毁操作。
  • Fifo.hpp:封装命名管道的创建、读写和通知操作。
  • server.cc:读取共享内存中的数据。
  • client.cc:向共享内存写入数据。
  • Makefile:编译程序。

8.2 先封装共享内存:Shm.hpp

  我们不希望每次使用共享内存时,都在 server.cc 和 client.cc 中重复写一遍 shmget()、shmat() 和 shmdt()。

  因此,可以把这些操作封装到一个类中。

cpp 复制代码
#pragma once

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

#define ERR_EXIT(m)         \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0)

#define CREATOR "creator"
#define USER "user"

const int gdefaultid = -1;
const int gsize = 4096;
const std::string pathname = ".";
const int projid = 0x66;
const int gmode = 0666;

class Shm
{
private:
    // 创建共享内存,或根据指定方式获取共享内存
    void CreateHelper(int flag)
    {
        printf("key: 0x%x\n", _key);

        _shmid = shmget(_key, _size, flag);
        if (_shmid < 0)
        {
            ERR_EXIT("shmget");
        }

        printf("shmid: %d\n", _shmid);
    }

    // 将共享内存映射到当前进程
    void Attach()
    {
        _start_mem = shmat(_shmid, nullptr, 0);

        // shmat() 失败返回 (void *)-1,而不是 nullptr
        if (_start_mem == reinterpret_cast<void *>(-1))
        {
            _start_mem = nullptr;
            ERR_EXIT("shmat");
        }

        printf("attach success\n");
    }

    // 解除当前进程的共享内存映射
    void Detach()
    {
        if (_start_mem == nullptr)
            return;

        if (shmdt(_start_mem) == -1)
        {
            ERR_EXIT("shmdt");
        }

        printf("Detach success\n");
        _start_mem = nullptr;
    }

    // 普通使用者获取已经存在的共享内存
    void Get()
    {
        CreateHelper(0);
    }

    // 清理当前对象使用的资源
    void Destroy()
    {
        Detach();

        // 只有创建者负责请求删除共享内存
        if (_usertype == CREATOR)
        {
            if (shmctl(_shmid, IPC_RMID, nullptr) == -1)
                ERR_EXIT("shmctl");

            printf("shmctl success\n");
        }
    }

public:
    Shm(const std::string &pathname, int projid,
        const std::string &usertype)
        : _shmid(gdefaultid),
          _size(gsize),
          _start_mem(nullptr),
          _usertype(usertype)
    {
        // 生成 System V IPC 键值
        _key = ftok(pathname.c_str(), projid);
        if (_key == static_cast<key_t>(-1))
        {
            ERR_EXIT("ftok");
        }

        if (_usertype == CREATOR)
        {
            // 必须创建新的共享内存,已存在时直接报错
            CreateHelper(IPC_CREAT | IPC_EXCL | gmode);
        }
        else if (_usertype == USER)
        {
            // 只获取已经存在的共享内存
            Get();
        }
        else
        {
            printf("创建角色错误!\n");
            exit(EXIT_FAILURE);
        }

        Attach();

        // 创建者负责初始化共享内存
        if (_usertype == CREATOR)
            memset(_start_mem, 0, _size);
    }

    // 返回共享内存在当前进程中的映射地址
    void *VirtualAddr()
    {
        printf("VirtualAddr: %p\n", _start_mem);
        return _start_mem;
    }

    int size()
    {
        return _size;
    }

    // 查询并打印共享内存属性
    void Attr()
    {
        struct shmid_ds ds;

        if (shmctl(_shmid, IPC_STAT, &ds) == -1)
        {
            ERR_EXIT("shmctl");
        }

        printf("shm_segsz: %zu\n",
               static_cast<size_t>(ds.shm_segsz));
        printf("key: 0x%x\n", _key);
    }

    ~Shm()
    {
        Destroy();
    }

private:
    int _shmid;            // 共享内存标识符
    int _size;             // 共享内存大小
    void *_start_mem;      // 当前进程的映射地址
    key_t _key;            // 用于定位共享内存的键值
    std::string _usertype; // 当前进程的角色
};

  接下来重点理解几个地方。

  第一,为什么需要 CREATOR 和 USER?

  因为创建者和使用者承担的职责不同。

  创建者使用:

cpp 复制代码
CreateHelper(IPC_CREAT | IPC_EXCL | gmode);

  它必须创建一个新的共享内存段,如果已经存在就报错。

  使用者使用:

cpp 复制代码
CreateHelper(0);

  这表示只获取已经存在的共享内存,不创建新的。

  第二,为什么只有创建者负责删除共享内存?

  因为客户端和服务器需要使用同一个共享内存段。

  如果任意一个使用者退出时都删除共享内存,就可能影响其他仍在使用它的进程。

  因此,这个示例约定由创建者负责请求删除,普通使用者只负责解除自己的映射。

  第三,为什么创建者需要调用 memset()?

cpp 复制代码
memset(_start_mem, 0, _size);

  共享内存创建后,不能假设其中的数据已经是我们需要的初始值。

  这里把整块共享内存初始化为零,可以让它在刚开始时表现为空字符串,也方便后续演示。

8.3 FIFO 封装:Fifo.hpp

  为了让服务器知道客户端什么时候完成写入,我们使用命名管道传递通知。

cpp 复制代码
#pragma once

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

class NamedFifo
{
public:
    NamedFifo(const std::string &path, const std::string &name)
        : _path(path), _name(name)
    {
        _fifoname = _path + "/" + _name;

        // 创建命名管道,若已存在则继续使用
        if (mkfifo(_fifoname.c_str(), 0666) < 0)
        {
            if (errno != EEXIST)
            {
                std::cerr << "mkfifo failed: "
                          << strerror(errno) << std::endl;
                return;
            }
        }

        std::cout << "mkfifo success" << std::endl;
    }

    ~NamedFifo()
    {
        // 删除文件系统中的 FIFO 路径名
        if (unlink(_fifoname.c_str()) == 0)
        {
            std::cout << "unlink success" << std::endl;
        }
    }

private:
    std::string _path;
    std::string _name;
    std::string _fifoname;
};

class FileOper
{
public:
    FileOper(const std::string &path, const std::string &name)
        : _path(path), _name(name), _fd(-1)
    {
        _fifoname = _path + "/" + _name;
    }

    void OpenForRead()
    {
        // 只读打开 FIFO
        _fd = open(_fifoname.c_str(), O_RDONLY);

        if (_fd < 0)
        {
            std::cerr << "open for read failed" << std::endl;
            return;
        }
    }

    void OpenForWrite()
    {
        // 只写打开 FIFO
        _fd = open(_fifoname.c_str(), O_WRONLY);

        if (_fd < 0)
        {
            std::cerr << "open for write failed" << std::endl;
            return;
        }
    }

    bool Wait()
    {
        char c;

        // 读取客户端发送的一个通知字节
        int number = read(_fd, &c, 1);

        return number > 0;
    }

    void Wakeup()
    {
        char c = 'c';

        // 向 FIFO 写入一个字节,通知服务器
        if (write(_fd, &c, 1) == -1)
        {
            std::cerr << "write fifo failed" << std::endl;
        }
    }

    void Close()
    {
        if (_fd >= 0)
        {
            close(_fd);
            _fd = -1;
        }
    }

    ~FileOper()
    {
        Close();
    }

private:
    std::string _path;
    std::string _name;
    std::string _fifoname;
    int _fd;
};

  这里的 Wait() 和 Wakeup() 可以简单理解为:

  • Wakeup():客户端发送一个通知字节。
  • Wait():服务器读取这个字节,知道有一次通知到达。

  需要注意,FIFO 中传递的是通知字节,而不是共享内存里的实际字符串。

  另外,命名管道的读写端通常需要配合打开。只读打开和只写打开可能互相等待对方,因此这个示例需要先启动服务器,再启动客户端。

8.4 服务器代码:server.cc

  服务器负责创建共享内存,并读取客户端写入的数据。

cpp 复制代码
#include "Shm.hpp"
#include "Fifo.hpp"

int main()
{
    // 创建命名管道
    NamedFifo fifo(".", "fifo");

    FileOper readerfile(".", "fifo");
    readerfile.OpenForRead();

    // 创建共享内存,并将其映射到服务器进程
    Shm shm(pathname, projid, CREATOR);

    char *mem = static_cast<char *>(shm.VirtualAddr());

    while (true)
    {
        // 收到客户端的通知后,读取共享内存
        if (readerfile.Wait())
        {
            printf("%s\n", mem);
            sleep(1);
        }
        else
        {
            break;
        }
    }

    readerfile.Close();
    return 0;
}

  服务器的执行流程是:

  1. 创建 FIFO。
  2. 打开 FIFO 的读端。
  3. 创建共享内存。
  4. 获取共享内存的映射地址。
  5. 等待客户端发送通知。
  6. 收到通知后,读取共享内存中的字符串。

  这里有一个顺序上的细节:服务器在打开 FIFO 读端时,可能会等待客户端打开写端。因此,启动服务器时,程序不一定会立刻执行到共享内存创建语句。

  这也是为什么我们通常需要先启动服务器,再启动客户端。

8.5 客户端代码:client.cc

  客户端不创建共享内存,而是获取服务器创建好的共享内存,并向其中写入数据。

cpp 复制代码
#include "Shm.hpp"
#include "Fifo.hpp"

int main()
{
    FileOper writerfile(".", "fifo");
    writerfile.OpenForWrite();

    // 获取服务器已经创建的共享内存
    Shm shm(pathname, projid, USER);

    char *mem = static_cast<char *>(shm.VirtualAddr());

    int index = 0;

    for (char c = 'A'; c <= 'Z'; c++, index += 2)
    {
        // 每个字母写入两次
        mem[index] = c;
        sleep(1);

        mem[index + 1] = c;
        sleep(1);

        // 在字符串末尾写入结束符
        mem[index + 2] = '\0';

        // 通知服务器,本轮写入已经完成
        writerfile.Wakeup();
    }

    writerfile.Close();
    return 0;
}

  客户端的核心逻辑就是不断向共享内存中写入数据。

  以第一次循环为例:

cpp 复制代码
mem[0] = 'A';
sleep(1);

mem[1] = 'A';
sleep(1);

mem[2] = '\0';

writerfile.Wakeup();

  执行后,共享内存开头的内容为:

text 复制代码
下标:  0    1    2
内容: 'A'  'A'  '\0'

  因此,服务器使用:

cpp 复制代码
printf("%s\n", mem);

  就能把它当作以 '\0' 结尾的 C 风格字符串打印出来。

  下一轮循环会在后面继续写入 BB,再下一轮写入 CC,以此类推。

  不过,sleep(1) 只是让程序暂停一段时间,并不保证另一个进程已经完成读取。

  而且,客户端发送通知之后会继续写入下一轮数据,服务器也可能尚未完成上一轮读取。因此,这段代码仍然存在竞争条件,不能视为可靠的同步实现。我们会在后面的章节中详细分析。

8.6 Makefile:编译程序

bash 复制代码
.PHONY:all
all:client server

client:client.cc
	g++ -o $@ $^ -std=c++11

server:server.cc
	g++ -o $@ $^ -std=c++11

.PHONY:clean
clean:
	rm -f client server

8.7 运行程序

  首先启动服务器:

bash 复制代码
./server

  然后在另一个终端启动客户端:

bash 复制代码
./client

  如果程序正常建立了共享内存映射,就可以看到客户端和服务器打印相关信息,并观察共享内存中的字符串变化。

  运行过程中,Shm 对象的析构函数会执行清理操作。客户端退出时解除自己的映射,服务器退出时也会解除映射,并由创建者请求删除共享内存。

  但如果服务器异常退出、被强制终止,或者程序在清理前崩溃,共享内存可能继续留在系统中。下一次运行时,就可能出现下面这个问题。


九、运行报错:shmget: File exists 是怎么回事?

  假设运行:

bash 复制代码
./server

  得到:

text 复制代码
shmget: File exists

  看到这条错误时,先不要急着修改代码。

  它通常说明:程序想创建一个新的共享内存段,但对应的 key 已经关联了一个现存的共享内存对象。

  为什么会这样?

  回顾创建共享内存的代码:

cpp 复制代码
shmget(_key, _size, IPC_CREAT | IPC_EXCL | gmode);

  因为设置了 IPC_CREAT | IPC_EXCL,所以如果共享内存已经存在,调用就会失败,并且 errno 通常会是 EEXIST。

9.1 使用 ipcs 查看共享内存

  可以执行:

bash 复制代码
ipcs -m

  输出可能类似:

text 复制代码
------ Shared Memory Segments --------
key          shmid     owner   perms   bytes   nattch
0x66026a25   688145    root    666     4096    0

  这些字段分别表示:

字段 含义
key 共享内存的键值
shmid 共享内存标识符
owner 所有者
perms 权限
bytes 共享内存段大小
nattch 当前连接数

  其中,bytes 表示共享内存段的大小。

  nattch 表示当前连接到该共享内存段的映射数量。

  如果 nattch 为 0,说明当前没有进程映射这块共享内存,但这并不代表共享内存对象已经被删除。

  共享内存可以在没有任何进程使用时,仍然保留在系统中。

9.2 如何删除遗留的共享内存?

  如果确认某个共享内存对象是之前运行遗留下来的,而且当前没有程序需要使用它,可以通过 shmid 删除它:

bash 复制代码
ipcrm -m 688145

  这里的 688145 应替换为实际查到的 shmid。

  删除前一定要确认对象确实不再需要。不要看到 ipcs -m 中存在对象就直接删除,因为其他进程可能正在使用它。

  删除后,再重新运行服务器。

  也可以使用:

bash 复制代码
ipcs -m

  检查系统中的共享内存对象是否已经发生变化。


十、实例二:只有共享内存,没有同步会发生什么?

  上面的实例中,我们使用 FIFO 发送通知。

  现在,我们把问题简化一下:假设客户端和服务器只使用共享内存,不使用 FIFO。

  这样做能不能实现通信?

  答案是:能实现数据共享,但无法保证读写顺序和数据一致性。

  先看代码。

10.1 服务器代码

cpp 复制代码
#include "Shm.hpp"

int main()
{
    // 创建共享内存并映射到当前进程
    Shm shm(pathname, projid, CREATOR);

    char *mem = static_cast<char *>(shm.VirtualAddr());

    while (true)
    {
        // 不断读取共享内存中的字符串
        printf("%s\n", mem);
        sleep(1);
    }

    return 0;
}

  服务器创建共享内存之后,就不断打印共享内存中的字符串。

  由于创建者在初始化时使用了 memset(),共享内存最开始的内容全为零,因此可以被当作一个空字符串读取。

10.2 客户端代码

cpp 复制代码
#include "Shm.hpp"

int main()
{
    // 获取服务器已经创建的共享内存
    Shm shm(pathname, projid, USER);

    char *mem = static_cast<char *>(shm.VirtualAddr());

    for (char c = 'A'; c <= 'Z'; c++)
    {
        // 按顺序将 A 到 Z 写入共享内存
        mem[c - 'A'] = c;

        sleep(1);
    }

    return 0;
}

  客户端每隔一秒向共享内存中写入一个字母。

  例如:

text 复制代码
第一次写入:A
第二次写入:AB
第三次写入:ABC
第四次写入:ABCD

  因为共享内存最开始被清零,字母后面暂时仍有 '\0',所以服务器通常可以看到逐渐增长的字符串。

10.3 为什么 sleep(1) 不能解决同步问题?

  很多初学者看到这段代码,会产生一个疑问:

  客户端每次写入之后都休眠一秒,服务器也每隔一秒打印一次,不就可以了吗?

  问题就在于:时间上的间隔,不等于进程之间建立了同步关系。

  例如,客户端执行:

cpp 复制代码
mem[0] = 'A';
sleep(1);

  这只能说明客户端暂停了一秒。

  它并不知道服务器是否已经读取了 A,也不知道服务器当前执行到了哪一条语句。

  服务器的执行速度可能受到以下因素影响:

  • 操作系统的进程调度。
  • CPU 的负载情况。
  • 其他进程的运行。
  • 系统调用和 I/O 的执行时间。

  因此,不能通过 sleep(1) 保证服务器一定在客户端下一次写入之前完成读取。

  更重要的是,客户端和服务器会同时访问同一块内存。如果一个进程正在写入,另一个进程恰好在读取,就可能发生竞争。

  例如,服务器原本希望读取:

text 复制代码
ABC

  但在读取过程中,客户端又修改了共享内存。

  服务器实际观察到的内容就可能与预期不一致。

10.4 共享内存本身不会自动提供互斥

  假设两个进程都想修改一个共享变量:

cpp 复制代码
int count = 0;

  如果两个进程都执行类似下面的操作:

cpp 复制代码
count++;

  那么从逻辑上看,这个操作通常包含读取、加一、写回等步骤。

  如果没有适当的同步,两个进程就可能在相互交错的执行过程中覆盖彼此的更新。

  最终结果可能与我们预期的结果不同。

  所以,必须记住:

  共享内存负责共享数据,不负责自动协调访问。


结语:

  今天的内容到这里就结束了,希望你能有所收获~

干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _

相关推荐
叼烟扛炮1 小时前
Linux 学习笔记(三十八):从实模式到分页——处理器内存管理的发展史
linux·运维·服务器
2401_868534781 小时前
论智慧医院网络建设
服务器·网络
润滑卫士1 小时前
油液水分在线传感器技术对比:电容法、电阻法与微波法的测量原理与适用性
运维
Decay__1 小时前
附-01 · MySQL 压测与容量规划 —— sysbench 基准测试、容量评估与参数基线
运维·数据库·sql·mysql·架构
mennekes1 小时前
暗装插座箱 VS 明装插座箱,工地和车间怎么取舍
运维·网络·科技·安全·制造
叼烟扛炮1 小时前
Linux 学习笔记(三十六):并发进阶——读写锁 vs 自旋锁,到底怎么选?
linux·运维·服务器·笔记·学习
qq_364682151 小时前
录音APP怎么切换语言?多语言版本如何使用
java·服务器·前端
正经教主1 小时前
【FDE系列】阶段2:Day 43(UP详细版):Docker Compose — 多容器一键编排
运维·docker·容器
大耳朵爱学习2 小时前
Andrew Ng深度解析:普通人如何利用AI把想法变成工具,实现工作自动化(小白程序员必看)
运维·人工智能·ai·自动化·大模型·就业·大模型学习