ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Linux下fork()父子进程地址相同值却不同?一文读懂虚拟内存与写时复制

Linux下fork()父子进程地址相同值却不同?一文读懂虚拟内存与写时复制 1. 一次经典的现象地址相同值却不同如果你写过一点Linux下的多进程程序大概率遇到过这样一个“诡异”的场景父进程里声明了一个变量fork()之后子进程里打印这个变量的地址和父进程打印出来的完全一样但你去修改子进程里的值父进程里的值却纹丝不动。地址明明相同值却各管各的这就像两个人拿着同一把钥匙打开房门之后看到的却是两间完全不同的屋子。这个现象几乎每次讲Linux地址空间时都会被拿出来当引子因为它背后藏着的正是操作系统的核心机密——虚拟地址空间。我第一次碰到这个问题时第一反应是怀疑编译器出了bug甚至怀疑是栈被破坏了。后来翻了《Unix环境高级编程》和《深入理解计算机系统》才搞清楚这根本不是什么玄学而是每个进程从诞生起就自带一套“假地址”这套地址由操作系统配合CPU的MMU内存管理单元精心导演目的就是让每个进程都觉得自己独占整台机器。这篇文章不打算把教科书抄一遍而是从我们亲手敲过的代码出发把这个“同一地址不同值”的现象一步一步拆开。你会看到虚拟地址到底是个什么东西页表和MMU在中间扮演了什么角色fork()之后父子进程的地址空间究竟怎么分家以及为什么修改子进程的值不会波及父进程。文章最后还会附上一套可以直接在终端里跑起来的实验代码和几个排查地址问题时常用的手段。不管你是在准备Linux面试还是刚接触系统编程又或者只是好奇操作系统在背后干了多少活读完这篇应该都能收获一套完整、可复现的理解链路。2. 把“地址”掰开揉碎虚拟地址空间是怎么来的2.1 先说结论进程手里拿的地址从来就不是内存的真实地址我们平时在C语言里打印指针比如printf(%p, a)那个输出的十六进制数字看上去像是内存的位置其实它只是一个“虚拟地址”。真实的内存颗粒上每一个字节都有一个物理地址但普通应用层的程序永远接触不到它。不是不想给而是给了会出大乱子。我打个比方你就明白了。整台机器的物理内存就像一栋大楼里的房间操作系统是大楼管理员。每个进程都是一个租客管理员不会把真实的房间号告诉租客而是发给每个租客一张“假地图”上面标着整齐划一的门牌号0x00000000到0x7fffffffffffffff64位系统下典型的用户空间范围。租客在这张假地图上找房间、放东西、取东西管理员则在背后偷偷记着假地图上的每一页对应大楼里的哪几间真实房间。这张“假地图”就是地址空间地图上的房间号就是虚拟地址。Linux里每个进程都有一套独立的假地图互不相干。所以父子进程打印出来的地址一样一点都不奇怪——它们各自拿着各自的地图但巧合的是地图上的门牌编号规则是一样的你打印的是0x7ffc12345678父进程打印的也是0x7ffc12345678可这两个编号背后对应的真实房间完全不是同一间。这套设计带来的好处立竿见影。第一每个进程都认为自己拥有从0到最大值的一整片连续内存写代码的时候根本不用关心物理内存碎片、别家进程占了什么地方编译器和链接器也只需要跟虚拟地址打交道简单很多。第二进程之间天然隔离你在自己的地址空间里乱写顶多把自己搞段错误碰不到别人的数据。第三物理内存可以被超额使用明明物理内存只有8G几十个进程每个都“声称”自己有4G空间只要它们实际动用的内存加起来不超过8G就行。2.2 地址空间不是一块铁板而是分区规划的“小区”如果你跟着我一起在Linux下敲过pmap -x或者读过/proc/PID/maps一定会看到地址空间被拆成了一堆区间每个区间有不同的名字和权限标记。这不是操作系统闲得没事干而是为了安全和高效。典型的一个32位Linux用户态进程地址空间从低到高大概是这样排的最下面是只读的代码段.text然后是已初始化数据段.data、未初始化数据段.bss、堆区heap再往上是一大块留给mmap动态映射的区域接着是栈区stack栈顶附近还挂着环境变量和命令行参数。每段都有明确的读写权限代码段通常不可写栈和堆可读写但不可执行中间还夹着很多随机偏移量——这叫地址空间布局随机化ASLR是为了防止攻击者精确预测目标地址。64位系统理论上用户空间地址范围巨大但实际可用的映射区同样被严格管理。地址空间看起来像一整块其实是分段加随机偏移的组合体。这里要特别提醒一句Linux内核的代码在编译时有一个默认的栈大小上限通常是8MB如果你在栈上声明大数组比如char buf[1024 * 1024 * 100]程序一跑就段错误。这不是bug是你踩到了栈区的边界。理解了地址空间是分段的再回头看父子进程“同一个地址”就更容易了那只是两个物理世界完全不同、但虚拟布局恰好同步的平行宇宙。地图一样房子不一样。2.3 从虚拟地址到物理地址MMU和页表才是幕后功臣现在关键来了虚拟地址怎么翻译成物理地址答案就是页表Page Table和MMU。操作系统的内存管理以“页”为单位典型大小是4KB。每个进程的地址空间在逻辑上被切成很多个4KB的页物理内存也被切成同样大小的物理页框。页表就是一张记录表每一条记录告诉你虚拟页号X对应物理页框Y权限是读还是写还是执行。CPU拿到一个虚拟地址之后先交给MMU查页表查到再去做真正的内存访问。这一步查表是有点开销的所以硬件里还有一个TLB翻译后备缓冲器也叫快表相当于把最近查过的映射关系缓存下来。这也是为什么你连续读写同一个内存区域比随机跳着访问要快得多——局部性好TLB命中率高。页表还有个特性很有意思用的不是一张超大的线性表而是一棵多层嵌套的树。进程的地址空间那么大如果给所有地址都建一条映射记录光页表就会吃掉大量内存。多级页表的意思就是只有实际用到的那一小段地址才建上层的目录项和底层的页表项其余的都是空指针。这种“懒加载”的思想贯穿了整个Linux内存管理后面讲fork()的写时复制时你还会再见到类似的策略。所以“地址相同但是值不同”这件事根本原因就是父子和子进程的虚拟地址相同但它们各自的页表里那条虚拟地址对应的物理页框不一样。页表不同物理内存就不同值自然各归各。3. 从fork到COW为什么两个“同一个地址”互不影响3.1 fork()在内存层面到底干了什么我们的父进程调用fork()之后内核要创建一个几乎一模一样的子进程。这里的“几乎一模一样”指的是寄存器上下文、文件描述符、信号处理方式、环境变量都复制了一遍地址空间呢很多入门教程会轻描淡写地说“子进程复制了父进程的地址空间”但你要是真以为是逐个字节把整块内存拷贝了一遍那就大错特错了。假设父进程已经用了1GB的内存fork()如果立刻实打实复制1GB那么每次创建子进程都会带来巨大的延迟和物理内存消耗启动几十个进程系统早就扛不住了。所以Linux采用了一种非常精妙的手段子进程的页表项一开始全部指向和父进程相同的物理页框。也就是说父进程和子进程在刚fork出来的那一刻其实是共享同一批物理内存的。你可能会问那如果子进程马上修改一个变量父进程岂不是也会被改这正是写时复制Copy-On-WriteCOW要解决的问题。所有共享的物理页框被内核标记成“只读”谁想写谁就要先触发一个缺页异常内核在异常处理里给触发者单独复制一个物理页框然后更新页表再重新执行那条写指令。这样一来只有被写的那个页才会被复制没被写过的页一直共享着既省了内存又省了时间。3.2 写时复制的触发过程值得你亲手去感受一次COW听起来很完美但它的细节里藏着很多坑。我建议你自己写一个小程序验证一下在父进程里定义一个数组比如256KBfork之前先把数组初始化一遍fork之后再让子进程往数组里写数据子进程写完父进程再去读确认数据没变。你可以在子进程写入之前和写入之后分别打印/proc/self/status里的VmRSS会看到子进程的RSS涨了一块而父进程的RSS几乎没动。为什么会涨因为子进程写入时触发了page fault内核在缺页异常处理里走了一条慢路径找到触发写操作的地址所对应的物理页分配一个新的物理页框把旧页里的内容复制到新页更新子进程的页表项把权限从只读改成可读写最后返回用户态重新执行那条写指令。而那个“旧页”继续留在父进程手里还是可读可写的。两个进程从此分道扬镳。这里有个细节值得注意COW不是fork()的时候发生的而是第一次写的时候才发生。也就是说子进程写入之前父子进程共享的页面非常多一旦写入那个4KB的页就私有化了。如果子进程写的是多页那每一页都会单独触发一次缺页自己拿自己的新页。整个过程的本质是把“复制”这件事从fork时点推迟到了“必须分离”的时点属于典型的惰性求值思想。3.3 那为什么打印出来的“地址”还是同一个值很多人看完COW反而更迷糊了既然页表已经分开了虚拟地址为什么还是同一个数字因为子进程拷贝的是父进程的虚拟地址布局最直接的表现就是stack pointer、堆指针、代码段基址都原样继承。你在父子进程里打印同一个局部变量a这个变量在各自进程的虚拟地址空间里恰好落在同一偏移位置所以打印值一模一样。但这只是“地图上的编号”相同页表里这个地址对应的物理页框已经因为COW机制而各归各了。打个更生活化的比方父子俩各拿一本同样出版、同样页码的字典查“第203页”这个位置。父进程的203页是一张印着“苹果”的纸子进程的203页一开始也是“苹果”但子进程用笔把它改成了“香蕉”于是父进程再去看自己的203页仍然是“苹果”。书名、页码一样纸却是两张。所以记住这句话**虚拟地址相同只是说明它们来自同一个虚拟布局模板值不同是因为页表映射的物理页面已经分家。**操作系统正是靠这种“看起来相同、实际上隔离”的机制才让每个进程都感觉独占了一台机器。3.4 vfork、共享内存和线程的特殊情况既然聊到了地址空间和父子进程就不得不把容易混淆的几个边缘情况一次说清。第一vfork()。老代码里偶尔会见到它的语义是“子进程先跑父进程阻塞子进程直接借用父进程的地址空间”。换句话说vfork()创建出的子进程和父进程不仅虚拟地址相同物理页也完全共享没有COW保护。子进程里一旦乱改父进程的变量父进程立刻跟着变所以vfork()之后子进程必须尽快调用exec或者_exit否则就是自己给自己挖坑。现在一般建议直接用fork()没人需要这点微小的性能差异。第二共享内存。通过mmap或者shmget创建的共享内存区域属于多个进程地址空间里的“公共区域”。父子进程如果都在自己的地址空间里映射了同一块共享内存那么这个地方的虚拟地址多半不一样但物理页框一样。你写入共享内存的数据另一方立刻就能看到。这也是分布式系统和数据库进程里常用的通信手段。第三线程。同一个进程里的多个线程共享同一个地址空间代码段、数据段、堆全都是共享的。每个线程只有自己的栈和寄存器上下文是私有的。所以多线程程序里一个线程的野指针把数据写坏了整个进程都会遭殃。相比之下多进程就扎实很多一个进程挂了通常不会直接影响另一个进程靠的就是地址空间隔离。搞清楚这几者的区别你对“地址空间”的理解会立体很多它既是隔离的墙也是可以按需打开的门关键是看你怎么开。4. 动手验证完整程序与观测工具实录4.1 一个可以“眼见为实”的实验程序理论说一千遍不如一行代码来得直观。下面这个程序是我在公司内部分享时常用的最小示例你在任何一台装有Linux的机器上都能跑不需要额外依赖#include stdio.h #include unistd.h #include stdlib.h #include sys/wait.h int global_var 42; int main(void) { int local_var 7; pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { /* 子进程打印地址和值然后修改 */ printf(子进程 fork之后 local_var %p, local_var %d\n, (void *)local_var, local_var); local_var 100; global_var 200; printf(子进程 修改之后 local_var %p, local_var %d, global_var %p, global_var %d\n, (void *)local_var, local_var, (void *)global_var, global_var); _exit(0); } else { /* 父进程先睡一会等子进程改完再打印 */ sleep(1); printf(父进程 子进程改完 local_var %p, local_var %d, global_var %p, global_var %d\n, (void *)local_var, local_var, (void *)global_var, global_var); wait(NULL); } return 0; }编译命令就一句话gcc -o fork_demo fork_demo.c跑起来之后你大概率会看到类似这样的输出子进程 fork之后 local_var 0x7ffd49a2e34c, local_var 7 子进程 修改之后 local_var 0x7ffd49a2e34c, local_var 100, global_var 0x60104c, global_var 200 父进程 子进程改完 local_var 0x7ffd49a2e34c, local_var 7, global_var 0x60104c, global_var 42看到了吗子进程和父进程打印出来的local_var一模一样但值一个是100一个是7global_var也都是0x60104c值却一个是200一个是42。这就是文章开头说的那个现象现在原因你应该已经能脱口而出了——父子进程的页表已经因为写时复制而分家虚拟地址相同并不代表物理地址相同。我个人建议你把local_var的初始值改一下比如改成0xdeadbeef或者把变量类型改成一个结构体数组看看COW对多页的影响。改动之后再次运行你会更直观地感受到只要是发生写操作的那一页就会被私有化没写过的页面一直共享到进程结束。4.2 用pmap和maps观察地址空间的全貌代码跑通了只算完成了一半。另一半是学会观察地址空间本身。Linux在/proc文件系统里提供了大量接口最常用的是这两个cat /proc/pid/maps pmap -x pid以fork_demo为例你可以在子进程里加上system(sleep 10)让进程多活几秒然后在另一个终端里用ps找到它的PID再查看maps文件。你会看到类似这样的片段00400000-00401000 r-xp 00000000 08:01 123456 /tmp/fork_demo 00601000-00602000 rw-p 00001000 08:01 123456 /tmp/fork_demo ... 7ffd49a0d000-7ffd49a2f000 rw-p 00000000 00:00 0 [stack] 7ffd49b3d000-7ffd49b40000 r--p 00000000 00:00 0 [vvar]每一行都是一种映射字段从左到右分别是虚拟地址范围、权限r读、w写、x执行、p私有或s共享、文件偏移、设备号、inode号、映射对象名称。你代码里那个local_var就在[stack]段global_var在00601000-00602000这个rw-p的数据段里。pmap -x会把maps整理成更好读的表格还会统计每段占用多少RSS。我平时排查内存泄漏时最喜欢用的就是pmap -x配合grep一眼就能看出哪段出现了异常增长。有一个经验要分享给你如果fork()之后子进程什么都没干直接exec掉那么父进程里那套地址空间几乎不需要额外拷贝。中间过程是先COW共享然后exec加载新程序把页表全部重建。所以“fork后立即exec”是非常高效的做法这个模式在守护进程、shell执行外部命令时用得特别多。你要是看到网上有人用system(“sleep”)去强制保留进程根本目的是为了给你留出观察窗口。4.3 通过系统中的统计数字感受COW除了maps/proc/pid/status里还有一个字段叫VmRSS代表该进程当前实际占用的物理内存常驻集大小。我做过一个实验让子进程写入一个256KB的数组然后对比父进程和子进程的VmRSS子进程大概涨了256KB父进程纹丝不动。如果我把子进程写入改成读操作两个进程的VmRSS几乎都不怎么涨。这个实验配合COW机制看特别有意义读操作不会触发缺页复制写操作才会。以后你看到“fork出很多子进程但内存没怎么涨”时不要惊讶那是COW在起作用等到大量子进程都开始各自写内存时才会看到物理内存被迅速吃掉。另外在嵌入式或者说资源受限环境里fork()的COW策略是节省内存的利器但一旦子进程批量改写堆里的数据原本“共享”的福利就会迅速消失这点在优化多进程服务时最好心里有数。“先共享读到够再按需写复制”这个思路其实和数据库里MVCC的核心哲学有异曲同工之妙。5. 常见问题与排查技巧实录5.1 面试和笔试常考的五个追问“父子进程的地址空间是共享还是隔离”这个考题几乎每个Linux岗的面试官都会问。常见追问大概有这么几层第一为什么两个进程打印出来的地址相同——因为子进程继承了父进程的虚拟地址布局但没有继承物理页框的独占权。虚拟地址是一个逻辑概念等价性并不代表物理内存相同。第二fork()之后父进程修改变量子进程能看到吗——看不到因为COW保证了第一次写操作之后写者的页表就私有化了。读操作不会触发COW所以只有一方改写时才会看到“各改各的”。第三COW是什么时候触发的——不是fork调用时而是任意一方的写指令真正执行到那一个页时由MMU配合内核缺页异常处理触发。第四fork()之后为什么不能直接用printf——技术上可以但printf涉及缓冲区、锁和文件描述符多进程同时输出容易交错、重复刷新而且printf和fork在异步信号安全上存在历史包袱。所以我习惯在fork之后用write或_exit而不是exit。这一点也是不少面试官喜欢追加的细节。第五fork()之后子进程能不能直接用父进程的栈——能访问但一旦写入就触发COW所以更准确的说法是“共享但受保护”。如果你真的希望父子之间共享数据需要显式使用共享内存或文件映射而不是靠栈上的局部变量。5.2 实际调试中容易踩的三个坑第一个坑是“信号和stdio缓冲区叠加”。没错很多新手会踩这个在fork之前已经用printf输出过一些内容但没刷缓冲fork之后子进程又执行一遍输出结果终端里同一行数据出现了两次。这不是地址空间的问题而是stdio的缓冲区被整个复制到了子进程里——注意缓冲区是进程内存的一部分COW对缓冲区同样适用但复制不代表清空内容还在。解决办法很简单fork之前fflush(NULL)或者干脆用write。第二个坑是“本来想改子进程的变量结果把父进程的改了”。这种情况常见于滥用全局变量加多进程配合的场景。你要明白全局变量在每个进程里都有自己的一份物理页副本不能指望“改一个变量别处也变”。如果真需要跨进程共享就用共享内存或者用mmap加上MAP_SHARED。第三个坑是“fork炸弹和资源耗尽”。我曾经手滑写过一个无限fork的演示脚本结果整个机器卡死只能强制重启。这在生产环境里是灾难在实验环境里也别乱来。排查时如果怀疑某个进程创建了大量子进程可以用pstree -p查看进程树或者用ps -ef --forest看父子关系。记住fork返回PID子进程的PID和父进程不同这是辨别身份的唯一可靠方式。5.3 排查地址相关问题的检查清单如果你在别的进程里看到类似的“地址相同但数据不一致”的问题我建议你按这个顺序排一遍先确认是不是真的跨进程操作。用ps确认两个进程的PID是不是不同如果PID相同那它俩根本就是同一个进程只是你可能在看两个线程——线程共享地址空间改数据当然互相可见。然后确认数据访问方式。走的是栈、堆、全局变量还是共享内存栈和堆默认私有共享内存默认公开。如果你发现数据“看起来共享”但“实际上不共享”多半是访问方式不对。再查一下编译器的优化。-O2以上优化等级有时会改变变量位置甚至把一些局部变量优化到寄存器里导致打印出来的“地址”不代表真实存储位置。调试阶段建议用-O0 -g保留符号表才能配合gdb看到准确信息。用gdb挂上去看看。gdb里可以用info proc mappings查看当前进程的地址空间映射也可以用p local_var和p local_var确认变量地址。如果父子进程都要看可以分别attach或者在代码里临时加一个sleep延长生命周期。最后用strace跟踪系统调用。如果你觉得fork之后的映射行为诡异strace -f可以显示所有进程的系统调用轨迹看到mmap、mprotect调用就能知道内核在什么时候改变了映射权限。注意写时复制是性能优化不是数据同步协议。永远记住这句话——父进程和子进程之间没有“共享变量”这种说法只有“临时共享的内存页”想通信老老实实用管道、消息队列、信号或者共享内存别指望内存地址相同就能互通有无。我自己在实际操作中还有一个习惯写多进程实验时一定在代码顶部加一个#define DEBUG开关方便用条件编译把调试打印打开或关掉。这样观察COW时可以在关键位置输出变量地址和值排查问题时又不会因为打印太多把缓存、时序搞乱。你可别小看这个习惯项目跑起来之后它往往能救你一次。如果你还想继续往深了挖下一步可以去看mm_struct结构体在内核源码里的定义也可以尝试自己写一个内核模块在模块里遍历某个进程的页表把虚拟地址和物理页框的对应关系打印出来。那会是另一个很有意思的旅程。
返回列表