从mmap→sendfile的性能优化
这里也有短暂的解释是什么。老文章,发出来复习一下的。
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 + write 和 mmap + 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管理连续heapmmap(MAP_ANONYMOUS)分配独立内存区域
| brk heap | mmap anonymous | |
|---|---|---|
| 虚拟地址 | 连续增长 | 独立地址区域 |
| 适合场景 | 小对象频繁分配 | 大对象申请 |
| 释放方式 | 通常只能收缩顶部 | 可以直接 munmap |
| 用途 | 普通 malloc | 大块内存、共享内存 |
glibc 通常会让较大的内存申请直接走 mmap,避免影响主 heap。
Go runtime 中的 mmap 与 arena
Go runtime 同样会通过 mmap 向操作系统申请大块内存区域:
OS:
mmap()
↓
heap arena
↓
span
↓
objectarena 可以理解为 runtime 从操作系统批量申请的一块堆空间,后续对象分配从这片区域中管理,减少频繁向内核申请内存。
例如:
s := make([]int, 100000)可以理解为:
slice header
↓
heap 中的数据区域slice、map、channel 等结构本身只是描述信息,具体位置由逃逸分析决定,可能位于栈或 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 周期 + 省缓存污染 + 省上下文折腾 = 系统吞吐量提升,延迟降低。
RoLingG | 博客
评论(0)