1. 先看一个最常见的场景 #
「把一个文件通过网络发出去」——下载服务、静态文件服务器、Kafka 发消息、数据库回传结果,本质都是这件事:
// 最朴素的写法
buf := make([]byte, 32*1024)
for {
n, err := file.Read(buf) // 从磁盘文件读到 buf
if n > 0 {
conn.Write(buf[:n]) // 把 buf 写到网络 socket
}
if err != nil { break }
}
看起来只是「读出来、写进去」,但在操作系统底层,这份数据被反复搬运了 4 次,还伴随 4 次用户态/内核态切换。零拷贝要解决的就是这个浪费。要看懂它,先补三个基础概念。
2. 三个预备概念 #
2.1 用户态 vs 内核态 #
程序跑在用户态,权限受限;读写磁盘/网卡这类硬件操作只能由操作系统内核态来做。应用每次请求 I/O,都要通过系统调用(syscall)从用户态「陷入」内核态,做完再切回来。这一来一回叫上下文切换,要保存/恢复寄存器等现场,有实打实的开销。
2.2 两块缓冲区 #
- 内核缓冲区:内核管理的内存,其中读文件用的那块又叫 Page Cache(页缓存)——磁盘数据先进这里,方便复用。
- 用户缓冲区:你代码里
make([]byte, ...)那个buf,在用户态内存里。
数据在「磁盘 ↔ 内核缓冲区 ↔ 用户缓冲区 ↔ 网卡缓冲区」之间流动,每跨一道就是一次拷贝。
2.3 谁来搬:CPU 拷贝 vs DMA 拷贝 #
- DMA 拷贝:DMA(直接内存访问)是硬件控制器,能在「硬件设备 ↔ 内存」之间搬数据,不占用 CPU。磁盘→内核、内核→网卡这种和硬件打交道的搬运由它完成。
- CPU 拷贝:「内存 ↔ 内存」(如内核缓冲区→用户缓冲区)的搬运由 CPU 亲自做,期间 CPU 干不了别的,这是最该省的开销。
记住一句话:零拷贝的「零」,主要指消灭 CPU 拷贝(内存到内存的搬运),而不是真的一次拷贝都没有——和硬件交互的 DMA 拷贝通常省不掉。
3. 传统 I/O:数据被搬了 4 次 #
file.Read() + conn.Write() 全程的数据流向(粉色=用户态,蓝色=内核态):
flowchart LR
Disk[("磁盘")] -->|"DMA拷贝①(硬件搬)"| PC["内核 Page Cache"]
PC -->|"CPU拷贝②⚠️"| Buf["用户缓冲区 buf"]
Buf -->|"CPU拷贝③⚠️"| Sock["Socket 缓冲区"]
Sock -->|"DMA拷贝④(硬件搬)"| NIC["网卡"]
classDef user fill:#fde6f0,stroke:#c2548a;
classDef kern fill:#e6f0fd,stroke:#3a7bd5;
class Buf user;
class PC,Sock kern;
- read 阶段:磁盘 →(DMA①) Page Cache →(CPU②) buf,伴随进/出内核 2 次切换。
- write 阶段:buf →(CPU③) Socket 缓冲区 →(DMA④) 网卡,又是 2 次切换。
数一下成本:
| 项目 | 次数 | 明细 |
|---|---|---|
| 上下文切换 | 4 次 | read 进/出 + write 进/出 |
| 数据拷贝 | 4 次 | DMA×2(磁盘→内核、socket→网卡)+ CPU×2(内核→用户、用户→socket) |
问题很清楚:数据从头到尾只是「过个路」,根本没被程序加工,却被 CPU 来回搬了 2 趟(图中 ⚠️)、还切换了 4 次上下文。这 2 次 CPU 拷贝纯属浪费——零拷贝就是来削它们的。
4. 零拷贝技术演进 #
核心思路:数据只是中转,就别让它绕到用户态再绕回来,让它在内核里直接从「文件」流到「网卡」。下面按历史顺序,一步步把拷贝和切换省掉。
4.1 mmap + write:先省一次 CPU 拷贝 #
用 mmap(见 §5 详解)把内核 Page Cache 直接映射到用户空间——用户和内核共享同一块物理内存,于是「内核→用户」那次 CPU 拷贝消失了,write 时直接从 Page Cache 拷到 socket。
flowchart LR
Disk[("磁盘")] -->|"DMA①"| PC["内核 Page Cache"]
PC -. "映射:用户/内核共享同一块内存,无需拷贝" .-> U["用户空间(拿到的是映射,不是副本)"]
PC -->|"CPU拷贝②"| Sock["Socket 缓冲区"]
Sock -->|"DMA③"| NIC["网卡"]
| 上下文切换 | 数据拷贝 |
|---|---|
| 仍是 4 次 | 3 次:DMA×2 + CPU×1 |
省了 1 次 CPU 拷贝。代价:mmap 本身有建立映射、缺页中断的开销,小文件不划算。
4.2 sendfile:一个系统调用搞定,再省切换 #
Linux 2.1 引入 sendfile(out_fd, in_fd, ...):直接告诉内核「把文件 fd 的内容发到 socket fd」,数据全程不进用户态。一次系统调用代替了 read+write 两次。
flowchart LR
Disk[("磁盘")] -->|"DMA①"| PC["Page Cache"]
subgraph K["一次 sendfile() 调用:数据全程不出内核态"]
PC -->|"CPU拷贝②"| Sock["Socket 缓冲区"]
end
Sock -->|"DMA③"| NIC["网卡"]
| 上下文切换 | 数据拷贝 |
|---|---|
| 2 次(只进/出一次) | 3 次:DMA×2 + CPU×1 |
切换从 4 降到 2。但内核里仍有 1 次 CPU 拷贝(Page Cache → Socket 缓冲区)。
4.3 sendfile + SG-DMA:真正的「零 CPU 拷贝」 #
Linux 2.4 起,如果网卡支持 SG-DMA(分散-聚集 DMA):内核不再把数据拷到 socket 缓冲区,只把数据在 Page Cache 里的位置和长度(描述符)告诉 socket,网卡的 SG-DMA 直接按描述符从 Page Cache 收集数据发走。
flowchart LR
Disk[("磁盘")] -->|"DMA①"| PC["Page Cache"]
PC -. "只传 位置+长度 描述符(不拷数据)" .-> Sock["Socket 缓冲区"]
PC ==>|"网卡 SG-DMA② 直接收走数据"| NIC["网卡"]
| 上下文切换 | 数据拷贝 |
|---|---|
| 2 次 | 2 次,且全是 DMA,CPU 拷贝 = 0 ✅ |
这才是教科书意义上的零拷贝:CPU 完全不碰数据。代价:纯转发,应用看不到数据内容(不能边发边改)。
4.4 splice:不依赖硬件的内核内零拷贝 #
Linux 2.6 的 splice 借助管道(pipe)在两个 fd 之间「移动」数据:数据始终在内核缓冲区里,靠页指针的拼接而非真实拷贝来流转,不要求网卡支持 SG-DMA。Go 在做 socket→socket 转发时会用到它(见 §6.4)。
4.5 一张表看懂演进 #
| 方案 | 上下文切换 | CPU 拷贝 | DMA 拷贝 | 数据进用户态? |
|---|---|---|---|---|
| 传统 read + write | 4 | 2 | 2 | 是 |
| mmap + write | 4 | 1 | 2 | 映射(不算拷贝) |
| sendfile | 2 | 1 | 2 | 否 |
| sendfile + SG-DMA | 2 | 0 | 2 | 否 |
| splice | 2 | 0 | 2 | 否 |
5. mmap 内存映射详解 #
mmap(memory map,内存映射)是零拷贝的一块基石,也单独是个极有用的工具,值得专门讲。
5.1 它做了什么 #
普通读文件:read() 把磁盘数据拷贝一份到你的缓冲区,你操作的是副本。
mmap 不一样:它把文件直接「映射」到进程的虚拟地址空间——返回一段内存地址,你像访问数组一样访问它,读写这段内存就等于读写文件,中间没有「拷到用户缓冲区」这一步。
flowchart TB
subgraph T["传统 read:产生一份副本"]
direction LR
D1[("磁盘文件")] -->|"DMA"| P1["Page Cache"] -->|"CPU拷贝"| B1["你的 buf(副本)"]
end
subgraph M["mmap:零副本"]
direction LR
D2[("磁盘文件")] <--> P2["Page Cache"]
P2 <-. "虚拟地址直接映射<br/>你访问的内存 = Page Cache 本身" .-> A2["进程地址空间"]
end
映射关系与按需加载:
graph LR
A["进程虚拟地址<br/>data[0..N]"] -->|映射,非拷贝| P["内核 Page Cache"]
P <-->|按需 DMA| D[("磁盘文件")]
A -.访问未加载的页.-> F["缺页中断<br/>内核临时把该页 load 进来"]
F --> P
5.2 关键机制:按需加载(缺页中断) #
mmap 调用瞬间并不真的读磁盘,只是建立了「地址 ↔ 文件」的映射关系。等你第一次访问某个地址时,发现对应的页还没在内存,触发缺页中断(page fault),内核才把那一页从磁盘 load 进 Page Cache。
好处:映射一个 10GB 的大文件几乎瞬间完成、也不吃 10GB 内存,用到哪页才加载哪页。
5.3 优点与代价 #
| 优点 | 代价 / 坑 |
|---|---|
| 省掉「内核→用户」的 CPU 拷贝 | 缺页中断有开销,小文件/顺序读未必比 read 快 |
| 大文件随机访问极方便(像操作数组) | 访问越界/文件被截断 → 触发 SIGBUS 信号崩溃 |
| 多进程映射同一文件可实现共享内存 | 映射占用进程虚拟地址空间,32 位系统受限 |
修改内存即修改文件(MAP_SHARED),省去 write |
脏页何时刷盘由内核决定,需 msync 控制持久化时机 |
5.4 什么时候用 mmap #
- ✅ 大文件随机读:如数据库、索引文件、Bigtable/LevelDB 这类存储引擎大量用 mmap 读 SSTable。
- ✅ 多进程共享内存:进程间高效通信。
- ✅ 文件当数组操作、频繁随机访问同一大文件。
- ❌ 一次性顺序读小文件:直接
read/os.ReadFile更简单更快,别为零拷贝而 mmap。 - ❌ 纯「文件→网络」转发:用
sendfile(§6.2)比 mmap+write 更彻底。
6. Go 语言里怎么用 #
好消息:Go 标准库已经把零拷贝藏在 io.Copy 背后了,多数时候你无需手写系统调用。
6.1 io.Copy:标准库自动选择零拷贝 #
io.Copy(dst, src) 会探测 dst/src 有没有实现快捷接口,命中就走零拷贝,否则才回退到「分配缓冲区 + 循环读写」。
// 文件 → 网络连接
func sendFile(conn net.Conn, path string) error {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
// 关键:在 Linux 上,io.Copy 探测到
// - dst (*net.TCPConn) 实现了 io.ReaderFrom
// - src 是 *os.File
// 底层自动调用 sendfile 系统调用,数据不进用户态,无 CPU 拷贝。
_, err = io.Copy(conn, file)
return err
}
它的内部决策逻辑大致是:
flowchart TD
S["io.Copy(dst, src)"] --> Q1{"dst 实现<br/>io.ReaderFrom?"}
Q1 -->|是| RF["调 dst.ReadFrom(src)"]
RF --> Q2{"src 是什么?"}
Q2 -->|"*os.File"| SF["走 sendfile ✅"]
Q2 -->|"另一个 conn"| SP["走 splice ✅"]
Q1 -->|否| Q3{"src 实现<br/>io.WriterTo?"}
Q3 -->|是| WT["调 src.WriteTo(dst)"]
Q3 -->|否| FB["分配 32KB 缓冲区<br/>循环 Read/Write(传统拷贝)"]
实践建议:优先用
io.Copy,别自己make([]byte, ...)手撸循环——既简洁,又能白嫖标准库的 sendfile/splice 优化。
6.2 直接调 sendfile #
需要精确控制时,可用 golang.org/x/sys/unix:
import "golang.org/x/sys/unix"
// 把 src 文件的 count 字节从 offset 处,零拷贝发到 dst socket
func rawSendfile(dstFd, srcFd int, offset int64, count int) (int, error) {
off := offset
return unix.Sendfile(dstFd, srcFd, &off, count)
}
实际项目极少这么写——io.Copy 已覆盖绝大多数场景,这里只为说明底层。
6.3 mmap:映射大文件 #
标准库没有直接导出 mmap,用 golang.org/x/sys/unix(跨 Unix)或只读场景用 golang.org/x/exp/mmap。
import (
"os"
"golang.org/x/sys/unix"
)
func mmapRead(path string) ([]byte, func() error, error) {
f, err := os.Open(path)
if err != nil {
return nil, nil, err
}
defer f.Close()
info, _ := f.Stat()
size := int(info.Size())
// 把文件映射成一段 []byte;PROT_READ 只读,MAP_SHARED 与他人共享同一 Page Cache
data, err := unix.Mmap(int(f.Fd()), 0, size, unix.PROT_READ, unix.MAP_SHARED)
if err != nil {
return nil, nil, err
}
// data 就是文件内容,像普通切片一样访问:
// data[0]、data[1024:2048] ……访问到未加载的页时自动缺页加载
// 用完必须 Munmap 释放映射。
closer := func() error { return unix.Munmap(data) }
return data, closer, nil
}
只读纯净版(标准库风格的随机读):
import "golang.org/x/exp/mmap"
r, _ := mmap.Open("big.idx")
defer r.Close()
buf := make([]byte, 8)
r.ReadAt(buf, 4096) // 随机读偏移 4096 处 8 字节,底层走 mmap,无系统调用开销
6.4 socket → socket 转发:splice #
代理/网关常做「把一个连接的数据原样转发到另一个连接」。在 Linux 上,io.Copy(dstConn, srcConn)(两端都是 *net.TCPConn)会自动走 splice,数据在内核管道里流转,不进用户态:
// 一个最简 TCP 代理转发,底层自动 splice 零拷贝
func proxy(dst, src *net.TCPConn) {
io.Copy(dst, src) // Linux: poll.Splice,内核内零拷贝
}
6.5 Go 进程内的「零拷贝」小技巧 #
上面是「内核层零拷贝」;Go 代码层面也有避免内存副本的常用手段:
import "unsafe"
// string ↔ []byte 零拷贝转换(Go 1.20+),避免标准转换产生的内存复制
func StringToBytes(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
func BytesToString(b []byte) string {
return unsafe.String(unsafe.SliceData(b), len(b))
}
⚠️ 危险警告:unsafe 转换出的 []byte 与原 string 共享同一块内存,而 Go 的 string 必须只读——改它会破坏不可变性,导致难以排查的 bug。仅在性能热点、且你能保证不修改时使用。
更安全的日常技巧:
- 用
bytes.Buffer/strings.Builder累积数据,避免反复+拼接产生大量临时副本。 io.Copy配*bytes.Reader/bufio减少小块读写。- HTTP 响应大文件直接
http.ServeContent/http.ServeFile,内部已用 sendfile。
7. 选型小结 #
graph TD
Q{要干什么?} --> A[文件原样发到网络]
Q --> B[大文件随机读/共享内存]
Q --> C[连接间转发数据]
Q --> D[进程内处理数据]
A --> A1["io.Copy(conn, file)<br/>→ 自动 sendfile ✅"]
B --> B1["unix.Mmap / mmap.Open<br/>→ 内存映射 ✅"]
C --> C1["io.Copy(dstConn, srcConn)<br/>→ 自动 splice ✅"]
D --> D1["bytes.Buffer / strings.Builder<br/>必要时 unsafe 转换"]
一句话总结:
- 零拷贝的本质:让中转数据别绕进用户态、别让 CPU 白搬——省掉 CPU 拷贝和上下文切换,DMA 拷贝一般留着。
- 技术谱系:
mmap+write(省 1 次 CPU 拷贝)→sendfile(省切换)→sendfile+SG-DMA/splice(CPU 拷贝归零)。 - mmap:把文件映射成内存、按需缺页加载,擅长大文件随机读与共享内存,但小文件顺序读别用。
- 在 Go 里:90% 的场景直接
io.Copy就白嫖了 sendfile/splice;要映射大文件用golang.org/x/sys/unix的Mmap;进程内避免副本用 Builder/Buffer,极致性能再考虑unsafe。
平台差异提醒 #
| 机制 | Linux | 其它 |
|---|---|---|
sendfile |
✅ Go 自动用 | macOS/BSD 有 sendfile,Windows 用 TransmitFile;Go runtime 已分别适配 |
splice |
✅ Go 自动用 | 仅 Linux;其它平台 io.Copy 回退到普通缓冲拷贝 |
mmap |
✅ unix.Mmap |
Windows 走 CreateFileMapping(golang.org/x/exp/mmap 已封装跨平台只读映射) |
所以在 Go 里写
io.Copy,是「能零拷贝就零拷贝、不能就安全回退」——这也是优先用标准库而非手撸系统调用的最大理由。