2. 共享内存(上):原理与数据结构设计,内核中的物理内存管理
大家好,欢迎来到《任务间通信机制内核级实现》的第二讲。今天咱们聊聊共享内存,这是任务间通信里效率最高的一种方式。为什么这么说?说白了,就是数据不用拷贝来拷贝去,大家直接读写同一块物理内存。
我个人习惯把共享内存比作「公共黑板」。几个任务都在上面写写画画,谁都能看,谁都能改。但问题也来了——怎么保证这块黑板不被人乱涂乱画?怎么管理这块物理内存?嗯,这就是我们今天要啃的硬骨头。
2.1 共享内存的核心原理
共享内存的原理其实很简单:让两个或多个进程/任务,通过页表映射到同一块物理内存页。这样,一个任务写数据,另一个任务马上就能看到,零拷贝,零延迟。
我在项目中遇到过这样一个场景:一个视频采集任务,每秒要传几十兆的数据给显示任务。如果用消息队列,光拷贝就得占掉一半的CPU时间。后来改成共享内存,CPU占用率直接降了40%。你想想看,这差距有多大。
核心要点:共享内存的本质是「虚拟地址不同,物理地址相同」。内核只负责建立映射关系,不参与数据传输。
2.2 内核中的物理内存管理
要实现共享内存,内核必须先管好物理内存。这块内容比较底层,我尽量讲得接地气些。
2.2.1 物理内存的层级结构
Linux内核把物理内存分成三个层级:Node → Zone → Page。为什么这么分?因为不同硬件的内存访问速度不一样。比如NUMA架构下,CPU访问本地内存和远端内存的速度差好几倍。
| 层级 | 说明 | 我踩过的坑 |
|---|---|---|
| Node(节点) | 每个CPU对应一个内存节点 | 曾经在NUMA机器上分配共享内存,没指定节点,结果跨节点访问慢得离谱 |
| Zone(区) | DMA区、Normal区、HighMem区 | DMA区的内存很宝贵,别乱用 |
| Page(页) | 基本分配单位,默认4KB | 大页(HugePage)能减少TLB miss,但分配时要小心 |
2.2.2 页框管理——struct page
内核里每个物理页框对应一个 struct page 结构体。这个结构体不大,但信息量很大。我挑几个关键字段说说:
struct page {
unsigned long flags; // 页的状态标志
atomic_t _refcount; // 引用计数
struct address_space *mapping; // 映射到的文件或匿名页
pgoff_t index; // 在映射中的偏移
struct list_head lru; // LRU链表节点
void *virtual; // 内核虚拟地址(如果映射了)
};
这里有个细节要注意:_refcount 是共享内存的关键。当多个任务映射同一页时,这个计数会大于1。我曾经调试过一个内存泄漏问题,就是共享内存页的引用计数没减干净,导致物理页一直释放不掉。
避坑指南:我曾经在驱动里手动操作 _refcount,结果搞出了死锁。后来学乖了,用 get_page() 和 put_page() 这两个内核API,安全又省心。
2.2.3 伙伴系统——物理内存分配器
内核用「伙伴系统」来管理物理页的分配和释放。它的核心思想是:把内存按2的幂次分成块,分配时找大小最合适的块,释放时合并相邻的空闲块。
举个例子:你要分配8KB(2页),伙伴系统会从2页的链表中取一块给你。如果2页链表空了,就从4页链表里拆一半出来。释放时,如果相邻的块也是空闲的,就合并成4页块放回去。
为什么会这样设计?因为碎片少,速度快。我在一个实时系统里测试过,伙伴系统的分配延迟基本稳定在微秒级,比slab分配器还快。
2.2.4 共享内存的物理页分配
当调用 shmget() 或 mmap() 创建共享内存时,内核会走这么几步:
- 调用
alloc_pages()从伙伴系统拿物理页 - 设置页的
mapping指向共享内存的address_space - 初始化
_refcount为1(第一个映射者) - 把页加入共享内存的LRU链表
这里有个关键点:共享内存的物理页是「匿名页」,没有对应的磁盘文件。所以当内存紧张时,内核会把它们交换到swap分区。嗯,这里要注意,如果swap分区不够大,共享内存分配可能会失败。
警告:共享内存的物理页一旦分配,除非显式释放或进程退出,否则会一直占用。我在一个嵌入式项目里见过,有人创建了共享内存后忘了删除,结果系统跑了三天就OOM了。
2.3 数据结构设计——shmid_kernel
内核用 struct shmid_kernel 来描述每一块共享内存。这个结构体是共享内存管理的核心:
struct shmid_kernel {
struct kern_ipc_perm shm_perm; // 权限控制
struct file *shm_file; // 指向共享内存文件的指针
unsigned long shm_nattch; // 当前附加的进程数
unsigned long shm_segsz; // 共享内存大小(字节)
time_t shm_atim; // 最后attach时间
time_t shm_dtim; // 最后detach时间
time_t shm_ctim; // 创建时间
pid_t shm_cprid; // 创建者PID
pid_t shm_lprid; // 最后操作者PID
};
我个人觉得,shm_file 这个字段最值得琢磨。它指向一个特殊的文件——tmpfs文件系统的文件。没错,共享内存底层其实是tmpfs在管。这样做的好处是:可以用VFS的page cache来管理共享内存的物理页,复用已有的内存回收机制。
你想想看,这设计多巧妙。不用自己再写一套内存管理代码,直接站在VFS的肩膀上。Linux内核里这种「复用现有机制」的思路随处可见。
2.4 共享内存的创建流程
咱们走一遍 shmget() 系统调用的内核路径:
- 用户态调用
shmget(key, size, flags) - 内核进入
ksys_shmget(),查找是否已有相同key的共享内存 - 如果没有,调用
newseg()创建新的共享内存段 newseg()里做三件事:- 分配
shmid_kernel结构体 - 在tmpfs中创建文件(
shm_file) - 初始化权限、大小等参数
- 分配
- 返回共享内存标识符(shmid)
这里有个细节:newseg() 里会调用 shmem_file_setup() 来创建tmpfs文件。这个文件的大小就是共享内存的大小,但物理页是「按需分配」的——只有真正访问到某个页时,才会触发缺页中断去分配物理内存。
经验之谈:我曾经以为 shmget() 一调用就会分配所有物理页,结果在一个大内存项目里(分配了2GB共享内存),系统卡了好几秒。后来才知道,物理页是懒分配的。所以如果你需要确保物理内存到位,可以创建后马上用 mlock() 锁住。
2.5 共享内存的附加与分离
创建完共享内存后,进程需要用 shmat() 把它附加到自己的地址空间。这个过程说白了就是「建立页表映射」:
// 简化的shmat流程
shmat() {
// 1. 根据shmid找到shmid_kernel
// 2. 在进程地址空间找一块空闲的虚拟地址
// 3. 调用do_mmap()建立映射
// - 设置vma的vm_file为shm_file
// - 设置vma的vm_ops为shm_vm_ops
// 4. 增加shm_nattch计数
// 5. 返回虚拟地址
}
分离时调用 shmdt(),做相反的操作:解除映射、减少引用计数。当 shm_nattch 降到0时,内核会释放共享内存的物理页。
嗯,这里要注意一个坑:如果进程崩溃了没来得及调用 shmdt(),内核会在进程退出时自动清理。但如果是内核模块里创建的共享内存,就得自己手动管理生命周期了。
2.6 小结与下期预告
今天咱们聊了共享内存的原理、物理内存管理、核心数据结构,以及创建和附加的流程。说白了,共享内存就是「让多个任务看同一块物理内存」,内核要做的就是管好物理页、建好页表映射。
下节课,我会继续讲共享内存的同步机制——信号量怎么和共享内存配合?多核环境下怎么保证数据一致性?还有,怎么避免死锁?这些都是实战中经常遇到的问题。
好,今天就到这里。如果你在项目中遇到过共享内存的坑,欢迎留言交流。咱们下期见。