第4章:共享内存(Shared Memory)——进程间最快的“快递员”
聊到进程间通信,我个人最偏爱的方式就是共享内存。为什么?说白了,它够快。你想想看,管道要拷贝数据,消息队列也要拷贝,但共享内存呢?两个进程直接读写同一块物理内存,零拷贝!
我在项目中遇到过一个大数据的场景:一个进程负责采集传感器数据,另一个负责实时分析。数据量动不动就几百兆。用管道?慢得让人抓狂。换成共享内存后,性能直接起飞。嗯,今天我们就来好好聊聊这个“快枪手”。
4.1 共享内存的原理与优势
共享内存的原理其实很简单。内核在物理内存中划出一块区域,然后让两个或多个进程的虚拟地址空间都映射到这块区域。这样一来,进程A往这块内存写数据,进程B立马就能看到。
它的核心优势就三个字:快、快、快。
- 零拷贝:数据不需要在内核和用户空间之间来回搬运。这是它比管道、消息队列快一个数量级的根本原因。
- 大数据友好:你传1KB和传1GB,开销几乎一样。因为只是修改了页表映射,没有数据拷贝动作。
- 双向通信:只要双方约定好读写规则,共享内存天然就是全双工的。
核心要点:共享内存是IPC中吞吐量最高的方式,没有之一。但它的代价是——你需要自己处理同步问题。
为什么会这样?因为共享内存本身不提供任何锁机制。两个进程同时写一块区域,数据就乱套了。所以,共享内存通常要搭配信号量或互斥锁一起使用。这个我们后面会讲到。
4.2 System V 共享内存 API 详解
Linux下有两套共享内存API:POSIX的和System V的。我个人习惯用System V的,虽然接口老一点,但稳定可靠,我在生产环境用了很多年没出过问题。
核心API就四个函数:
| 函数 | 作用 | 头文件 |
|---|---|---|
shmget() |
创建或获取共享内存段 | sys/shm.h |
shmat() |
将共享内存附加到进程地址空间 | sys/shm.h |
shmdt() |
分离共享内存 | sys/shm.h |
shmctl() |
控制共享内存(删除、查询状态等) | sys/shm.h |
4.2.1 shmget() — 创建或获取
这个函数是第一步。它的原型是:
int shmget(key_t key, size_t size, int shmflg);
- key:相当于共享内存的“门牌号”。两个进程用同一个key就能找到同一块内存。通常用
ftok()生成。 - size:共享内存的大小,单位字节。建议按页对齐(4KB的倍数)。
- shmflg:权限标志。比如
IPC_CREAT | 0666表示如果不存在就创建,权限为读写。
返回值是共享内存的标识符(shmid),后面三个函数都要用到它。
小技巧:我习惯用IPC_CREAT | IPC_EXCL来确保只有第一个进程负责创建,后面的进程只获取。这样可以避免重复初始化的问题。
4.2.2 shmat() — 附加到进程
创建好共享内存后,进程需要把它“挂载”到自己的虚拟地址空间里。这就是shmat()干的事:
void *shmat(int shmid, const void *shmaddr, int shmflg);
- shmid:shmget返回的标识符。
- shmaddr:建议传NULL,让内核自己选地址。指定地址容易出问题,我不推荐。
- shmflg:一般传0。如果传
SHM_RDONLY,就只能读不能写。
返回值是映射后的起始地址。之后你就可以像操作普通指针一样读写这块内存了。
4.2.3 shmdt() — 分离
用完了记得分离:
int shmdt(const void *shmaddr);
注意,分离只是解除映射,不会删除共享内存。共享内存会一直存在,直到你显式删除或系统重启。
4.2.4 shmctl() — 控制与删除
这个函数功能很强大。最常用的操作是删除:
shmctl(shmid, IPC_RMID, NULL);
删除后,所有进程都无法再attach。但已经attach的进程还能继续用,直到它们自己detach。
我曾经踩过的坑:忘记调用shmctl()删除共享内存,导致系统共享内存资源耗尽。用ipcs -m一看,一堆废弃的共享内存段。后来我写了个监控脚本,定期清理孤儿共享内存。
4.3 实战:两个进程通过共享内存交换大数据
光说不练假把式。我们写一个完整的例子:进程A写入1MB数据,进程B读取并校验。
4.3.1 公共头文件
// shm_common.h
#ifndef SHM_COMMON_H
#define SHM_COMMON_H
#include <sys/shm.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define SHM_KEY 0x1234
#define SHM_SIZE (1024 * 1024) // 1MB
typedef struct {
int ready; // 数据是否就绪
int data_len; // 实际数据长度
char data[SHM_SIZE]; // 数据区
} shared_data;
#endif
这里我定义了一个结构体,包含一个ready标志位。为什么?因为共享内存没有同步机制,我们需要一个简单的“生产者-消费者”协议。进程A写完数据后把ready设为1,进程B看到1就开始读。
4.3.2 写入进程(writer.c)
// writer.c
#include "shm_common.h"
int main() {
// 创建共享内存
int shmid = shmget(SHM_KEY, sizeof(shared_data), IPC_CREAT | 0666);
if (shmid == -1) {
perror("shmget");
exit(1);
}
// 附加到进程空间
shared_data *shm = (shared_data *)shmat(shmid, NULL, 0);
if (shm == (void *)-1) {
perror("shmat");
exit(1);
}
// 准备数据
memset(shm->data, 'A', SHM_SIZE);
shm->data_len = SHM_SIZE;
// 内存屏障,确保数据写完后再设置标志
__sync_synchronize();
shm->ready = 1;
printf("Writer: 数据已写入,共 %d 字节\n", shm->data_len);
// 等待读者读完
sleep(2);
// 分离并删除
shmdt(shm);
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
注意我用了__sync_synchronize()这个内存屏障。为什么?因为编译器可能会重排指令,把ready=1提前到数据写入之前。那样读者就会读到不完整的数据。嗯,这个细节很容易被忽略。
4.3.3 读取进程(reader.c)
// reader.c
#include "shm_common.h"
int main() {
// 获取共享内存(不创建)
int shmid = shmget(SHM_KEY, sizeof(shared_data), 0666);
if (shmid == -1) {
perror("shmget");
exit(1);
}
// 附加
shared_data *shm = (shared_data *)shmat(shmid, NULL, 0);
if (shm == (void *)-1) {
perror("shmat");
exit(1);
}
// 等待数据就绪
while (!shm->ready) {
usleep(100); // 忙等待,实际项目建议用信号量
}
// 内存屏障,确保读到最新的数据
__sync_synchronize();
// 校验数据
int valid = 1;
for (int i = 0; i < shm->data_len; i++) {
if (shm->data[i] != 'A') {
valid = 0;
break;
}
}
if (valid) {
printf("Reader: 数据校验成功!共 %d 字节\n", shm->data_len);
} else {
printf("Reader: 数据校验失败!\n");
}
// 分离
shmdt(shm);
return 0;
}
4.3.4 编译与运行
gcc -o writer writer.c
gcc -o reader reader.c
# 先运行writer,再运行reader(另一个终端)
./writer
./reader
运行结果应该是:
Writer: 数据已写入,共 1048576 字节
Reader: 数据校验成功!共 1048576 字节
关键点总结:
- 共享内存本身不提供同步,需要自己实现协议(如这里的ready标志)。
- 内存屏障是必须的,否则编译器和CPU的重排会坑你。
- 生产环境中,建议用信号量或互斥锁替代忙等待。
4.4 避坑指南
最后,分享几个我实战中遇到的坑:
- 共享内存泄漏:进程崩溃退出时,如果没调用
shmctl(IPC_RMID),共享内存会变成孤儿。用ipcs -m查看,ipcrm -m shmid手动删除。 - 大小限制:Linux默认的共享内存最大是32MB。如果需要更大,用
sysctl -w kernel.shmmax=1073741824调整。 - 权限问题:两个进程必须用相同的key,并且有足够的权限。我遇到过用root创建,普通用户无法attach的情况。
- 指针陷阱:共享内存里不能存指针!因为不同进程的虚拟地址空间不同,指针指向的地址在另一个进程里是无效的。用偏移量代替。
好了,共享内存的内容就到这里。下一章我们聊聊信号量——那个专门用来给共享内存“上锁”的好搭档。