16、共享内存:进程间通信、shmget/shmat、同步问题
共享内存,说白了就是让两个或多个进程,能直接读写同一块物理内存。这是Linux下效率最高的进程间通信方式——没有之一。为什么?因为它不需要像管道那样拷贝数据,也不需要像消息队列那样反复进出内核。数据就在那里,谁都能看,谁都能改。
我刚开始接触共享内存时,觉得这东西挺神奇的。两个完全独立的进程,地址空间是隔离的,怎么能访问同一块内存呢?后来才明白,这其实是操作系统在背后做了手脚——把同一块物理内存映射到不同进程的虚拟地址空间里。
16.1 共享内存的核心概念
共享内存的工作原理,可以用一张图说清楚:
你看,进程A和进程B的虚拟地址可能完全不同,但操作系统把它们的页表项指向了同一个物理页帧。这样一来,A写进去的数据,B马上就能读到——零拷贝,零延迟。
核心要点:共享内存是IPC(进程间通信)中速度最快的方式,因为它避免了数据在内核空间和用户空间之间的来回拷贝。但代价是——你需要自己处理同步问题。
16.2 shmget:创建或获取共享内存
要使用共享内存,第一步就是调用shmget()。这个函数要么创建一个新的共享内存段,要么获取一个已经存在的。
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
参数说明:
- key:一个整数,用来标识共享内存段。不同进程通过相同的key找到同一块内存。一般用
ftok()生成。 - size:共享内存的大小,单位字节。创建时必须指定,获取时填0即可。
- shmflg:权限标志,类似文件权限。常用
IPC_CREAT | 0666表示创建并设置读写权限。
返回值:成功返回共享内存标识符(shmid),失败返回-1。
我的习惯:创建共享内存时,我一般会加上IPC_EXCL标志,这样如果同key的内存已经存在,shmget()会返回错误,避免不小心覆盖已有数据。
16.3 shmat:把共享内存挂到进程地址空间
shmget()只是拿到了一个标识符,你还得用shmat()把它映射到进程的虚拟地址空间里,才能真正读写。
void *shmat(int shmid, const void *shmaddr, int shmflg);
参数说明:
- shmid:
shmget()返回的共享内存标识符。 - shmaddr:建议的映射地址。填NULL让系统自动选择,我建议你永远填NULL——省心。
- shmflg:通常填0表示可读写。如果填
SHM_RDONLY则只读。
返回值:成功返回映射后的虚拟地址,失败返回(void*)-1。
用完之后,记得调用shmdt()解除映射。但这只是从当前进程的地址空间里移除,共享内存本身还在,其他进程仍然可以访问。
注意:shmat()返回的地址,你直接当普通指针用就行。但千万别越界——你申请了1024字节,就别写到1025去。我曾经见过一个同事,因为越界写把另一个进程的数据给踩了,排查了整整两天。
16.4 一个完整的例子
光说不练假把式。我们写一个简单的例子:进程A写入数据,进程B读取数据。
写端(writer.c):
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/shm.h>
#include <unistd.h>
#define SHM_SIZE 1024
int main() {
key_t key = ftok("/tmp", 'A');
if (key == -1) {
perror("ftok");
exit(1);
}
int shmid = shmget(key, SHM_SIZE, IPC_CREAT | 0666);
if (shmid == -1) {
perror("shmget");
exit(1);
}
char *data = (char *)shmat(shmid, NULL, 0);
if (data == (void*)-1) {
perror("shmat");
exit(1);
}
const char *msg = "Hello from writer!";
strcpy(data, msg);
printf("Writer: 已写入数据: %s\n", msg);
// 等待读端读取
sleep(5);
shmdt(data);
// 注意:这里没有删除共享内存,读端还需要用
return 0;
}
读端(reader.c):
#include <stdio.h>
#include <stdlib.h>
#include <sys/shm.h>
#define SHM_SIZE 1024
int main() {
key_t key = ftok("/tmp", 'A');
if (key == -1) {
perror("ftok");
exit(1);
}
int shmid = shmget(key, SHM_SIZE, 0666);
if (shmid == -1) {
perror("shmget");
exit(1);
}
char *data = (char *)shmat(shmid, NULL, 0);
if (data == (void*)-1) {
perror("shmat");
exit(1);
}
printf("Reader: 读取到数据: %s\n", data);
shmdt(data);
// 读完后可以删除共享内存
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
先运行writer,再运行reader。你会看到reader成功打印出writer写入的字符串。
注意顺序:这个例子能跑通,是因为writer在写入后sleep了5秒,给了reader足够的时间来读取。但在实际项目中,你不能依赖sleep——你需要用信号量或互斥锁来同步。
16.5 同步问题:共享内存的阿克琉斯之踵
共享内存本身不提供任何同步机制。两个进程同时写同一块内存,数据就乱了。这就是经典的「竞态条件」。
我举个例子:假设两个进程都在更新一个计数器:
// 两个进程同时执行
counter = counter + 1;
在汇编层面,这行代码可能被拆成三步:
- 从内存读counter到寄存器
- 寄存器加1
- 写回内存
如果进程A执行完第2步,还没来得及写回,进程B就开始执行——那结果就错了。本来应该加2,结果只加了1。
怎么解决?常用的方法有:
| 方法 | 说明 | 适用场景 |
|---|---|---|
| 信号量(System V) | 用semget/semop实现PV操作 |
多进程同步,经典方案 |
| 互斥锁(pthread) | 需要把互斥锁放在共享内存中 | 多线程+多进程混合场景 |
| 文件锁 | 用flock或fcntl |
简单场景,但性能差 |
| 原子操作 | 用GCC内置的__sync_fetch_and_add |
简单计数器,性能最好 |
我的经验:如果只是传递少量数据(比如一个状态标志),用原子操作就够了。但如果是传递大块数据(比如视频帧),我建议用信号量配合双缓冲——一个缓冲区在写,另一个在读,用信号量切换。这样读写互不干扰。
16.6 共享内存的生命周期管理
共享内存不像普通内存,进程退出后它不会自动消失。你得手动删除,否则它会一直留在系统里,直到重启。
查看系统里有哪些共享内存:
ipcs -m
删除指定的共享内存:
ipcrm -m shmid
或者在代码里用shmctl()删除:
shmctl(shmid, IPC_RMID, NULL);
我曾经踩过的坑:有一次在服务器上调试程序,频繁创建共享内存但忘了删除。结果系统报"无法分配共享内存",一查发现共享内存段数量达到了系统上限。用ipcs -m一看,好家伙,几十个废弃的共享内存段挂在那里。从那以后,我养成了习惯——在程序退出时,由最后一个使用者负责清理。
16.7 共享内存 vs 其他IPC方式
我把几种常见的IPC方式做了个对比,方便你选型:
| 方式 | 速度 | 是否需要同步 | 典型场景 |
|---|---|---|---|
| 共享内存 | 最快 | 是 | 大数据量、高频通信 |
| 管道/命名管道 | 中等 | 否(自带同步) | 流式数据、简单通信 |
| 消息队列 | 较慢 | 否(自带同步) | 消息传递、解耦 |
| Socket | 较慢 | 否(自带同步) | 跨网络通信 |
说白了,共享内存就是「用复杂度换速度」。你得到了最高的性能,但必须自己处理同步、生命周期、死锁等问题。如果你不需要那么高的性能,用管道或消息队列会更省心。
总结一下:共享内存是Linux下最快的IPC方式,核心函数就三个——shmget创建/获取、shmat映射、shmdt解除映射。同步问题必须用信号量或互斥锁解决,别指望共享内存自己会帮你做。最后,记得清理——ipcrm或shmctl是你的好帮手。
公众号:蓝海资料掘金营,微信deep3321