[操作系统] 从mmap→sendfile的性能优化

RoLingG 其他 2026-08-31

从mmap→sendfile的性能优化

https://mit-public-courses-cn-translatio.gitbook.io/mit6-s081/lec08-page-faults-frans/8.6-memory-mapped-files

这里也有短暂的解释是什么。老文章,发出来复习一下的。

mmap 是 操作系统提供的“把文件或匿名页映射进进程虚拟地址空间”的系统调用(Linux/Unix 家族,Windows 对应 CreateFileMapping + MapViewOfFile)。
一句话:

“mmap 做完,磁盘文件(或一堆匿名页)就变成了一个可以像内存数组一样读写的 []byte,即硬盘文件映射到内存中。

这个技术在 Kafka 中有涉及到,倒不如说凡是用了 DMA 技术的中间件,基本都涉及到了 mmap

这里拿 Kafka 的 “零拷贝” 技术举例 mmap 在其中的作用:

mmap

提前须知内容:

用户缓冲区指 mmap 返回的那段进程用户虚拟地址空间,也就是通过 mmap() 映射到进程里的地址区间。

用户缓冲区的主要数据结构是 VMA(Virtual Memory Area,虚拟内存区域),具体到 Linux 内核就是 vm_area_struct

在内核中,每个进程都有一个 mm_struct 结构(即进程描述符 task_struct 中的 mm 字段),它描述了该进程的整个用户地址空间。mm_struct 中维护了所有 VMA 的链表和红黑树。每个 vm_area_struct 就代表一段连续的虚拟地址区间。

mmap 的优势就在于,将原先:

”硬盘 → 内核态 page cache,内核态 page cache → 用户态用户缓冲区,用户态用户缓冲区 → 内核态 Socket 缓冲区,内核态 Socket 缓冲区 → 网卡“

这一个 read + write 数据拷贝流程,通过 mmap,缩略成 mmap + write

“硬盘 → 内核态 page cache,内核态 page cache 映射到 用户缓冲区(这一步是提前建立好的,不算入步数内),用户缓冲区 → 内核态 Socket 缓冲区,内核态 Socket 缓冲区 → 网卡” 三步完成数据拷贝。

具体 page cache 不用将其数据在获取过程中传给用户态,是因为用户进程通过 mmap 发起了系统调用,在用户缓冲区与 page cache 之间建立了映射。

Socket 原先要从用户缓冲区中获取数据,但现在因为映射的原因, 用户缓冲区获取数据是从 page cache 的映射中直接获取,少了一次 page cache CPU 拷贝数据到用户缓冲区的问题(但是两次 mmap() 调用导致的状态切换,但也没关系,因为传统方式也至少需要两次切换)。

操作状态切换次数
mmap() 建立映射2 次(用户→内核→用户)
read() 读数据2 次(用户→内核→用户)

传统 read + writemmap + write 相同,都是 2 次。

mmap 的优势在于:建立一次映射,可多次随机访问,后续读写无需系统调用

传统 read × n 次mmap + n 次访问
状态切换2n 次2 次
数据拷贝n 次(每次 read 都拷贝)0 次(映射后直接读)

请输入图片描述

但 kafka优化相比于 mmap 优化传统拷贝流程之外,还加了 sendfile() 具体从 Page Cache 到 Socket 的数据拷贝传输的话是通过 sendfile() 进行拷贝传输的,由 DMA 完成,属于零拷贝。

注意:这里 Socket 是发送时直接从 Page Cache 读取数据,这时候就会发生拷贝事件

sendfile

mmap + write 虽然省了一次数据拷贝,但仍有缺陷

硬盘 → PageCache ←──映射──→ 用户空间 → Socket缓冲区 → 网卡
                               ↑_________↓
                              CPU拷贝(瓶颈!)

关键问题write() 系统调用时,CPU 必须 把数据从 PageCache 复制到 Socket 缓冲区——用户态参与了,CPU 也参与了

而 Kafka 的场景是:消费者只是原样拉取消息,不需要修改、不需要解析内容。既然数据只是"过路",为什么要让它经过用户态?

sendfile 整体流程如下:

        DMA 1                     DMA 2
硬盘  ───────────→  PageCache  ───────────→  网卡
      (存储DMA)                 (网络DMA)
         ↑___________________________↑
              CPU完全不参与数据搬运

sendfile 的核心机制

sendfile 是一个纯内核态的系统调用,数据流转完全绕过用户空间:

// 传统方式(用户态参与)
read(fd, buf, len);       // 数据先到用户空间
write(sockfd, buf, len);  // 再从用户空间发出

// sendfile(内核自己搞定)
sendfile(sockfd, fd, offset, len);  // 用户态只下命令,不碰数据

DMA Gather Copy:零拷贝的底层支撑

现代网卡支持 Scatter-Gather DMA,让 sendfile 彻底摆脱 CPU:

技术原理效果
传统 DMA只能从连续物理地址搬运数据必须先拷贝到 Socket 缓冲区整理
Gather Copy可从多个不连续物理页收集数据直接从 PageCache 发送到网卡

如果是老网卡没有 DMA Gather Copy 的话,就会降级为 “PageCache → Socket → 网卡” 的多一次 CPU 拷贝的流程。但即便降级了也比 mmap + write 的方案好,因为传统 DMA 能够代替 mmap

原因在于 sendfile 利用内核直接访问 Page Cache,因此不需要 mmap() 把文件映射到用户空间,省去了 mmap() 这个系统调用带来的两次用户态/内核态切换;同时 DMA 可以让网卡直接读取内存页,减少 CPU 数据拷贝。

sendfile 的局限性

上面也说了 kafka 使用的场景为 “消费者只是原样拉取消息,不需要修改、不需要解析内容”,这也说明了 sendfile 并非万能,以下场景无法使用效果受限

限制说明
不能修改数据如需加密、压缩、改格式,必须读到用户态处理
TLS/SSL 不适用SSL 需要加密,数据必须过用户态,sendfile 优势丧失
需要硬件支持老旧网卡不支持 Gather Copy,退化为 1 次 CPU 拷贝
仅适用文件→Socket不能用于 Socket→Socket 转发(如代理场景)
RocketMQ 用 mmap + write 而不用 sendfile,正是因为它需要解析消息体做重试/延迟路由——功能需求牺牲了零拷贝性能。

mmap 优化的是 "用户态如何高效读取内核数据"(省拷贝,但仍需用户态参与); sendfile 优化的是 "内核如何直接转发数据"(用户态只下命令,数据完全不碰用户空间)。

两种常见用法

① 文件映射(替代 read/write)

int fd = open("data.bin", O_RDWR);
void *p = mmap(NULL, 8192,
               PROT_READ | PROT_WRITE,
               MAP_SHARED,
               fd, 0);
// 修改映射区域
p[0] = 'A';
msync(p, 8192, MS_SYNC);  // 强制同步回文件
munmap(p, 8192);

mmap 并不是直接把文件加载到用户内存,而是建立文件与进程虚拟地址之间的映射:

   用户虚拟地址
        ↓
    Page Cache
        ↓
     磁盘文件

第一次访问映射区域时,如果对应文件页还没有加载到内存,内核会将数据加载到 Page Cache,并建立虚拟地址到物理页的映射。

因此 p[0] = 'A'; 修改的是 Page Cache 中对应的文件页,之后由内核负责回写文件。


② 匿名映射(共享内存、大块内存分配)

void *sh = mmap(NULL, 1 << 20,
                PROT_READ | PROT_WRITE,
                MAP_SHARED | MAP_ANONYMOUS,
                -1, 0);

匿名映射不关联文件,而是直接申请匿名内存页。

常用于:

  • 进程间共享内存 IPC
  • 大块内存分配
  • runtime 内存管理

例如 fork();,如果使用 MAP_SHARED,则父子进程共享同一块物理内存:

 父进程               子进程

 虚拟地址             虚拟地址
    |                  |
    +--------+---------+
             |
          同一物理页

如果使用 MAP_PRIVATE,则采用 Copy-On-Write (COW)机制:

fork 前:
 父进程
   |
物理页 A
   |
 子进程

子进程修改:
父进程 → 物理页 A
子进程 → 复制后的物理页 B

只有发生写操作时,内核才会复制页面。


malloc / heap 与 mmap 的区别

现代 glibc malloc 底层主要有两种内存来源:

  • brk 管理连续 heap
  • mmap(MAP_ANONYMOUS) 分配独立内存区域
brk heapmmap anonymous
虚拟地址连续增长独立地址区域
适合场景小对象频繁分配大对象申请
释放方式通常只能收缩顶部可以直接 munmap
用途普通 malloc大块内存、共享内存

glibc 通常会让较大的内存申请直接走 mmap,避免影响主 heap


Go runtime 中的 mmap 与 arena

Go runtime 同样会通过 mmap 向操作系统申请大块内存区域:

OS:
  mmap()
    ↓
heap arena
    ↓
  span
    ↓
  object

arena 可以理解为 runtime 从操作系统批量申请的一块堆空间,后续对象分配从这片区域中管理,减少频繁向内核申请内存

例如:

s := make([]int, 100000)

可以理解为:

slice header
      ↓
heap 中的数据区域

slicemapchannel 等结构本身只是描述信息,具体位置由逃逸分析决定,可能位于栈或 heap

这一点涉及到 Go 的逃逸分析以及 Go 原生各个数据结构底层的实现逻辑,具体可以仔细看看,能加深对 Go 本身相关实现的理解。

而它们关联的大量动态数据,通常来自 runtime 管理的 heap arena

性能特点

  • 省一次用户态↔内核态拷贝(对比 read/write)。
  • 读/写延迟加载(缺页才映射物理页)。
  • 不同进程 MAP_SHARED 同一块文件,物理页共享,天然 IPC。
  • TLB 抖动:映射太大、随机访问会比小块内存慢,可用 madvise(MADV_RANDOM) 提示内核。

总结

mmap = “把磁盘文件或匿名页 ‘贴’ 到虚拟地址;第一次访问才通过缺页真正分到物理页。”

传统 read + write(4 次拷贝,4 次切换)

硬盘 → PageCache → 用户缓冲区 → Socket缓冲区 → 网卡
 ↑______↓  ↑_________↓  ↑_________↓  ↑______↓
  DMA拷贝     CPU拷贝       CPU拷贝     DMA拷贝

read() 第一次 DMA 拷贝开始 用户态 → 内核态,第一次CPU拷贝结束 内核态 → 用户态;

write() 同理第二次 DMA 拷贝开始 用户态 → 内核态,第二次CPU拷贝结束 内核态 → 用户态。

mmap + write(3 次拷贝,4 次切换)

硬盘 → PageCache ←──映射──→ 用户空间 → Socket缓冲区 → 网卡
  ↑______↓                    ↑__________↓   ↑______↑
   DMA拷贝                        CPU拷贝       DMA拷贝

建立映射开始到结束 用户态 → 内核态 → 用户态;

write() 同理第二次 DMA 拷贝开始 用户态 → 内核态,第二次CPU拷贝结束 内核态 → 用户态。

sendfile(2 次拷贝,2 次切换,CPU 不碰数据)

硬盘 → PageCache ──────→ 网卡
 ↑______↓ ↑_______________↓
  两次DMA拷贝(全程无CPU参与)

sendfile() 发起时经历两次 DMA 拷贝,第一次开始时 用户态 → 内核态,第二次结束时 内核态 → 用户态。

优化后的各种优势

CPU解放优势

CPU 解放代表着更高的 CPU 资源利用效率,系统其他任务更有资源使用,系统整体运行更快。

省下的 CPU 时间可以:

  • 处理其他客户端的请求(提高并发连接数
  • 执行压缩、序列化、业务逻辑(降低请求延迟
  • 做垃圾回收、日志处理(提升系统吞吐量

数据搬运是内存带宽密集型任务,会抢占 CPU 缓存和总线。CPU 不碰数据后,缓存命中率飙升,业务逻辑执行更快。

状态切换减少优势

状态切换的减少意味着开销降低,上下文污染减少,时间开销降低。

状态切换开销

用户态 → 内核态(系统调用入口):
  1. 保存用户态寄存器(十几个到几十个寄存器)
  2. 切换栈指针(用户栈 → 内核栈)
  3. 权限检查(CPU 环级切换)
  4. 刷新 TLB/缓存(部分场景)
  
内核态 → 用户态(返回):
  1. 恢复用户态寄存器
  2. 切换栈指针回来
  3. 可能的调度检查

切换带来的上下文污染

问题影响
缓存失效用户态缓存的数据,进内核后可能用不上
TLB 抖动页表切换导致地址翻译变慢
流水线清空CPU 指令预执行被打断

省状态切换 = 省 CPU 周期 + 省缓存污染 + 省上下文折腾 = 系统吞吐量提升,延迟降低。

PREV
[Golang] Go异常处理与其他语言的区别(补)

评论(0)

发布评论