
大家好,欢迎来到 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:修改共享内存属性)
- 八、实例一:使用共享内存实现进程间通信
- [九、运行报错: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;
}
服务器的执行流程是:
- 创建 FIFO。
- 打开 FIFO 的读端。
- 创建共享内存。
- 获取共享内存的映射地址。
- 等待客户端发送通知。
- 收到通知后,读取共享内存中的字符串。
这里有一个顺序上的细节:服务器在打开 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++;
那么从逻辑上看,这个操作通常包含读取、加一、写回等步骤。
如果没有适当的同步,两个进程就可能在相互交错的执行过程中覆盖彼此的更新。
最终结果可能与我们预期的结果不同。
所以,必须记住:
共享内存负责共享数据,不负责自动协调访问。
结语:
今天的内容到这里就结束了,希望你能有所收获~
干货整理到手抖,觉得有用的话,赏个三连回回血?__(:ᗤ」ㄥ)_ _