mmap 了 1 页内存,读它为什么一分钱不花?
写 C 的人大概都用过 mmap:向内核申请一段内存,拿到地址就能读写。直觉上,申请了就是占了,读一下总该分到物理内存吧?
下面这段程序,只申请了 1 页,先读一次,再写一次:
#include<sys/mman.h>#include<stdio.h>#definePAGE_SIZE4096intmain(){size_tmap_size=PAGE_SIZE;void*addr=mmap(NULL,map_size,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);volatilecharc=*(char*)addr;/* 读:触发缺页 */*(char*)addr='A';/* 写:再次触发缺页 */printf("%c\n",c);return0;}先把直觉摆到台面上:
申请了就是占了,读了就该分 1 页,RSS 涨 4KB。
实测结果却不是这样:读,不花;写,才花。更怪的是,“读完再写”和“上来就写”,走的根本不是同一条内核路径。
申请了 1 页,读一下真的免费吗?
要回答这个问题,不能只看代码猜,得让内核自己说。我用 eBPF 给内核挂了几个探针,专门盯着缺页处理里几个关键函数,看它们到底有没有被触发。
实验很简单,三个小程序,其他条件全一样,每组只 mmap 1 页,只改碰内存的方式:只读、直接写、读后写。实验在 QEMU + Linux 6.17 里做,Ubuntu 25.10,x86_64 + KVM,内核开了 BTF,方便eBPF调试。
结果整理成一张对照表:
| 碰法 | 本以为会怎样 | 内核实际做了什么 | 结果 |
|---|---|---|---|
| 只读 | 分配函数会来,读了就分 1 页 | 只有 do_anonymous_page(flags=0x1a54,不带写标志),分配事件、do_wp_page 都没出现 | 读没分新页 |
| 直接写 | 分配函数会来,写了就分 1 页 | do_anonymous_page(flags=0x1a55,带写标志)+ 分配事件 vma_alloc_folio_noprof(order=0) | 首触写真的分配一页 |
| 读后写 | 第二次写还会进 do_anonymous_page | 第二次写不再进 do_anonymous_page,而是进了 do_wp_page(flags=0x1a55),后跟分配事件 vma_alloc_folio_noprof(order=0),据此排除 wp_page_reuse、判定为 wp_page_copy | 读过的页一写还要拷一遍 |
读和写的 flags 只差最后一位:0x1a54 是读,0x1a55 是写,正好就是写标志这一位。
这里要交代一个细节:wp_page_copy 和 wp_page_reuse 在内核里都是 inline 函数,探针挂不到它们本体。所以读后写那一行,不是“探针抓到了 wp_page_copy”,而是“探针抓到了 do_wp_page + 分配事件”。copy 一定要分配新页,reuse 不用分配,有分配事件就能排除 reuse,这才是判定为 copy 的依据。分配事件在 trace 里叫 vma_alloc_folio_noprof,它是 wp_page_copy 经 folio_prealloc、vma_alloc_zeroed_movable_folio、vma_alloc_folio 走下来的下游。
读后写这条路的调用关系,画出来是这样:
handle_pte_fault └── do_wp_page ├── wp_page_reuse # 不分配,只标可写 └── wp_page_copy # 必分配 └── folio_prealloc -> vma_alloc_zeroed_movable_folio -> vma_alloc_folio -> vma_alloc_folio_noprof到这里,第二个怪现象已经藏不住了:**写就是写,怎么还分两条路?**答案在缺页处理里。
三种碰法,三条路
先说一个前提:mmap 返回后,内核只是在进程的地址空间链表里记下了这段虚拟地址,没有分配任何物理页,也没有建立任何页表项。虚拟内存的“申请”和物理内存的“占用”是两回事,缺页中断才是真正花钱的时刻。
CPU 访问还没建页表的地址,就会触发缺页,经 do_user_addr_fault 进入 do_anonymous_page,之后按碰法分流:
读:先借一张零页。这不是写缺页(没有写标志),进程也没有禁用零页(!mm_forbids_zeropage),于是走零页分支:
entry=pte_mkspecial(pfn_pte(my_zero_pfn(addr),vma->vm_page_prot));内核把虚拟地址映射到全局唯一的一张 Zero Page,并把页表项标为 special。读到的全是 0,需求满足了,物理内存一分没花。
直接写:分一张新页。写缺页同样进 do_anonymous_page,但走分配分支:调用vma_alloc_zeroed_movable_folio申请新页(实测 trace 里表现为下游的vma_alloc_folio_noprof),再用set_ptes建立可写映射。这时才完成虚拟内存到物理内存的实质性绑定。
读后写:把零页复印一张。这时页表项已经存在了,只不过指向零页且不可写。所以写缺页不再进 do_anonymous_page,而是进 do_wp_page,也就是写保护缺页的通用处理。do_wp_page 发现这是一个 special 页表项(vm_normal_page取不到正常页),于是走wp_page_copy(inline,实测中以 do_wp_page 后的分配事件为据,见上表):分配一个真正的匿名页,建立可写映射。零页本身永远不会被写,写操作永远发生在新页上。
一句话:读走零页分支,首触写走分配分支,读后写走 do_wp_page 的拷贝分支。三条路,三种代价。
两本账:虚拟的“有”,物理的“占”
延迟分配的核心就一句话:虚拟地址的“有”和物理内存的“占”是两本账,缺页中断是结账的时刻,而结账的方式取决于你怎么碰它——读,记一笔零页的账;写,才真正掏钱。
这篇实验还让我重新确认了一件事:证据必须和机制有因果:探针抓到 do_anonymous_page 还是 do_wp_page,本身就是走了哪条路的判定;函数本体被 inline 时,区分依据就是它下游必然产生或不产生的事件,比如 do_wp_page 后的分配事件,PFN 和 RSS 只是可选的旁证。
写应用时,记住这几点
说到底,读是借,写才是拥有;内核把账单往后推,写应用的人就得在账单到期那一刻想清楚:
- 别用虚拟大小衡量内存占用。mmap 成功不等于内存到手,真正花钱的是你碰过的页。大块 mmap 加稀疏访问,只花碰过的页;反过来,只看虚拟大小做容量评估,会高估很多。
- 第一次写的代价,别留在关键路径上。首触写要缺页、分配、建页表,时延敏感的代码最好在启动或空闲时,把热页先写一遍热身;反过来,不用的大内存也别提前全量 memset,那等于亲手把延迟分配的好处毁掉。
- 只读的页,别顺手写。读到的零页是大家共用的,一写就变成自己的私有页,还要走拷贝分支。哪怕写回相同的值,也会触发分配;确定要全页重写时,直接写比先读后写少走一层分支。
- 需要保证时,别只靠 mmap 返回。写时分配才可能失败,失败可能落在业务中途。要求稳的场景,在启动时就把关键内存触碰一遍、尽早处理失败,或者用 mlock 锁定,把风险尽量留在启动时。
你第一次被 mmap“骗”是什么时候?欢迎在评论区说说。