共享内存到底是"共享"了什么?——从 ipcs -m 跑出来的那一刻说起
先跑个东西。打开终端,敲:
ipcs -m
啥都没有。空的。
再启动一个程序——我们就叫它 reader。大概长这样:
// reader.cc
#include "shm.hpp"
int main() {
shm shm;
shm.create(); // 创建共享内存
shm.debug(); // 打印 key 和 id
sleep(5);
shm.delete_it(); // 5秒后删掉
return 0;
}
跑起来的同时,另一个终端每隔一秒查一次:
while true; do ipcs -m; sleep 1; done
你会看到:共享内存段凭空出现,nattch 为 0(没人挂接),5 秒后消失。
共享内存的生命周期随内核,不随进程。
就这一句话,把共享内存跟文件、管道拉开了一个本质差距。进程退出,文件描述符自动关了,管道自动断了。但共享内存一旦创建出来,它就赖在内核里,除非你主动删它,或者重启机器。
就这么回事。
先复习一下:命名管道干了件什么事
命名管道(FIFO)的原理跟匿名管道一样——让不同的进程看到同一份内核缓冲区。
不同在哪?命名管道能让两个毫无血缘关系的进程通信。
匿名管道只能父子、兄弟之间玩。命名管道靠的是 Linux 文件系统:在指定路径下创建一个 p 类型的特殊文件,两个进程用同一个路径打开,内核就给它们指向同一份缓冲区。
mkfifo /tmp/myfifo
然后 server 以读方式 open,client 以写方式 open——就成了。
注意这两个角色的分工:server 负责创建和删除管道文件(mkfifo + unlink),client 只需要 open + 读写 + close。 client 不用管管道的生命周期——它看到的只是一个普通文件路径,打开就用。
有个细节值得注意:命名管道首次打开时,读端会在 open 这里阻塞,不是在 read 这里阻塞。
什么意思?你把 server 启动起来,用读方式打开管道,如果写端还没打开,它就卡在 open 那一行,不往下走。直到 client 也打开这个管道文件,open 才返回,然后才轮到 read 阻塞等待数据。
写个测试验证一下:
// server 端
std::cout << "open begin" << std::endl;
int fd = open("/tmp/myfifo", O_RDONLY);
std::cout << "open end" << std::endl;
// 如果 client 没启动,上面这行永远不会打印
client 一旦启动,server 的 open end 立刻打出来,然后再卡在 read 上。
这是一个同步过程。 通信的同步不只是 read/write 的事,open 的时候就开始了。
换个问法:管道在 open 阶段就做了一次握手——"对端在吗?不在我就等。"不用这些词,还能说清楚吗?
如果你把写端先打开呢?自己测一下——把 server 改成写端。行为类似,写端也会在 open 阻塞,等读端打开。操作系统在 open 阶段就做了同步,不等到 read/write。
另外提一个容易被忽略的事实:命名管道有独立的 inode,但不会把数据刷新到磁盘。 它只是一个文件系统层面的"符号"——让不同进程通过同一个路径找到同一个内核缓冲区。磁盘上那个 p 类型文件,里面永远是空的。
还有一个生命周期细节:client 退出时,server 怎么知道? 进程退出,它的文件描述符表、PCB 全部被 free 掉。管道文件在内核里的 struct file 引用计数减到 0,文件自动关闭。server 端的 read 就会读到 0(EOF),意味着对端已关闭。这时候 server 就可以退出了:
// server 端
while (true) {
ssize_t n = read(fd, buffer, sizeof(buffer));
if (n == 0) {
std::cout << "client quit, me too" << std::endl;
break;
}
// 处理数据…
}
Ctrl+C 关掉 client,server 立刻收到 “client quit, me too”,跟着退出。这就是管道提供的天然生命周期同步。
命名管道的应用场景不多。一个典型例子:两个进程之间传文件。
进程A 进程B
读 a.txt ──→ 写 FIFO ──→ 读 FIFO ──→ 写 b.txt
进程 A 从文件读 → 塞进管道 → 进程 B 从管道取出来 → 写到另一个文件。跟我们上节课从键盘读、往显示器写是一个道理——Linux 下一切皆文件,把键盘换成文件,显示器也换成文件,逻辑不变。
把左边换成网络(从 socket 读数据),右边换成数据库或 Redis(往数据库写)——这就是一个简单的进程池模型了。只不过它只有一个管道和一个子进程罢了。你当然可以创建多个管道、多个子进程,基于命名管道也能设计出完整的进程池。
换个问法:命名管道和匿名管道的本质区别就一点——匿名管道只能有血缘关系的进程之间通信,命名管道可以让两个毫不相干的进程通信。剩下所有特性(四种情况、五种特性)完全一样。
有个事值得想一想:匿名管道和命名管道,归根结底是同一类东西——基于文件的通信。 匿名管道没有磁盘文件,但它走的是内核里的文件抽象(struct file + 缓冲区);命名管道多了个磁盘上的 p 类型文件当"路标",但通信的数据路径完全一样。操作系统的设计者当年搞这套东西,不是从零写新代码,而是把已有的文件系统代码裁一裁、改一改——文件系统本来就有缓冲区、有 inode、有读写接口,管道无非是把"写入磁盘"那一步砍掉,换成"等另一个进程来读"。所以管道成了最早诞生的 IPC 方式之一。
匿名管道在现实中最典型的应用就一个:shell 的竖划线 |。ls | grep foo——ls 的 stdout 通过匿名管道接到 grep 的 stdin。两个命令是父子进程,管道是 pipe() 创建的。每天你在终端敲的 |,背后就是匿名管道。
System V:为什么有人要定一套 IPC 标准
管道用得好好的,为什么还要搞新东西?
两个原因:
所以有人就想:能不能设计一套更丰富的通信方式?
但如果你是设计者,你会碰上一个更现实的问题:你在 Linux 上搞一套 IPC,它就只能用在 Linux 上。 Windows 也用进程,macOS 也用进程,它们都需要进程间通信。你给每家都设计一套不同的接口,程序员学起来要命。
所以先不急着写代码。先定标准。
这个标准就叫 System V。
System V 标准规定了三种通信技术:
| 共享内存 (shm) | 大块数据高效交互 |
| 消息队列 (msg) | 按数据块/类型发送消息 |
| 信号量 (sem) | 多进程同步和互斥 |
诚实地说:这三种技术现在基本过时了。原因很简单——网络 socket 既能本地通信又能跨网络通信,扩展性好得多。但共享内存里有个东西非常值得学,这就是我们接下来要讲的。
从一个图开始理解共享内存
两个进程,A 和 B。每个进程都有自己的虚拟地址空间,有自己的页表,虚拟地址通过页表(MMU 硬件做地址翻译)映射到物理内存的不同区域。
进程间通信的本质是什么?让不同进程看到同一份资源。
怎么让 A 和 B 看到同一块物理内存?
分三步:
第一步:在物理内存中开辟一段空间。知道了起始地址,知道了大小,整个字节范围的地址就都知道了。
严格来说,这一步甚至可以延迟——操作系统支持虚拟空间的延迟申请:创建共享内存时可以不立即分配物理页,等进程真正通过指针读写时才触发缺页异常、分配物理内存。所以"创建共享内存"这个动作,真正重要的是在内核里创建描述结构体,物理内存反而可以缓一缓。
第二步:把这段物理空间映射到进程 A 的虚拟地址空间里。映射到哪?堆和栈之间有个区域叫共享区。
高地址
┌─────────────┐
│ 栈 │
├─────────────┤
│ 共享区 ←── 共享内存映射到这里
├─────────────┤
│ 堆 │
├─────────────┤
│ 未初始化数据│
├─────────────┤
│ 初始化数据 │
├─────────────┤
│ 代码段 │
└─────────────┘
低地址
当年动态库就是映射到共享区的。动态库能映射,空的内存块就也能映射。
第三步:进程 B 也做同样的事——在共享区申请虚拟地址,把同一块物理内存映射过来。
现在,A 和 B 各自拿到了一个虚拟地址。A 往这个地址写,B 从这个地址读——直接可见,没有拷贝。
这就是共享内存。我们学动态库的时候已经接触过这个机制了,只不过当时映射进去的是库的代码和数据,现在映射进去的是一块空内存。
两个马上要问的问题
问题一:整个过程谁干的?
申请物理内存、填页表、构建映射关系、修改进程 PCB——这些全涉及内核数据结构。只有操作系统能干。
操作系统干了,但谁让它干的?用户。 用户通过系统调用,让操作系统去干。
所以:创建共享内存需要系统调用。
问题二:共享区属于用户空间吗?
属于。
共享区不在内核空间(那 1GB 内核空间我们后面再聊),它属于用户空间。这意味着什么?
用户拿着指针就能直接访问。
跟 malloc 出来的堆空间一样——malloc 返回一个指针,你就能直接读写。共享内存 shmat 返回的也是指针,你就能直接读写。区别只是:malloc 的空间在堆上,共享内存的空间在共享区。
所以:使用共享内存不需要系统调用。
创建时需要,删除时需要,但读写不需要——指针就够了。
类比一下:创建管道用 pipe / mkfifo(系统调用),使用管道用 read / write(也是系统调用)。共享内存呢?创建用 shmget(系统调用),使用直接拿指针怼——没有系统调用开销。这就是它快的原因。
先把共享内存包成一个类
在写具体代码之前,先交代一下这次用的 C++ 文件组织方式。平常写 C++ 你会 class Foo 声明放 .h、实现放 .cpp——头源分离。这次我们把声明和实现都写在一个 .hpp 文件里。为什么?
头源分离的根本目的是方便打包成库。 把 .cpp 编成 .o,打包成 .a 或 .so,配上 .h 交付。但对于开源项目,还有另一种做法叫 header-only——所有代码塞一个 .hpp,用户 #include 就能直接用,不需要链接库、不用设库搜索路径。省事。课堂上文件太多也容易看花眼,所以用 .hpp 减少文件数量。
好了,开始设计 shm.hpp:
#pragma once
#include <sys/shm.h>
#include <sys/ipc.h>
#include <iostream>
#include <string>
#include <unistd.h>
class shm {
public:
shm() : _shmid(–1), _key(0), _size(4096) {}
~shm() {}
bool create(); // 创建全新的共享内存
bool get(); // 获取已存在的共享内存
void *attach(); // 挂接到当前进程地址空间
bool delete_it(); // 删除共享内存
void debug(); // 打印 key 和 shmid
void get_attr(); // 获取共享内存内核属性
private:
int _shmid; // 用户层标识符
key_t _key; // 内核层键值
int _size; // 共享内存大小
key_t get_key(); // 通过 ftok 生成键值
bool create_common(int flags); // 创建/获取的核心逻辑
};
构造函数里 _shmid 初始化为 -1,这在后面 shmat 调用前可以用作有效性检查。
一个重要区分:shm obj; 只是在你栈上定义了一个对象——这 不是 创建共享内存。它跟你定义一个 int x 没本质区别。真正的共享内存创建发生在调用 obj.create() 的时候,create() 内部调 shmget() 陷入内核。你代码里可以定义十个 shm 对象,但不调用 create(),内核里一块共享内存都不会有。
create() 和 get() 的唯一区别就是 shmflg 参数不同,所以抽一个私有函数:
bool shm::create_common(int flags) {
_key = get_key();
if (_key < 0) {
std::cerr << "ftok failed" << std::endl;
return false;
}
_shmid = shmget(_key, _size, flags);
if (_shmid < 0) {
std::cerr << "shmget failed" << std::endl;
return false;
}
return true;
}
bool shm::create() {
return create_common(IPC_CREAT | IPC_EXCL | 0666);
}
bool shm::get() {
return create_common(IPC_CREAT);
}
注意权限 | 0666:拥有者、所属组、other 都可读写。如果创建时不设权限,默认 perms = 0——连创建者自己都没权限去挂接和访问。你可以在创建后用 ipcs -m 验证:没设权限时 perms 列为 0,设了 0666 之后变成 666。
代码来了:shmget 和那个让人困惑的 key
要创建共享内存,第一个系统调用是:
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
成功返回一个整数——共享内存标识符 (shmid)。失败返回 -1。
size 是共享内存的大小。注意:必须是 4KB 的整数倍。
shmflg 有两个常用选项:
| IPC_CREAT | 共享内存不存在就创建,存在就直接获取返回 |
| IPC_CREAT | IPC_EXCL | 共享内存不存在就创建,存在就出错返回 |
IPC_CREAT 单独用就是"旱涝保收"——不管有没有,反正给你搞到一个。这是获取 (get) 的语义。
IPC_CREAT | IPC_EXCL 组合用就是"我必须拿到全新的"——这是创建 (create) 的语义。
所以设计上,通信两端:一端用 create 创建,另一端用 get 获取。
现在到最核心的问题了:第一个参数 key 是干什么的?
key:为什么不让内核自己生成?
你可能会想:让内核自动生成一个唯一 key 不就完了?跟 PID 一样,跟文件描述符一样——系统生成,返回给你。
问题在于:进程 B 怎么知道这个 key 是多少?
系统里有十个、二十个共享内存段。进程 B 一进来,脸一抹黑,它怎么知道哪个是进程 A 创建的那个?
- 让 A 把 key 通过某种方式传给 B?那你都已经能传数据了,还要共享内存干嘛?见鬼了。
- 让 A fork 一个子进程?那共享内存就退化成只能父子之间用了,跟匿名管道一个层次。
所以结论是:key 必须由用户来设置。
A 和 B 在源代码层面约定好同一个 key——A 创建时用这个 key,B 获取时也用这个 key。大家 #include 同一个头文件,key 就一致了。
共享内存让两个毫不相干的进程看到同一份资源,靠的就是"约定一个 key"。 不需要提前通信,不需要 fork,纯约定。
那 key 具体怎么生成?ftok
你当然可以随便写一个数字:
key_t key = 0x6666;
但拍脑袋想的数字容易冲突——系统里别人也可能用了 0x6666。冲突了就创建失败,你还得调。
更好的做法是用 ftok:
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);
它要两个参数:
- pathname:一个存在的路径(可以是目录,可以是文件)
- proj_id:你自己随便写的一个整数(项目 ID)
ftok 本质是一个算法:它取这个路径对应文件的 inode 号,再取你给的 proj_id,两个数字做某种组合(比如异或、或者各取 16 位拼接),生成一个 32 位的整数作为 key。
严格来说,ftok 不属于系统调用。 它就是在用户态做了一组位运算——拿 inode 号和项目 ID 搅在一起生成一个数。没有陷入内核,没有上下文切换。它唯一依赖的"系统级数据"就是路径对应的 inode 号,而 inode 号可以通过 stat() 拿到。
只要 A 和 B 用同样的路径、同样的项目 ID,ftok 就会给它们生成同样的 key。
// shm.hpp 里封装
key_t get_key() {
const char *path = "/home/user/project"; // 随便一个存在的路径
int proj_id = 0x66;
key_t k = ftok(path, proj_id);
if (k < 0) {
std::cerr << "ftok failed" << std::endl;
return –1;
}
return k;
}
A 和 B 都 include 这个头文件,都调用 get_key(),拿到一样的 key。A 用 IPC_CREAT | IPC_EXCL 创建,B 用 IPC_CREAT 获取。搞定。
一个小麻烦:十进制 vs 十六进制
跑起来之后你会发现问题:代码里打印的 key 是十进制(std::cout << key 默认十进制),但 ipcs -m 显示的是十六进制。对不上,没法肉眼验证。
写个简单的转换函数:
std::string to_hex(int n) {
char hex[64];
snprintf(hex, sizeof(hex), "%x", n);
return std::string("0x") + hex;
}
现在 debug() 方法就能打印出跟 ipcs -m 格式一致的 key:
void shm::debug() {
std::cout << "key: " << to_hex(_key) << std::endl;
std::cout << "shmid: " << _shmid << std::endl;
}
reader 创建完、writer 获取完,两边打印的 key(十六进制)和 ipcs -m 第一列完全一致。客观上证明它们看到的是同一个共享内存。
shmid 为什么会一直往上涨?
反复创建、删除共享内存,你会发现 shmid 不是回收重用的——19、20、21、22……线性递增。
这跟它的内核实现有关:共享内存在内核里用数组管理,shmid 包含了数组下标和序列号信息。删了不会"填空",下次创建用新的下标。这是 System V IPC 资源的一个特点——不只是共享内存,消息队列、信号量的 id 也这样。
key 和 shmid:它俩什么关系?
| 谁生成的 | 用户通过 ftok | 内核通过 shmget 返回 |
| 在哪用 | 内核层面标识唯一性 | 用户层面标识共享内存 |
| 类比 | struct file 的地址(内核内部用) | 文件描述符(用户代码用) |
就像一个文件:inode 编号是内核用的,文件描述符 fd 是给你写代码用的。创建之后,你每次操作共享内存用的都是 shmid,key 基本不再出现。
一个反直觉的事实:进程退出了,共享内存还在
写个最简单的测试:
// reader 端
shm shm_obj;
shm_obj.create(); // shmget + IPC_CREAT | IPC_EXCL
// 打印 key 和 shmid
// 然后进程结束
第一次运行:创建成功,ipcs -m 能看到。
第二次运行:出错返回! 因为共享内存已经存在了,而你的选项是 IPC_CREAT | IPC_EXCL——存在就报错。
但你第一次的进程早就退出了。共享内存居然还在。
$ ipcs -m
—— Shared Memory Segments ——–
key shmid owner perms bytes nattch status
0x66626319 7 whb 0 4096 0
结论一:共享内存的生命周期随内核,不随进程。 除非你主动删除(系统调用 shmctl + IPC_RMID),或者关机重启,否则它永远在那里。
怎么删?两种方式:
# 命令行
ipcrm -m <shmid>
# 代码里
shmctl(shmid, IPC_RMID, nullptr);
注意:删除用 shmid,不是用 key。 key 是内核内部用的,用户层面的操作只能通过 shmid。
shmctl:不止是删除
shmctl 的函数名不是 shm_rm,也不是 shm_delete。它叫 control——控制。
因为对共享内存的操作不只是删除,还有:
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
获取属性的时候,你需要一个 struct shmid_ds:
struct shmid_ds {
struct ipc_perm shm_perm; /* 权限信息 */
size_t shm_segsz; /* 共享内存大小 */
pid_t shm_cpid; /* 创建者的 PID */
pid_t shm_lpid; /* 最后一次操作的进程 PID */
shmatt_t shm_nattch; /* 当前挂接数 */
time_t shm_atime; /* 最后一次挂接时间 */
time_t shm_dtime; /* 最后一次去关联时间 */
time_t shm_ctime; /* 最后一次修改时间 */
};
struct ipc_perm {
key_t __key; /* 就是你设进去的那个 key! */
uid_t uid; /* 拥有者 */
gid_t gid;
uid_t cuid; /* 创建者 */
gid_t cgid;
unsigned short mode; /* 权限 */
unsigned short __seq; /* 序列号 */
};
看到了吗?你之前设进去的 key,就躺在 shm_perm.__key 里面。
写个验证代码:
void get_attr() {
struct shmid_ds ds;
int n = shmctl(_shmid, IPC_STAT, &ds);
if (n < 0) {
std::cerr << "shmctl IPC_STAT failed" << std::endl;
return;
}
std::cout << "创建者 PID: " << ds.shm_cpid << std::endl;
std::cout << "大小: " << ds.shm_segsz << std::endl;
std::cout << "键值: " << ds.shm_perm.__key << std::endl;
std::cout << "我的 PID: " << getpid() << std::endl;
}
跑一下:ds.shm_cpid 跟你 getpid() 的值一模一样。键值也跟你 ftok 生成的一样。内核里确实存着这个结构体。
操作系统管理共享内存,跟管理进程一样——先描述,再组织。共享内存在内核里有一个 struct shmid_ds(或者类似的结构体),你创建 10 个共享内存,就有 10 个这样的结构体变量,用数组或链表串起来。所谓的"管理共享内存",就是对这堆结构体做增删查改。
创建者和拥有者不是一回事
注意 shmid_ds 里有两个不同的字段:shm_perm.cuid(创建者)和 shm_perm.uid(拥有者)。
一个东西是你创建的,它不一定是你的。 就像农民工大叔在城里盖了一栋房子——他是创建者,但房子不归他。共享内存也一样:进程 A 创建的共享内存,可以通过 IPC_SET 把拥有者改成另一个用户。
shmctl 的控制能力包含了对这些属性的查询和修改——权限、拥有者、大小(部分)都可以动态调整。
shmat / shmdt:把共享内存挂到你的地址空间
光创建还不够。共享内存段存在内核里,你的进程还没法访问它。得把它"挂接"到你自己的虚拟地址空间。
#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);
shmat —— attach。shmdt —— detach。
第二个参数 shmaddr 是指定挂接地址。传 NULL 就行——让操作系统自己选一块空闲的共享区地址。你自己指定很难,因为你不知道哪块地址是空的。
那这个参数为什么存在?因为操作系统自己也可以调自己的系统调用。 就像你妈做饭——饭是她做的,她当然能吃。操作系统加载动态库时,就会在内部用类似的机制指定一个地址把 .so 映射进去。我们用户态程序没必要指定,NULL 足够。
第三个参数 shmflg 可以设权限(比如 SHM_RDONLY 只读挂接),传 0 就行。
最关键的是返回值——一个 void * 指针。挂接成功,它就指向共享内存在你进程里映射的起始地址。
void *attach() {
void *addr = shmat(_shmid, nullptr, 0);
if (addr == (void *)–1) {
std::cerr << "shmat failed" << std::endl;
return nullptr;
}
return addr;
}
这个返回值像什么?像 malloc。
- malloc 在堆上开空间,返回堆地址
- shmat 在共享区映射空间,返回共享区地址
使用方式也一样——拿指针直接访问,不需要任何系统调用。
// 写端
void *addr = shm_obj.attach();
int *p = (int *)addr;
*p = 1777;
// 读端
void *addr = shm_obj.attach();
int *p = (int *)addr;
std::cout << *p << std::endl; // 1777
A 写完,B 立刻就能读到。没有 read,没有 write,没有拷贝。
这就是共享内存为什么是 IPC 里最快的。
你最好自己跑一下:attach 之后 nattch 的变化
把 reader 改成先创建、再挂接、再查看:
shm shm_obj;
shm_obj.create(); // shmget
sleep(3);
void *addr = shm_obj.attach(); // shmat
sleep(3);
shm_obj.delete_it(); // shmctl + IPC_RMID
用 watch ipcs -m 盯着:
- 进程启动 → nattch = 0,共享内存出现
- 3 秒后 → nattch = 1,挂接数变成 1
- 再过 3 秒 → 共享内存消失
实际怎么用:把共享内存当结构体
裸的 void* 指针只能按字节访问,实际用的时候都是强转成结构体:
// 通信双方约定好的数据结构
struct Data {
int count;
char buffer[1024];
};
写端:
void *addr = shm_obj.attach();
Data *p = (Data *)addr;
p->count = 42;
strcpy(p->buffer, "hello from writer");
读端:
void *addr = shm_obj.attach();
Data *p = (Data *)addr;
std::cout << p->count << std::endl; // 42
std::cout << p->buffer << std::endl; // "hello from writer"
因为共享内存已经映射到用户空间,你拿到的是虚拟地址,跟 malloc 出来的堆内存用起来没有任何区别。可以把它当数组、当结构体、当类——随便你怎么组织,不需要任何系统调用。
换个问法:你 malloc 一块内存怎么用的?拿指针强转成你想要的类型,然后读写。共享内存呢?完全一样。
共享内存没有自带保护机制
这是一个重要的坑。
共享内存映射到用户空间后,任何挂接了它的进程都能随时读、随时写——没有锁,没有同步,没有互斥。
A 正在写一半,B 跑过来读——读到的就是半成品数据。
管道有天然的同步:读端在数据到来前会阻塞,写端在缓冲区满之前不会写。共享内存什么都没有——它就一块裸内存。
所以共享内存通常要搭配信号量或其他同步机制使用。 光有共享内存不够。
这一点我们到信号量那部分再细说。
附带概念:并发编程的几个基本词
既然提到了"多进程同时访问同一份资源会出问题",就得把几个概念摆出来。这些词后面讲信号量、线程同步时反复出现。
临界资源:任何时刻只允许一个执行流访问的资源。共享内存是临界资源。打印机是临界资源。全局变量在多线程里也是临界资源。
临界区:进程中访问临界资源的那段代码。不是整个进程都是临界区——只有那些真正读写共享资源的部分才算。
非临界区 ──→ 临界区(访问共享资源)──→ 非临界区
↑ 这里需要保护
互斥:同一时刻只允许一个执行流进入临界区。A 在里面的时候,B 在外面排队。
同步:多个执行流按一定顺序访问临界资源。A 写完了通知 B 去读——这就是同步。
不保护会怎样? 举个最简单的例子——父子进程同时 printf 往 stdout 打:
父进程: printf("AAAAA\\n");
子进程: printf("BBBBB\\n");
你期望看到的是整齐的两行 AAAAA 和 BBBBB。但实际跑起来,stdout 是共享资源(都指向同一个终端),两个进程没有做任何同步,输出经常变成:
AABBAABBAA
BBAA
字符搅在一起。多个执行流并发访问同一份共享资源,没有保护就一定出乱子。 共享内存也一样——A 写一半,B 来读,读到的就是半成品。
所有对共享资源的保护,本质是对访问共享资源的代码段进行保护。 不是保护资源本身(资源就在那,没法"保护"),而是保护"谁、什么时候、以什么顺序"去碰它。
共享内存本身不带任何保护——这就注定了它必须跟信号量搭档。共享内存负责"传数据",信号量负责"管秩序"。
关于补充内容:消息队列和信号量的代码
信号量和消息队列的完整编码部分在直播中没有展开讲——这两种技术现在确实用得少了,网络 socket 既能本地又能跨网络,扩展性好得多。但代码已经录成了附加课:消息队列部分用责任链模式包装,信号量部分用建造者模式包装。有兴趣可以看,没精力的话,课堂上的共享内存部分完全够用。三种技术接口高度一致(xxxget、xxxctl、xxxop),学透一个,另外两个看接口就能上手。
最后:操作系统到底怎么管理共享内存?
回到最本质的问题。Linux 内核里可能同时存在几十个共享内存段——有的是刚创建的,有的是被挂接的,有的是等待释放的。它们状态各不相同,大小各不相同,权限各不相同。
操作系统怎么管理?先描述,再组织。
就像进程有 PCB (task_struct),文件有 struct file 和 struct inode,共享内存也有自己的描述结构体。
在内核里(大概是 struct shmid_ds 或者类似的结构体),记录了:
- 这个共享内存的 key
- 这个共享内存的大小
- 谁创建的 (cuid, cgid)
- 谁拥有的 (uid, gid)
- 权限 (mode)
- 当前有几个进程挂接了 (nattch)
- 创建时间、最后挂接时间、最后分离时间
所有的共享内存结构体,在内核里用数组(或链表)串起来。创建共享内存 = 在数组里新增一个元素;删除 = 从数组里移除;查找 = 遍历数组按 key 匹配。
操作系统说到底就是一个数据结构和算法的集合。 数据结构决定算法。把共享内存理解成"内核里一个数组元素",比把它想象成"一块神秘的物理内存"靠谱得多。
消息队列和信号量呢?提一嘴
System V 除了共享内存还有两个:消息队列和信号量。
消息队列:一个进程给另一个进程发送"有类型的数据块"。
struct msgbuf {
long mtype; // 消息类型,必须 > 0
char mtext[]; // 消息数据
};
int msgget(key_t key, int msgflg);
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
消息队列的内核结构体是 struct msqid_ds,跟共享内存的 shmid_ds 类似,都内嵌了 struct ipc_perm(包含 key、权限、拥有者信息)。
信号量:用来做多进程的同步和互斥——相当于一把跨进程的锁。原理后面再说。
这三种技术的接口高度一致——都有 xxxget 创建/获取、xxxctl 控制、xxxop 操作。因为它们是同一个标准 (System V) 下的产物。 共性极强,学透一个,另外两个看接口就能用。
所以到底发生了什么?就这几件事
共享内存 = 物理内存块 + 内核描述结构体。 创建时最重要的不是申请物理内存,而是在内核里创建描述结构体。理解了这个,“创建共享内存"就从神秘操作变成了"给内核数组加一个元素”。
key 由用户设置不是设计缺陷,是被逼的。 两个毫不相干的进程要在不通信的情况下找到同一块共享内存,只能靠"约定"。ftok 让这个约定变得可靠。
共享内存的生命周期随内核,不随进程。 进程死不死,共享内存都在那。你得主动删它。ipcs -m 查看,ipcrm -m <shmid> 删除。
创建需要系统调用,使用不需要。 shmget、shmat、shmctl 是系统调用。但一旦 shmat 返回了指针,读写共享内存就是纯粹的指针操作——没有 read/write,没有拷贝。所以它快。
共享内存没有保护机制。 裸的内存块,谁挂接了都能随时读写。数据一致性要你自己保证——通常搭配信号量。


