零拷贝Zero-copy

b1b9aa01663b4b60051f11fae4410b7b7f53af22.jpg

技术概念

零复制(Zero-copy,也称零拷贝),是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。

上面这段说明是百度百科的解释,我看了一下wiki的解释,一个字不差,不知道谁抄的谁的

但不管谁是原创,在业务应用上领域,眨眼看上去还是看不懂

但有一点读懂的是,他的应用场景主要是网络传输文件

零拷贝能解决什么问题

一个文件下载服务器

我们有一段代码程序,是一个web服务,主要作用是提供服务器上文件下载功能

当一个请求操作进来时,我们的计算机会执行一系列相关的IO操作,包含文件的读取、复制、传输等,示例如下

步骤1:磁盘数据 → 内核缓冲区(操作系统管理的内存)
步骤2:内核缓冲区 → 应用程序缓冲区(你的Go代码里的变量)
步骤3:应用程序缓冲区 → 套接字缓冲区(网络发送的内核内存)
步骤4:套接字缓冲区 → 网卡硬件

这里步骤 2 和步骤 3 就是冗余拷贝:数据只是从内核到应用程序 "过了一手",没做任何处理就又送回内核。零拷贝的核心就是去掉这些无意义的拷贝,减少 CPU 消耗和内存带宽占用。

简单来说,就是需要把文件读取到你的程序变量中,再进行网络传输,这里也就意味着,我们下载的每一个文件,都要过一遍程序的内存申请、释放

虽说内存在使用完,会释放掉,但如果能直接把服务器上的文件直接进行网络传输,岂不是节省了内存的申请释放过程

进行一下调整

如果我们把上面步骤2步骤3省略掉,那程序里怎么实现读取文件,传输给客户端?

的确,代码还是要写的,文件不读取拷贝如何传递给客户端,难道手动拿U盘去服务器拷贝么?

零拷贝不是完全不拷贝(最终数据还是要到磁盘去取),而是避免应用程序参与数据拷贝。

实现思路是让内核直接处理数据转移,应用程序只需要告诉内核 “数据在哪”“要传到哪”(通过指针或描述符)。

直白的来说,就相当于程序之前是操作一个变量的值进行传递,现在修改为操作该变量的指针

我们先来看一下改进后的示例

步骤1:磁盘数据 → 内核缓冲区
步骤2:内核缓冲区 → 套接字缓冲区(内核内部直接转移,不经过应用程序)
步骤3:套接字缓冲区 → 网卡硬件

还有一种更极致的做法:内核直接把磁盘数据的内存地址 “告诉” 网卡,跳过套接字缓冲区(需要硬件支持,比如 DMA)。

在业务中的应用场景

简单说:当数据"只是路过"应用程序,不需要二次处理时,零拷贝收益最大。具体包括:

  • 大文件传输:比如下载服务、视频流服务,数据从磁盘到网络,无需应用程序处理。
  • 日志收集 / 转发:日志数据从文件或网络接收后,直接转发到其他地方(如 ELK),无需解析。
  • 中间件数据传递:如消息队列、缓存系统,数据在内存中直接转发,减少拷贝开销。

在程序中的应用

这里提到的用户态,就把它当作代码程序的操作即可,或者说变量

在Go语言中,零拷贝技术的实现依赖于操作系统提供的底层系统调用(如sendfile、mmap等),注意哈,以来系统层的技术

Go标准库已经对这些系统调用的进行了封装,无需过多关心实现。

以下是Go中零拷贝的几个实用场景

sendfile 系统调用(最典型)

  • 作用:直接在内核态将文件数据从文件描述符拷贝到套接字描述符,完全跳过用户态内存。

讲人话就是:A卖房子给C,去中介B那签个合同即可,而不是说A先把房子卖BB再卖给C

适用场景:文件内容直接通过网络发送(如静态文件服务、大文件传输)。

package main

import (
    "io"
    "net"
    "os"
)

func main() {
    // 打开本地大文件
    file, _ := os.Open("large_file.dat")
    defer file.Close()

    // 连接远程服务器
    conn, _ := net.Dial("tcp", "remote-server:8080")
    defer conn.Close()

    // 关键:io.Copy会检测是否支持sendfile,支持则用零拷贝
    _, _ = io.Copy(conn, file)
}

标准库io.Copy内部已经实现了零拷贝的系统调用

io.Copy的源是*os.File,目标是网络连接(如*net.TCPConn)时,Go 会调用sendfile系统调用

数据流程为:磁盘 → 内核文件缓冲区 → 内核套接字缓冲区 → 网卡

全程无用户态内存参与,减少 2 次拷贝(用户态→内核态的来回拷贝)

mmap + write(内存映射)

作用:将文件内容直接映射到用户态内存地址空间,应用程序操作内存即可读写文件,避免read/write的拷贝。

适用场景:需要频繁随机访问文件内容(而非顺序读取),且数据无需修改或修改后直接落盘。

package main

import (
    "net"
    "syscall"
)

func main() {
    // 打开文件(注意需用syscall.Open,获取文件描述符)
    fd, _ := syscall.Open("data.txt", syscall.O_RDONLY, 0644)
    defer syscall.Close(fd)

    // 获取文件大小
    stat, _ := syscall.Stat(fd)
    size := stat.St_size

    // 映射文件到用户态内存(PROT_READ:只读;MAP_SHARED:修改同步到文件)
    data, _ := syscall.Mmap(fd, 0, int(size), syscall.PROT_READ, syscall.MAP_SHARED)
    defer syscall.Munmap(data) // 必须释放映射

    // 这里data是[]byte,你可以读取其内容放入其它变量中,当然也可以直接网络发送

    // 发送映射的内存数据(此时data是内核缓冲区的映射,write时减少拷贝)
    conn, _ := net.Dial("tcp", "server:8080")
    defer conn.Close()
    _, _ = conn.Write(data) // 此处write可能优化为直接从映射内存拷贝到套接字缓冲区
}

相比较而言,io.copy在网络传输时零拷贝,使用简单;而syscall.Mmap是真正的零拷贝,不仅可用于网络传输,也可以用于其它的读取业务场景,但使用难度稍微大些

编程语言的设计

零拷贝概念在编程语言的设计中也有体现,并不单一指的就是操作系统层的减少文件内存拷贝,而是一种思想的体现

func parseData(data []byte) {
    // 提取头部(不拷贝数据,仅创建新切片)
    header := data[:4]

    // 提取内容(同样不拷贝)
    content := data[4:100]

    // 处理header和content...
}
var buf bytes.Buffer
buf.WriteString("hello")
data := buf.Bytes() // 返回内部[]byte的引用,无拷贝

总结

  • 静态文件操作
  • 读取、不修改
  • 节省CPU内存
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。