【Linux】共享内存为什么快?System V 的 key、shmget、shmat 与进程通信实战

前言
管道通信的路径是:

进程 A 用户空间
↓ write()
内核管道缓冲区
↓ read()
进程 B 用户空间

每次发送和接收都要经过系统调用,并在用户空间与内核空间之间传递 数据。 操作系统

System V 共享内存换了一个思路:

同一批共享物理页
↙ ↘
进程 A 进程 B
地址空间 地址空间

只要映射建立完成,两个进程就可以通过各自的虚拟地址直接访问这份共享区域。

共享内存快,关键就在这里:映射建立后,进程直接访问共享区域,不需要每次收发数据都走一遍管道式的数据搬运。

一、System V IPC 先认识一下
传统 System V IPC 主要包括三类:

共享内存
消息队列
信号量

它们都是由内核维护的 IPC 对象。

和匿名管道最大的感觉区别之一是:

进程退出,不代表 System V IPC 对象一定自动消失。

例如共享内存没有执行删除操作时,可以在进程结束后继续通过: 计算机内存

ipcs -m

看到它。

所以写 System V IPC 时,除了“创建”和“使用”,还要把“谁负责清理”设计进去。

二、共享内存到底共享了什么
共享内存不是“两个进程拥有同一个虚拟地址”。

更准确的说法是:

进程 A 某段虚拟地址 ─┐
├── 映射到同一份共享内存页
进程 B 某段虚拟地址 ─┘

两边的虚拟地址值甚至可以不同。

重要的是最后落到同一份共享内存对象。

 

映射以后:

mem[0] = 'A';

映射成功以后,对这段地址的普通读写就是内存访问,不需要每次再调用 write() / read() 完成 IPC 数据搬运。 网络

共享内存快,不是因为“没有内核”,而是建立映射后,每次数据读写不再通过内核 IPC 系统调用做一次额外的数据搬运。

创建、映射、解除映射、删除资源本身仍然需要系统调用。

三、顺手修正一个容易混淆的内存知识
原稿中把 malloc() 后第一次访问内存写成了“写时拷贝”。

这两个概念不要混在一起。

malloc() 取得一段虚拟地址后,操作系统可能采用按需分配 / 缺页分配:

先保留虚拟地址范围

第一次真正访问

触发缺页

内核准备物理页并建立映射

而 Copy-On-Write,写时拷贝 更典型的场景是 fork() 后父子进程暂时共享只读映射页,某一方写入时再复制物理页。

所以:

malloc 首次触碰页面 ≠ fork 后 COW

四、key 和 shmid 分别解决什么问题
这一块最容易把 key 和 shmid 混在一起。

4.1 key:不同进程约定“我要找哪一个对象”
假设 Server 和 Client 都需要连接同一块共享内存。 计算机服务器

在任何通信发生之前,它们就要有一个共同约定。

System V IPC 使用:

key_t key;

作为这种 rendezvous key。

常见做法是通过:

ftok(pathname, proj_id);

生成

ftok():

#include <sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);

注意: ftok() 不能保证全局绝对不冲突。

它只是根据文件信息和 proj_id 组合一个 key_t。所以工程里要选择稳定、双方都能访问的 pathname,并做好创建冲突处理。

4.2 shmid:内核返回给进程的操作句柄
调用:

int shmid = shmget(key, size, flags);

成功后返回的整数才是后续:

shmat
shmctl

等接口使用的共享内存标识符。 计算机内存

所以可以这样记:

key → 用来“找”
shmid → 找到以后用来“操作”

共享内存对象创建后,内核会返回 shmid 供后续接口使用。

原稿里“不能由 OS 分配 id”这个说法需要删掉。

五、shmget():创建和获取共享内存
#include <sys/ipc.h>
#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);

常见创建方式:

int shmid = shmget(
key,
4096,
IPC_CREAT | IPC_EXCL | 0666
);

含义:

IPC_CREAT
不存在就创建

IPC_EXCL
和 IPC_CREAT 一起使用时:
如果已经存在则失败

0666
创建时的访问权限

如果 Server 要保证“我创建到的一定是一块新的共享内存”,用: 计算机服务器

IPC_CREAT | IPC_EXCL | 0666

很合适。

Client 只想获取已经存在的对象时,不需要再写:

IPC_CREAT

可以直接:

int shmid = shmget(key, 4096, 0);

注意:“获取共享内存就使用 IPC_CREAT”这个说法不严谨。

IPC_CREAT 的语义是“不存在就创建”,并不是“只获取”。

六、为什么程序退出了,共享内存还在
创建:

shmget(...)

然后程序直接退出。

再次运行创建者时,如果使用:

IPC_CREAT | IPC_EXCL

可能看到:

File exists

查看:

ipcs -m

这个现象正好说明 System V IPC 的生命周期 和普通进程内资源不一样: Linux与 Unix

进程退出

共享内存对象自动删除

测试时可以:

ipcrm -m <shmid>

手动删除。

正式程序应该由拥有资源生命周期管理职责的一方调用:

shmctl(shmid, IPC_RMID, nullptr);

七、shmat():把共享内存接进自己的地址空间
void *shmat(
int shmid,
const void *shmaddr,
int shmflg
);

最常见:

void* addr = shmat(shmid, nullptr, 0);

让内核选择映射地址。 操作系统

 

shmat() 的失败判断按接口返回值直接写:

if (addr == (void*)-1)
{
perror("shmat");
}
不要写:

if ((int)addr < 0)

也不要为了 64 位指针改成:

if ((long long)addr < 0)

shmat() 的失败返回值就是 (void*)-1,直接按接口约定比较最稳。

八、shmdt() 和 shmctl(IPC_RMID) 不是一回事
解除当前进程的映射: 操作系统

shmdt(addr);

它只表示:

当前进程不再挂接这块共享内存

并不等于:

删除共享内存对象

真正请求删除:

shmctl(shmid, IPC_RMID, nullptr);

在 Linux 上,IPC_RMID 会把共享内存段标记为删除;如果仍有进程挂接,实际资源会等最后一个挂接解除后再完成回收。

把创建、使用和清理串起来,就是:

shmget

shmat

读写共享内存

shmdt

拥有者 shmctl(... IPC_RMID ...)

九、写一个最小 Server / Client
9.1 公共代码 comm.hpp
#pragma once

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

constexpr const char* PATHNAME = ".";
constexpr int PROJ_ID = 0x66;
constexpr std::size_t SHM_SIZE = 4096;

inline key_t MakeKey()
{
key_t key = ftok(PATHNAME, PROJ_ID);

if (key == -1)
{
perror("ftok");
std::exit(1);
}

return key;
}

9.2 Server:创建、读取、删除
#include "comm.hpp"

#include <cstdio>
#include <cstring>
#include <unistd.h>

int main()
{
key_t key = MakeKey();

int shmid = shmget(
key,
SHM_SIZE,
IPC_CREAT | IPC_EXCL | 0666
);

if (shmid < 0)
{
perror("shmget");
return 1;
}

char* mem =
static_cast<char*>(shmat(shmid, nullptr, 0));

if (mem == (void*)-1)
{
perror("shmat");
shmctl(shmid, IPC_RMID, nullptr);
return 2;
}

while (true)
{
std::printf("client# %s\n", mem);

if (std::strcmp(mem, "quit") == 0)
{
break;
}

sleep(1);
}

shmdt(mem);
shmctl(shmid, IPC_RMID, nullptr);

return 0;
}

9.3 Client:获取并写入
#include "comm.hpp"

#include <cstring>
#include <iostream>
#include <string>

int main()
{
key_t key = MakeKey();

int shmid = shmget(key, SHM_SIZE, 0);
if (shmid < 0)
{
perror("shmget");
return 1;
}

char* mem =
static_cast<char*>(shmat(shmid, nullptr, 0));

if (mem == (void*)-1)
{
perror("shmat");
return 2;
}

while (true)
{
std::string message;

std::cout << "Please Enter# ";
if (!std::getline(std::cin, message))
{
break;
}

std::snprintf(
mem,
SHM_SIZE,
"%s",
message.c_str()
);

if (message == "quit")
{
break;
}
}

shmdt(mem);
return 0;
}

原稿里的实验让 Client 逐个写入 A-Z,Server 能直接看到共享区内容变化: 计算机服务器

 

十、为什么共享内存快,但也最容易把 数据读乱
共享内存映射成功后:

mem[0] = 'A';

写入会直接作用在共享区域。

它没有像管道一样天然提供:

“没有数据时 read 阻塞”
“写完一段以后读端再消费”

这意味着:

Client 正写到一半

Server 同时开始读取

Server 可能看到一个中间状态

共享内存解决的是“共享数据放在哪里”,不是自动解决“什么时候读、什么时候写”。 计算机内存

所以通常还需要:

信号量
互斥锁
条件变量
其他通知机制

配合使用。

10.1 用 FIFO 做一次“通知实验”
你原稿里有一个很有价值的实验:

共享内存:负责真正的数据
命名管道:只负责通知“现在可以读了”

例如 Client 先把一批数据写完整,再:

write(fifo_fd, &token, sizeof(token));

Server 先阻塞:

read(fifo_fd, &token, sizeof(token));

收到 token 后才读共享内存。 计算机内存

这个实验很好地说明了:

数据通道

同步/通知通道
可以是两套机制

但它只解决当前一写一读场景里的顺序通知。

如果以后有多个写者同时修改同一片共享区,还需要真正的互斥保护。

这就会自然过渡到下一篇的信号量。

十一、共享内存大小为什么经常和 4 KiB 一起出现
你的实验里申请:

4097 字节

ipcs -m 可能仍显示用户请求的:

4097

但底层内存管理以页为基本单位,实际映射和分配会涉及页粒度向上取整。

所以不要写成:

“shmget 的 size 必须是 4096 的整数倍。”

API 接受的 size 不要求调用者必须写成整页大小。

更准确的说法是:

用户请求大小可以不是页大小整数倍,但内核底层建立映射和准备页时仍然受页粒度约束。 操作系统

 

十二、shmid_ds 让我们看到内核需要记录什么
用户空间可以通过:

shmctl(shmid, IPC_STAT, &ds);

取得共享内存状态。

典型结构包括:

struct shmid_ds {
struct ipc_perm shm_perm;
size_t shm_segsz;
time_t shm_atime;
time_t shm_dtime;
time_t shm_ctime;
pid_t shm_cpid;
pid_t shm_lpid;
shmatt_t shm_nattch;
// ...
};

其中:

shm_segsz 请求的共享内存大小
shm_atime 最近一次 attach 时间
shm_dtime 最近一次 detach 时间
shm_cpid 创建者 PID
shm_lpid 最近操作进程 PID
shm_nattch 当前挂接数量

ipc_perm 又把: 网络

key
uid/gid
creator uid/gid
mode
sequence

5
这些 System V IPC 公共管理信息抽了出来。

这个“公共头”在消息队列 和信号量里还会再次出现。

总结
共享内存要理解的是两层:

第一层是:

key

shmget

shmid

它解决两个进程如何找到同一个 System V 共享内存对象。

第二层是:

shmid

shmat

虚拟地址

普通内存访问

它解决进程怎样真正读写共享区域。 操作系统

最后把几个容易混淆的结论放在一起:

IPC_CREAT 不是“只获取”,它表示不存在时创建;
Client 只获取已存在对象时可以使用 shmget(key, size, 0);
ftok() 可能碰撞,不提供绝对唯一性保证;
shmat() 失败要和 (void*)-1 比较;
shmdt() 只是解除当前进程映射,不是删除;
IPC_RMID 负责请求删除共享内存;
共享内存本身不提供互斥和同步。
————————————————
版权声明:本文为CSDN博主「爱和冰阔落」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/2402_87731470/article/details/163595994

上一篇 通过mysql中的Data目录恢复数据库数据
下一篇 如何设计一个高可用的MySQL方案?