Li Sheng | Backend / Distributed Storage Engineer Li Sheng | Backend / Distributed Storage Engineer
Home
Resume
Projects
Topics
Notes
GitHub (opens new window)
Home
Resume
Projects
Topics
Notes
GitHub (opens new window)
  • Go语言

  • C++

  • 算法题

  • 存储系统

    • 导航

    • 单机IO基础

      • 命令ls的执行过程
      • mmap 与 write
        • write 是一次显式的数据提交
        • mmap 先建立地址关系
        • 写入 mmap 区域时发生了什么
        • 同步语义的差异
        • 性能差异来自访问模式
        • 把两条路径放到一张图里
        • 进一步讨论
      • 命令write的执行过程
    • IO语义与持久化

    • 块层与高速路径

    • Ceph

    • DAOS

    • 归档

  • CephFS

  • 分布式系统

  • 计算机网络

  • Redis与缓存

  • Kubernetes

  • 技术笔记
  • 存储系统
  • 单机IO基础
lisheng
2024-09-10
目录

mmap 与 write

这篇文章适合放在系统调用层和 Page Cache 之间阅读。它不把 mmap() 和 write() 当成两个孤立 API 做参数对比,而是回答一个更底层的问题:应用访问文件数据时,数据到底是通过一次显式系统调用拷贝进内核,还是通过虚拟地址、页表和缺页中断间接进入内核管理的页缓存。

从这个角度看,write() 和 mmap() 的差异不只是“有没有少一次拷贝”。它们真正不同的是控制点在哪里。write() 的控制点在系统调用发生的那一刻,应用把一段用户缓冲区交给内核,内核立即沿着文件写路径处理这批字节。mmap() 的控制点被拆开了:mmap() 调用本身只建立虚拟地址区间和文件之间的关系,真正的数据读取或修改,要等 CPU 访问那段虚拟地址并触发页表检查、缺页处理或脏页标记时才发生。

# write 是一次显式的数据提交

使用 write(fd, buf, len) 时,应用已经把要写入的数据放在用户态缓冲区里。系统调用进入内核后,VFS 根据 fd 找到对应的 file 对象,再把操作分发给具体文件系统。对于普通 buffered write,内核通常会把用户缓冲区中的数据拷贝到 Page Cache 中对应的页里,并把这些页标记为脏页。系统调用返回时,数据通常已经进入内核页缓存,但不等于已经落到存储设备上。

这条路径的关键特征是边界清晰。应用主动调用 write(),内核在这个调用上下文中检查权限、定位文件偏移、准备页缓存页、执行用户态到内核态的数据拷贝,并更新文件大小、mtime、ctime 等相关元数据。应用也能用返回值判断这次提交了多少字节,遇到空间不足、信号中断或权限错误时,错误会沿着系统调用返回路径显式暴露出来。

这种清晰边界也带来了成本。每次 write() 都要进入内核,并且 buffered write 至少要把用户缓冲区复制到 Page Cache。对于顺序写、网络请求落盘、日志追加和小块写入,这种模型很好控制;应用明确知道自己什么时候提交数据,内核也可以把 Page Cache、回写、合并和块层调度组织起来。但如果应用要频繁随机访问一个大文件中的许多小片段,重复系统调用和显式拷贝就可能成为额外开销。

# mmap 先建立地址关系

mmap() 的第一步不是读取整个文件,也不是把文件内容一次性搬进内存。它做的是在进程用户地址空间中找一段虚拟地址区间,创建一段 VMA,并把这段 VMA 和目标文件、文件偏移、权限、共享方式等信息关联起来。调用返回后,应用拿到的是一个用户态虚拟地址,可以像访问数组一样读写这段地址。

这段地址属于用户地址空间,而不是内核地址空间。进程可以直接发起普通 load/store 指令访问它,但这些指令能否成功,要看页表中是否已经有对应映射、访问权限是否匹配、页是否已经在内存中。mmap() 只是把“如果将来访问这段地址,应该到哪个文件的哪个偏移找数据”这条关系登记下来,并不保证所有页面已经就绪。

因此,mmap() 的 I/O 往往是懒发生的。第一次读取某个尚未驻留内存的映射页时,CPU 发现页表项不存在或不可用,会触发缺页异常。内核的缺页处理逻辑根据虚拟地址找到对应 VMA,再根据 VMA 定位文件和文件偏移,通过文件系统把对应数据页读入 Page Cache,随后把这个物理页映射到进程页表中。缺页处理返回后,刚才那条用户态访存指令会重新执行,这次就能直接从内存中读到文件内容。

这里的“少一次拷贝”要准确理解。对于文件映射读,数据从磁盘进入 Page Cache 后,可以直接通过页表映射给进程访问,不需要再从内核缓冲区复制到用户缓冲区。但这不意味着没有内核参与,也不意味着没有 I/O 成本;缺页中断、文件系统读取、页表更新、TLB 影响和内存回收都可能成为真实开销。

# 写入 mmap 区域时发生了什么

当应用向 mmap() 返回的地址写入时,表面上看只是一次普通内存写,但内核仍然要维护文件映射语义。如果映射页尚未建立,第一次写会先触发缺页处理。对于可写的文件映射,内核需要准备可写页表项;写入发生后,相关页会成为脏页,后续由回写机制或同步调用把修改推进到底层文件系统和块设备。

如果映射使用 MAP_SHARED,写入的语义是修改共享的文件后备页。同一文件的其他共享映射进程可能观察到这些修改,最终这些脏页也应该被写回文件。应用可以通过 msync() 请求把映射范围内的修改同步出去,也可以依赖内核后台回写、内存压力或文件关闭附近的生命周期动作推进写回,但这些动作和“刚刚那条 store 指令执行完成”不是同一个时间点。

如果映射使用 MAP_PRIVATE,第一次写通常会触发写时复制。进程得到的是自己的私有匿名页副本,后续修改不会写回原文件。也就是说,MAP_PRIVATE 的读路径仍然可以从文件页开始,但写路径会从“文件后备页”分叉到“进程私有页”。这也是很多程序用私有映射加载文件、解析文件,但不希望修改污染原文件的原因。

因此,讨论 mmap() 写入时不能只说“像写内存一样写文件”。更准确的说法是:应用用内存访问指令修改虚拟地址;这次修改落在共享文件页、私有副本还是触发权限错误,取决于 VMA 权限、映射类型、页表状态和缺页处理结果。文件最终是否被修改,还要看这个映射是不是共享文件映射,以及脏页是否被写回。

# 同步语义的差异

write() 和 mmap() 都可能最终产生脏页,但应用能施加控制的位置不同。write() 的返回值告诉应用这次系统调用有多少字节被内核接受;如果需要更强持久化语义,应用通常继续调用 fsync() 或 fdatasync(),要求文件数据和必要元数据推进到更稳定的位置。这里的同步对象是文件描述符背后的文件。

mmap() 的修改则不经过一次次 write() 系统调用。应用的 store 指令本身没有返回“写入文件成功”这种语义。要同步映射范围内的修改,应用通常使用 msync();如果同时还关心文件元数据、目录项或更完整的持久化边界,仍然需要理解 fsync()、文件系统日志和块设备 flush 之间的关系。msync() 解决的是映射页范围的写回请求,不应该被简单等同于所有场景下的完整崩溃一致性保证。

这也是 mmap() 容易带来工程误判的地方。它让文件访问看起来像内存访问,降低了局部读写代码的复杂度,但把错误暴露、同步边界和持久化时机变得更隐蔽。磁盘 I/O 错误、文件截断、越界访问、权限变化和总线错误可能在访存时暴露,而不是在一个显式的 write() 返回值中暴露。

# 性能差异来自访问模式

把 mmap() 简单总结为“比 write() 快”是不稳的。mmap() 的优势通常出现在大文件随机读取、重复访问同一批页面、多个进程共享同一份只读文件页、或者应用希望用指针直接在文件内容上建立数据结构时。此时页表映射减少了用户态缓冲区和内核页缓存之间的重复复制,缺页机制也能按需把真正访问到的页面带进内存。

但 mmap() 也有自己的成本。第一次访问页面会触发缺页处理,随机访问会制造大量小粒度缺页,页表和 TLB 压力可能上升,内存回收时还要处理文件页和脏页。对于简单顺序流式读写,read()、write() 配合合适的缓冲区大小往往更直接,内核也更容易通过 readahead、writeback 和 I/O 合并优化吞吐。

选择时更可靠的判断方式是看数据访问形态。如果应用天然以字节流方式生产或消费数据,例如日志追加、网络数据落盘、压缩流处理,write() 的显式提交模型更容易控制。如果应用需要把文件当作可随机访问的大数组,例如数据库缓存管理、索引文件查询、只读字典加载,mmap() 更容易把文件访问接到虚拟内存机制上。两者都经过 Page Cache,并不是一个“走缓存”、另一个“不走缓存”的区别。

# 把两条路径放到一张图里

write() 路径可以概括为:用户缓冲区中的字节,经系统调用进入内核,被复制到 Page Cache,页被标记为脏页,再由回写和持久化机制继续向下推进。它的主线是“应用主动提交一段数据”。

mmap() 路径可以概括为:应用先请求一段虚拟地址和文件偏移建立映射,真正访问时由 CPU 页表检查触发缺页,内核把文件页带入 Page Cache 并更新页表;写入共享映射时,普通内存 store 会让文件页变脏,再由后续同步或回写机制推进。它的主线是“应用访问地址,内核按需兑现这个地址背后的文件页”。

这两条路径最终都会和 Page Cache、脏页、回写、文件系统元数据以及块层 I/O 发生关系。它们的差异不在于谁更“底层”,而在于应用和内核之间的契约形式不同:write() 以系统调用作为一次数据交接点,mmap() 以虚拟内存映射作为长期共享关系。理解这个差异,后续再看 msync()、fsync()、Direct I/O、数据库 buffer pool 和文件系统崩溃恢复时,才不会把“访问成功”“写入成功”和“持久化成功”混成同一件事。

# 进一步讨论

【待展开:mmap 缺页读取路径|mmap-page-fault-read-path】:把 VMA 查找、文件页定位、Page Cache 读取和页表更新串成完整的 mmap 读路径。

【待展开:mmap 写入、脏页与 msync 语义|mmap-dirty-page-msync】:解释共享映射写入后脏页如何形成、何时写回,以及 msync() 和 fsync() 的边界。

【待展开:MAP_SHARED 与 MAP_PRIVATE 的语义分叉|map-shared-vs-private】:把共享文件页、私有写时复制和是否回写原文件之间的关系讲清楚。

Edit (opens new window)
Last Updated: 2026/04/27, 14:16:46
命令ls的执行过程
命令write的执行过程

← 命令ls的执行过程 命令write的执行过程→

最近更新
01
待完成专题池
04-28
02
待完成专题池
04-28
03
为什么 Kubernetes CSI 插件架构要拆成 Controller、Node 与 Sidecar
04-28
更多文章>
Theme by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式