
聊一个平时不太起眼但关键时刻特别能打的系统调用mincore。前两周我在优化一个自研服务的冷启动流程要判断一个几十GB的数据文件里到底有多少内容还躺在 page cache 里有多少得老老实实从磁盘读。用 vmtouch 这类现成工具能看个大概但没法嵌进业务代码做决策。于是我绕回最底层直接用 mincore 写了个采集器问题很快落地。这个名字你大概率在 man 手册里见过但真正用过的人并不多。它做的事情一句话就能讲明白返回一段虚拟内存地址范围内的每一页当前是否驻留在物理内存里。就这一条信息能支撑缓存命中率评估、启动预热、内存热点统计、文件冷热分析等一大堆场景而且调用开销低、不需要 root 权限普通用户态进程也能直接用。这篇文章会把接口参数、内核行为、实战代码和踩坑记录一次讲透适合做系统编程、性能优化和底层运维的朋友参考。1. mincore 是什么以及为什么你需要认识它1.1 从一个典型的“冷启动血案”说起假设你有一个搜索服务启动时要加载一个 200GB 的倒排索引。第一次启动特别慢因为索引文件从头到尾都要从磁盘读一遍如果紧接着重启第二次会快很多因为几十GB的索引还热在 page cache 里。麻烦的是服务不可能每次都靠“刚启动过”来碰运气。自动化扩容、故障切换之后新机器上的索引文件往往一点缓存都没有服务一上来就把磁盘 I/O 打满接口延迟飙升。我当时面临的问题就是这个能不能写一个小的监控模块在上线之前先扫描一遍关键文件看看缓存命中率是多少如果太低就主动预读等服务真正开始接收流量数据已经在内存里了。这个需求如果用/proc/meminfo或者free来看只能得到全系统的缓存总量根本没有办法对应到具体的文件、具体的偏移。而mincore解决的就是这个粒度问题。1.2 核心价值把“内存是否命中”变成可编程判断mincore其实是“memory in core”的缩写这里的“core”指物理内存历史上叫 core memory。它返回的是虚拟地址空间中某一页是否真的映射了物理页框。对于文件映射来说这个“物理页框”通常就对应文件页缓存中的那一个页。有了它你就能把一个平时只能靠经验拍脑袋的问题变成代码里的一个布尔判断这个文件偏移对应的页在不在物理内存里如果不在这意味着访问它可能会触发磁盘 I/O。如果大量页都不在就需要考虑预读或者预热。这种判断的粒度默认是 4KB取决于系统页表大小比任何文件系统层面的统计都要精确得多而且真的可以放进业务代码里在运行时动态做决策。1.3 和其它内存查询手段的分工初学 Linux 内存管理的人容易把几个概念搞混。/proc/self/smaps能看到每个 VMA 的总占用和 RSS但都是区域级别的汇总/proc/meminfo看的是系统整体mlock负责把页锁在内存里madvise是给内核提出内存使用建议。而mincore的独特位置在于它是唯一一个能直接按页查询驻留状态的用户态接口。你可以把前面几个都理解为“管理”类的接口而mincore是“观测”类的接口。先有准确观测才能做正确的管理。2. 接口细节与内核行为拆解2.1 原型、参数和 vec 数组的布局mincore的原型长这样#include sys/mman.h int mincore(void *addr, size_t length, unsigned char *vec);三个参数分别是addr待检测虚拟内存区域的起始地址。Linux 手册要求它必须是页大小的整数倍写代码时最好先向下对齐到页边界别去赌老内核的宽容度。length待检测区域的字节长度不要求页对齐内核会按页向上取整但不改变内存映射本身。vec输出缓冲区是一个unsigned char数组。数组里每一个字节对应一个页面所以字节数至少是(length page_size - 1) / page_size这里的page_size用sysconf(_SC_PAGESIZE)获取。调用成功后vec[0]表示addr所在页的驻留状态vec[1]表示addr page_size所在页的状态以此类推。注意每个字节的最低 bitbit 0才是驻留标志位其它位当前都是 0。所以在判断的时候一定要写vec[i] 0x01不要直接拿vec[i] 1去比较也不要以为vec[i]是 0 或 1 的布尔值。2.2 返回值和 errno 速查表mincore成功时返回 0失败返回 -1 并设置 errno。常见的几种情况我在表里列一下errno含义常见诱因ENOMEM地址范围包含未映射的虚拟内存区域对堆里尚未 mmap 的地址或者已 munmap 的区域调用EINVALlength 为 0或者 addr 不是页大小的整数倍参数没校验就传进去了EFAULTvec 指针不可写传入只读地址或无效指针EAGAIN内核临时内存不足2.6.11 之后极端内存压力下偶发重试即可实际开发中遇到最多的是 ENOMEM尤其当你用一个很大的 length 去检测一个并不完全连续映射的区域时只要中间有一个空洞整个调用就会失败。所以正确做法是要么对整个 mmap 区域做检测要么自己按子区域拆开检测。2.3 内核是怎么得出“驻留”这个结论的搞清楚这一点你才能真正理解mincore的结果而不是背结论。传统实现会遍历当前进程的页表如果页表项存在说明该页已经被当前进程访问过并且建立了到物理页的映射那么返回驻留。如果页表项不存在对于文件映射内核不会直接返回 0而是去该文件底层的页缓存现在通常是 xarray里查找。只要文件页已经在 page cache 中即使当前进程从未访问过这一页mincore也会返回 1。后面这个行为特别关键。它意味着mincore对文件映射的语义不只是“当前进程有没有建立该页映射”而是“这个文件页当前是否存在于系统 page cache 中”。这就是 vmtouch 这类工具能够统计一个文件全局缓存命中率的内核基础。我可以负责任地说我在实际项目里验证过先用另一个进程read()一个文件让页进入 page cache再在一个只做了 mmap 的进程中调用mincore结果一样返回 1。对于匿名映射情况就简单很多直接看该进程页表里有没有对应物理页即可。2.4 文件映射、匿名映射和大页面的差异对于普通文件映射mincore的结果会受 page cache 回收、readahead、writeback 的影响。只要内核还在 page cache 里保留这一页结果就是 1一旦因为内存压力被回收掉结果变成 0。对于匿名映射MAP_ANONYMOUSmincore反映的是进程自身匿名页是否在物理内存里。如果页被换出到 swap驻留位会变成 0。Linux 4.0 之后对匿名共享内存的支持也更完整了但如果你在很老的内核上跑遇到奇葩结果不要太惊讶。再有一个容易踩的坑是透明大页THP。如果一个区域被内核折叠成 2MB 的透明大页mincore在遍历页表时发现这是大页映射会把这 2MB 范围内所有对应的小页全部标记为驻留。这会导致你的统计结果看起来“几乎全命中”实际上底层可能是按整个大页加载的。做热度统计时如果看到某个区域命中率从 0 突然跳到 100%先怀疑一下是不是 THP 在起作用不必急着写信给内核维护者。3. 动手写一个页面驻留检测器3.1 最小可用的 C 实现理论说再多不如跑一段代码。下面这个程序接受一个文件路径把文件 mmap 进进程地址空间然后调用mincore统计每个页的驻留情况最后输出总的命中率和前几页的具体状态。这是我在工具类项目里最常用的骨架。#define _GNU_SOURCE #include stdio.h #include stdlib.h #include fcntl.h #include unistd.h #include sys/mman.h #include sys/stat.h int main(int argc, char *argv[]) { struct stat st; unsigned char *vec; char *addr; size_t pages, i; size_t resident 0; long page_size; int fd; if (argc ! 2) { fprintf(stderr, usage: %s file\n, argv[0]); return EXIT_FAILURE; } page_size sysconf(_SC_PAGESIZE); fd open(argv[1], O_RDONLY); if (fd 0) { perror(open); return EXIT_FAILURE; } if (fstat(fd, st) ! 0) { perror(fstat); return EXIT_FAILURE; } addr mmap(NULL, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0); if (addr MAP_FAILED) { perror(mmap); return EXIT_FAILURE; } pages (st.st_size page_size - 1) / page_size; vec calloc(pages, 1); if (!vec) { perror(calloc); return EXIT_FAILURE; } if (mincore(addr, st.st_size, vec) ! 0) { perror(mincore); return EXIT_FAILURE; } for (i 0; i pages; i) { if (vec[i] 0x01) { resident; } } printf(file size : %lld bytes\n, (long long)st.st_size); printf(page size : %ld bytes\n, page_size); printf(total pages : %zu\n, pages); printf(resident pages : %zu\n, resident); printf(hit ratio : %.2f%%\n, (double)resident * 100.0 / pages); munmap(addr, st.st_size); close(fd); free(vec); return 0; }这里有个细节要注意mmap时我把整个文件都映射进来但对一个 200GB 的文件这不会真正占 200GB 的物理内存只是占一段虚拟地址空间所以可以放心映射。映射长度如果为 0mmap会失败因此使用前最好判断一下文件非空。3.2 编译和效果演示编译只需要一行gcc -O2 -Wall -o mincore_stat mincore_stat.c然后随便找一个文件测试。为了看效果可以先准备一个 512MB 的文件用dd写入一些随机字节再访问一遍把它带进 page cachedd if/dev/urandom of/tmp/testfile bs1M count512 cat /tmp/testfile /dev/null ./mincore_stat /tmp/testfile这时的命中率基本接近 100%因为刚刚cat已经让整个文件进入了 page cache。如果你想看“冷”文件的效果可以用 root 执行echo 3 /proc/sys/vm/drop_caches清掉 page cache再跑一次./mincore_stat /tmp/testfile命中率会降到接近 0。注意这个操作会影响整机缓存生产环境慎用。3.3 一个非常容易踩的性能陷阱我第一次写类似工具时犯过一个低级错误循环每一页调用一次mincore。比如这样for (i 0; i pages; i) { mincore(addr i * page_size, page_size, byte); if (byte 0x01) resident; }对一个 600MB 的文件这会造成 15 万次系统调用。实测中我跑了几十秒才出结果而改成一次性把整个范围传进去同样的文件不到 0.1 秒就完成。原因很简单mincore是一次系统调用开销主要在陷入内核和遍历页表循环调用等于重复支付了几十万次系统调用成本。普通文件一次性传入的 vec 数组长度也就是“文件大小 / 页大小”一个 1GB 文件的 vec 也就 256KB完全在可控范围内。记住这条经验能用一次mincore查完的范围绝不要拆成多次。3.4 结合 madvise 做“预热-验证”闭环mincore只负责看状态真正让页进入 page cache 的工作通常交给madvise()的MADV_WILLNEED标志。它用来告诉内核这段内存区域我马上要访问请尽量提前读入。这就相当于主动给内核发了一个预热指令。实际预热的套路一般是这样的#define _GNU_SOURCE #include sys/mman.h // 先扫描找到所有未驻留页 for (i 0; i pages; i) { if (!(vec[i] 0x01)) { char *p addr i * page_size; // 触发内核预读 madvise(p, page_size, MADV_WILLNEED); } }注意madvise是按页粒度传地址但连续多次调用仍然有系统调用开销。更高效的做法是把未命中的页聚合成段然后对每个连续段调用一次madvise(seg_addr, seg_len, MADV_WILLNEED)。完成之后再调用一次mincore复查命中率就能验证预热效果。这套“扫描 - 预读 - 回采验证”的循环就是我做的服务启动预热模块的核心逻辑。4. 实战场景从缓存命中率到启动预热4.1 场景一服务启动前评估数据文件冷热这个场景最直接。服务启动脚本里在真正拉起业务进程之前先对关键数据文件跑一遍mincore统计。如果命中率低于阈值比如不到 60%说明冷数据很多需要madvise(MADV_WILLNEED)预热或者干脆把启动顺序调整到磁盘低峰期。有人可能会问为什么不直接预读整个文件因为大文件全部预读会抢占带宽并且如果内存本身不够预读的页很快又被回收白忙一场。先用mincore摸清冷热只对冷的部分预热效率高副作用小。我实测过一个 80GB 的索引文件全量预读需要十几分钟而先扫描发现只有 5GB 的页不在缓存里只预读这一部分两分钟内完成效果完全一样。4.2 场景二监控一个文件在运行期的被回收情况另一个有价值的玩法是持续采样。服务跑起来之后用多轮mincore扫描同一个大文件记录命中率随时间的变化曲线。如果命中率在某次内存压力峰值后突然掉下来就能精确看到哪些数据段被内核回收了甚至能定位到具体文件的哪些偏移区间受影响。这里要控制采样频率。扫描一个 1GB 文件一次mincore本身很快但如果你的服务内存很紧张扫描的 mmap 操作和 vec 分配会带来额外开销。我一般把采样周期设在 5 到 60 秒之间具体看业务容忍度。数据落成时间序列之后和 CPU、磁盘 I/O 的指标放一起往往能找到一些很有意思的因果关联。4.3 场景三结合 mlock 做关键数据锁定决策mlock()可以把一段内存锁定在物理内存里禁止换出但锁多少、锁哪些区域也是有成本的。盲目 lock 一个超大文件可能挤占其它进程的内存反而引起系统抖动。这时可以先用mincore观察一段时间如果某个关键区段的命中率持续接近 100%说明它一直是热的不需要 lock。如果某个关键区段总是偶尔掉到 0并且这个掉零对延迟影响很大那么这些区段才值得用mlock锁定。这种“观察后决策”的思路比一拍脑袋把所有核心数据全部mlock要理性很多。我见过有人把十几GB的共享内存全部锁定结果系统其它服务频繁触发 OOM最后只能改回来。4.4 现成工具串讲vmtouch、fincore、pcstat如果你不想自己写代码Linux 生态里已经有好几个基于mincore的小工具vmtouch老牌工具能统计文件或目录的 page cache 占用比例支持-t参数让文件进入 page cache支持-e从 page cache 中移除。fincore核心逻辑更精简输出风格类似 coreutils适合脚本处理。不过维护状态一般新系统上可能需要自己编译。pcstatGo 写的工具输出每个文件块的缓存状态对诊断单个文件特别方便。这些工具说到底都是对mincore的封装。遇到需求比较特殊的情况别急着引一个监控框架自己用mincore写一个 100 多行的采集器往往更可控也更容易集成到现有运维体系里。5. 常见问题与排查技巧实录5.1 vec 数组大小里隐藏的坑很多人第一次写代码会把 vec 数组长度算成length / page_size而不是向上取整。文件大小不是页大小的整数倍时最后一个页就会被漏掉结果永远少一页统计值总是和你预想的差一点。正确的计算方式size_t pages (length page_size - 1) / page_size;另外如果地址没有按页对齐mincore可能返回 EINVAL。所以我通常写一个工具函数先把地址向下取整到页边界同时把length扩展覆盖到最后一个页的尾部再调用mincore。从需要检测的用户态缓冲区地址出发经常会遇到非页对齐的情况这一步省不了。5.2 结果“全 0”或“全 1”先别慌扫出来全部为 0最常见的两个原因一是文件刚从干净状态加载页面都还没进 page cache二是你检测的地址范围没有真正映射过比如 mmap 之后没有访问任何页而文件页缓存里也没有对应数据。扫出来全部为 1则要怀疑透明大页是否已经启用。可以检查一下/sys/kernel/mm/transparent_hugepage/enabled如果处于 always 或 madvise 模式并且你的文件映射区域被内核折叠成了 THP那么mincore会对整个大页范围标记驻留。这种“乐观命中”不是 bug但会误导容量规划统计时最好加上 THP 出现概率的参考信息。5.3 结果不稳定是常态学会“多次采样”mincore返回的永远是调用瞬间的快照。内核后台有 kswapd、writeback 线程在不停工作page cache 可能在下一条指令执行前就被回收。所以如果你的业务逻辑依赖这个结果做精确决策最好在一个时间窗口内做多次采样取稳定趋势。我自己在预热模块里的做法是第一轮扫描后立即预热等待 2 到 3 秒再做第二轮回采如果回采命中率低于 95%再补一轮预热。多轮下来基本能把冷页全部拉进内存也不会因为一次瞬时抖动就误判。5.4 透明加密、FUSE 和特殊文件系统下的表现如果你的文件系统是 FUSE 实现比如某些网盘挂载、透明加密中间层mincore的行为可能会让你困惑。它判断的是内核页缓存层的驻留情况而 FUSE 用户态文件系统不一定使用标准 page cache 语义。我遇到过在透明加密目录上跑mincore结果基本全 0但实际读取速度并不慢的情况原因是加密层有自己的缓存路径绕过了普通文件页缓存。所以在特殊文件系统上做缓存分析时先做一个对照实验cat一下文件再立刻mincore如果命中率还是没有上升就要结合具体文件系统实现来解读数据别把mincore的返回值当成绝对真理。5.5 用 strace 快速验证调用参数调试自己的程序时strace是很好的帮手。一行命令就能看到mincore的参数和返回值strace -e tracemincore ./mincore_stat /tmp/testfile输出会类似mincore(0x7f2a68a00000, 536870912, [......]) 0如果返回ENOMEM就能立刻确认是地址范围问题如果返回EINVAL说明addr或length不符合要求。这种直观反馈比在代码里加日志快得多。5.6 快速自检清单把常见坑整理成一份清单写完代码后逐条核对addr是否已经按页对齐length是否无符号溢出。vec大小是否用(length page_size - 1) / page_size计算。是否用vec[i] 0x01读取驻留位而不是判断vec[i] 1。是否一次传入完整范围而不是循环每页调用。结果是否可能受 THP、FUSE 或透明加密影响。是否做了多次采样避免瞬时误导。是否考虑了大文件下 vec 分配的虚拟内存开销。这些看起来都是小细节但在真实生产环境里每一条都可能导致分析结论完全跑偏。最后再分享一点个人体会mincore这个系统调用本身很小但每次用都能提醒我一个道理用户态看到的内存状态永远是某个时间点的快照而内核在后台无时无刻不在做 readahead、writeback、回收这些动作。真正要把mincore用好不能只看一次结果要把它和madvise、mlock以及业务场景组合成一个闭环。我现在的做法是服务启动前先对关键文件做一轮mincore冷热扫描对冷区域按聚合段调用madvise(MADV_WILLNEED)预热等几秒后回采验证。如果命中率达标再放流量。这一套逻辑代码量不大带来的冷启动延迟优化却非常可观。如果你也在折腾内存热点分析、缓存预热或者性能调优建议从写一个几十行的mincore小工具开始别一上来就引入一堆重型的监控框架。底层接口用顺手了你会发现很多看似复杂的问题其实解决起来并不复杂。