一、多进程内存模型概述
各位同学,今天我们来聊聊多进程架构下的内存管理。说实话,这块内容我当年刚接触时也觉得挺抽象的,但后来在项目中踩过几次坑,才真正理解它的重要性。
多进程的内存模型,说白了就是回答三个问题:
每个进程怎么拥有自己的独立空间?
虚拟地址怎么映射到真实的物理内存?
进程间共享数据时,内存是怎么高效复制的?
嗯,我们一个一个来看。
1.1 进程地址空间隔离
先问大家一个问题:如果两个进程同时访问内存地址 0x1000,会发生什么?
你可能会想,那不就冲突了吗?
实际上不会。因为每个进程看到的地址,都是虚拟地址。操作系统给每个进程都画了一个「假的地图」——进程以为自己拥有从 0 到 4GB(32位系统)的完整空间,但实际上这只是个幻觉。
地址空间隔离,就是这个幻觉的核心。
我当年在做一个嵌入式系统时,遇到过一个问题:进程 A 不小心写了一个野指针,结果进程 B 莫名其妙崩溃了。排查了三天,最后发现是内核模块里共享内存的权限没设对。从那以后,我对地址空间隔离有了刻骨铭心的理解。
关键点:每个进程的虚拟地址空间是独立的,进程 A 的 0x1000 和进程 B 的 0x1000 对应的是完全不同的物理内存。一个进程崩溃,不会直接影响其他进程——这就是隔离带来的好处。
地址空间隔离的好处很明显:
- 安全性:进程不能随意读写其他进程的内存
- 稳定性:一个进程的 bug 不会拖垮整个系统
- 可移植性:每个进程可以独立编译、链接,不用管其他进程的地址布局
1.2 虚拟内存与物理内存映射
好,那虚拟地址怎么变成物理地址呢?
这里就要提到 MMU(内存管理单元) 了。它是 CPU 里的一个硬件模块,专门干这个翻译的活。
流程大概是这样的:
- CPU 执行指令,访问一个虚拟地址
- MMU 查页表,找到对应的物理地址
- 如果页表里没有(缺页),触发缺页异常,操作系统去磁盘上找
- 找到后,更新页表,重新执行指令
我习惯把这个过程比作「查字典」。虚拟地址是你要查的单词,页表就是字典,物理地址就是单词的解释。每次访问内存,都要翻一次字典。
当然,翻字典太慢了。所以 CPU 里有个 TLB(快表),缓存最近用过的映射关系。我见过一些性能调优的案例,TLB 命中率从 90% 提升到 99%,整个系统的吞吐量直接翻倍。
我的经验:在写高性能服务时,尽量让数据在内存中连续存放。这样虚拟地址也是连续的,TLB 的覆盖范围更大,命中率更高。我曾经把一个哈希表改成数组,性能提升了 30%。
页表的结构,不同架构略有不同。以 x86-64 为例,一般是四级页表:
| 层级 | 名称 | 作用 |
|---|---|---|
| L1 | PML4 | 顶级页表,指向下一级 |
| L2 | PDPT | 页目录指针表 |
| L3 | PD | 页目录 |
| L4 | PT | 页表项,指向物理页框 |
你想想看,每次地址翻译要查四次表,是不是很慢?所以 TLB 才那么重要。
1.3 写时复制技术原理
最后聊聊写时复制(Copy-on-Write,简称 COW)。这个技术太经典了,我几乎每天都在用。
场景是这样的:
父进程调用 fork() 创建子进程。按照传统做法,要把父进程的整个地址空间复制一份给子进程。如果父进程占用了 1GB 内存,复制就要花很长时间,而且浪费物理内存。
COW 的做法就聪明多了:
- 创建子进程时,不复制物理内存
- 父子进程共享同一份物理内存
- 把这些页标记为「只读」
- 谁要写,谁就触发缺页异常,内核再复制一份
说白了,就是「懒加载」——不到万不得已,绝不复制。
核心思想:共享 + 延迟复制。大多数情况下,子进程 fork 后马上 exec 执行新程序,根本不会写父进程的内存。COW 避免了无谓的复制。
我曾经在项目中遇到一个坑:一个服务 fork 了 100 个子进程,每个子进程都持有父进程的大块内存。虽然用了 COW,但子进程各自修改了少量数据,导致大量页面被复制。结果物理内存被撑爆了。
避坑指南:如果你 fork 大量子进程,并且每个子进程都会修改内存,那 COW 反而会加剧内存压力。我曾经建议团队改用线程池 + 共享内存的方式,内存占用从 8GB 降到了 2GB。
COW 的实现,在 Linux 内核里大概是这样:
// 伪代码示意
fork() {
// 1. 复制父进程的页表
child->page_table = copy_page_table(parent->page_table);
// 2. 将所有可写页标记为只读
foreach page in child->page_table {
if (page->writable) {
page->writable = false;
page->cow = true; // 标记为 COW 页
}
}
// 3. 父子进程共享物理页
// 实际物理页引用计数 +1
}
// 当进程尝试写一个 COW 页时:
handle_page_fault(address) {
page = lookup_page(address);
if (page->cow) {
// 分配新物理页
new_page = alloc_physical_page();
// 复制内容
memcpy(new_page, page->physical_addr, PAGE_SIZE);
// 更新页表,指向新页,并设为可写
update_page_table(address, new_page, writable=true);
// 原页引用计数 -1
}
}
嗯,这里要注意:COW 的粒度是页(通常是 4KB)。如果你只修改一个字节,内核也要复制整个 4KB 的页。所以频繁的小修改,用 COW 并不划算。
我个人的习惯是:
如果子进程需要大量修改数据,我会在 fork 之前就把数据放到共享内存(mmap 的 MAP_SHARED)里,这样 COW 就不会触发,所有子进程直接读写同一份物理内存。
好了,这一章的内容就到这里。下一章我们会深入讲 进程间通信与内存共享,到时候会用到今天讲的这些基础。
课后思考:如果父进程 fork 后立即 exit,子进程变成孤儿进程,那 COW 页会怎么处理?想明白这个问题,你对 COW 的理解就更深一层了。