零拷贝(Zero-Copy)与 mmap 内存映射

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/unixMmap;进程内避免副本用 Builder/Buffer,极致性能再考虑 unsafe

平台差异提醒 #

机制 Linux 其它
sendfile ✅ Go 自动用 macOS/BSD 有 sendfile,Windows 用 TransmitFile;Go runtime 已分别适配
splice ✅ Go 自动用 仅 Linux;其它平台 io.Copy 回退到普通缓冲拷贝
mmap unix.Mmap Windows 走 CreateFileMappinggolang.org/x/exp/mmap 已封装跨平台只读映射)

所以在 Go 里写 io.Copy,是「能零拷贝就零拷贝、不能就安全回退」——这也是优先用标准库而非手撸系统调用的最大理由。