共享内存基础:进程间最快的通信方式
大家好,我是你们的嵌入式系统讲师。今天我们来聊聊共享内存——这个在Linux进程间通信(IPC)工具箱里,速度最快、效率最高的家伙。
说实话,我刚开始做嵌入式开发那会儿,对共享内存的理解也就停留在「几个进程能同时访问一块内存」这个层面。直到有一次做视频采集系统,两个进程之间要传大量图像数据,用管道?太慢。用消息队列?封装开销太大。最后换了共享内存,性能直接翻倍。嗯,从那以后我就知道,这玩意儿是真香。
共享内存的概念与原理
共享内存,说白了就是让两个或多个进程,共同访问同一块物理内存区域。
你想想看,正常情况下每个进程都有自己的虚拟地址空间,A进程的地址0x1000和B进程的0x1000,映射到物理内存上完全是两个不同的地方。但共享内存不一样——它让不同进程的虚拟地址,映射到同一块物理内存上。
这样一来,A进程往这块内存写数据,B进程马上就能读到。不需要拷贝,不需要经过内核中转,直接读写。这就是它快的原因。
核心要点:共享内存是IPC中唯一不经过内核中转的数据交换方式。管道、消息队列、Socket都要在内核空间和用户空间之间来回拷贝数据,共享内存不用。
共享内存与物理内存的关系
这里有个容易混淆的点,我当年也踩过坑。
共享内存本身并不占用物理内存——它只是建立了一个映射关系。真正占用物理内存的,是进程往共享内存里写数据的那一刻。
打个比方:共享内存就像一张空桌子。你说「这张桌子归我们几个用了」,这叫创建共享内存。但桌子本身是空的,不占地方。只有当你往桌上放东西,才真正占用了空间。
在Linux内核里,共享内存是通过shmid_kernel结构体来管理的。每个共享内存段都有一个唯一的标识符——shmid。内核维护着一张全局的共享内存表,记录着哪些段被创建了、多大、谁在用。
我的经验:用ipcs -m命令可以查看当前系统里所有的共享内存段。调试的时候特别好用。我曾经靠这个命令抓到一个内存泄漏的bug——某个进程创建了共享内存但忘了删除,系统里堆了几百个废弃的共享内存段。
共享内存的创建:shmget
创建共享内存,用的是shmget函数。它的原型长这样:
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
三个参数:
- key:相当于共享内存的名字。不同进程通过这个key找到同一块共享内存。可以用
IPC_PRIVATE(只能用于父子进程),也可以用ftok()生成一个唯一的key。 - size:共享内存的大小,单位是字节。注意,内核会按页对齐(通常是4KB),所以你指定的大小会被向上取整到页的整数倍。
- shmflg:权限标志,类似文件权限。比如
IPC_CREAT | 0666表示如果不存在就创建,权限是读写。
返回值是shmid,后面所有的操作都靠这个id来定位共享内存段。
注意:shmget返回的是shmid,不是指针!很多新手会搞混。shmid只是一个整数标识符,你得用shmat把它挂接到进程的地址空间,才能拿到真正的指针。
共享内存的挂接与脱接:shmat / shmdt
创建好共享内存之后,进程要使用它,必须先「挂接」到自己的地址空间里。
void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);
shmat的参数:
- shmid:shmget返回的标识符。
- shmaddr:建议的挂接地址。传NULL让内核自己选,我建议你永远传NULL——省心。
- shmflg:通常传0。如果传
SHM_RDONLY,就只能读不能写。
返回值是一个指针,指向共享内存的起始地址。拿到这个指针,你就可以像操作普通内存一样读写它了。
shmdt就简单了——把共享内存从当前进程的地址空间里卸下来。注意,这只是断开映射,不会删除共享内存本身。
我个人的习惯:用完共享内存后,立刻调用shmdt。别等到进程退出时让系统自动清理。在长时间运行的服务里,不及时脱接会导致虚拟地址空间碎片化,时间长了可能出问题。
共享内存的控制:shmctl
shmctl是共享内存的「控制面板」。它的功能很丰富:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
常用的cmd有这几个:
| 命令 | 作用 |
|---|---|
| IPC_STAT | 获取共享内存的状态信息,存到buf里 |
| IPC_SET | 修改共享内存的权限、所有者等属性 |
| IPC_RMID | 标记共享内存为删除状态 |
这里有个细节要注意:IPC_RMID并不是立即删除共享内存。它只是打上一个「待删除」的标记。只有当所有挂接的进程都调用shmdt脱接之后,内核才会真正释放这块内存。
我曾经踩过的坑:有一次我调用了shmctl(..., IPC_RMID, NULL),然后以为共享内存已经被删了。结果另一个进程还在读写它,数据全乱了。后来才明白,IPC_RMID只是标记删除,不是立即释放。正确的做法是:先通知所有进程脱接,再调用IPC_RMID。
共享内存的读写操作
读写共享内存,其实就是读写普通内存。拿到shmat返回的指针后,用memcpy、sprintf、甚至直接指针赋值都行。
// 写数据
char *data = (char *)shmat(shmid, NULL, 0);
strcpy(data, "Hello from process A");
// 读数据
char buffer[256];
strcpy(buffer, data);
printf("Read: %s\n", buffer);
就这么简单。没有系统调用,没有数据拷贝,直接读写物理内存。
但简单归简单,有个大问题——同步。
共享内存的同步问题
这是共享内存最头疼的地方,也是面试最爱问的。
你想啊,两个进程同时往同一块内存写数据,会发生什么?数据竞争,结果不可预期。A进程写了一半,B进程读到了半成品数据,那就乱套了。
共享内存本身不提供任何同步机制。它就是个「裸」内存,你得自己想办法保证数据的一致性。
常用的同步手段有:
- 信号量:最经典的做法。写之前P操作,写完之后V操作。保证同一时刻只有一个进程在写。
- 互斥锁:如果共享内存放在多线程环境里,可以用pthread的互斥锁。但要注意,互斥锁默认只在同一进程的线程间有效,跨进程要用
PTHREAD_PROCESS_SHARED属性。 - 文件锁:简单粗暴,但性能差一些。
我的建议:对于简单的生产者-消费者场景,用信号量就够了。对于复杂的数据结构(比如共享队列、共享缓冲区),建议用读写锁——读操作可以并发,写操作独占。我在做多路视频采集时就是这么干的,读进程有3个,写进程只有1个,读写锁比互斥锁性能好很多。
知识体系总览
下面这张图,把共享内存的核心知识点串起来了:
从图上可以看得很清楚:共享内存的核心就是「映射到同一块物理内存」。创建、挂接、控制、读写、同步,这五个环节缺一不可。其中同步问题是最容易出错的,也是实际项目中需要花最多心思去设计的。
好了,共享内存的基础知识就讲到这里。记住一句话:共享内存是IPC的速度之王,但同步问题是你绕不过去的坎。下一节我们会深入实战,用代码把今天讲的东西全部跑一遍。