传统读写模式
传统读写模式流程图
- 第一次数据拷贝: 用户进程发起 read() 系统调用,当前上下文从用户态切换至内核态,DMA(Direct Memory Access) 引擎从文件中读取数据,并存储到内核态缓冲区 (DMA 拷贝)
- 第二次数据拷贝: 将数据从内核态缓冲区拷贝到用户态缓冲区 (CPU 拷贝),然后返回给用户进程,拷贝数据时会发生一次上下文切换 (从内核态切换到用户态)
- 第三次数据拷贝: 用户进程发起 write() 系统调用,当前上下文从用户态切换至内核态,数据从用户态缓冲区被拷贝到 Socket 缓冲区 (CPU 拷贝)
- 第四次数据拷贝: write() 系统调用结束返回到用户进程,当前上下文从内核态切换至用户态,第四次数据拷贝为异步执行,从 Socket 缓冲区拷贝到网卡 (DMA 拷贝)
transferTo
transferTo() 和 send() 类似,也是一个系统调用,用于在文件之间高效地传输数据。
transferTo 在操作系统层面实现了零拷贝技术,允许将数据直接从一个文件传输到另一个文件,而无需通过用户空间进行中转。
transferTo 流程图
- 第一次数据拷贝: 用户进程发起 transferTo() 调用,将文件数据拷贝到一个 Read buffer(内核态)中,当前上下文从用户态切换至内核态
- 第二次数据拷贝: 内核将 Read buffer 中的数据拷贝到 Socket 缓冲区
- 第三次数据拷贝: 数据从 Socket 缓冲区拷贝到网卡,当前上下文从内核态切换至用户态
相比较于传统的读写模式, transferTo 把上下文的切换次数从 4 次减少到 2 次,同时把数据拷贝的次数从 4 次降低到了 3 次, 虽然已经前进了一大步,但是作为过渡阶段,transferTo 距离零拷贝还有一些距离。
零拷贝
零拷贝是相对于用户态来讲的,数据在用户态不发生任何拷贝。
sendfile + DMA
sendfile() 是作用于两个文件描述符之间的数据拷贝的系统调用,这个拷贝操作是直接在内核中进行的,没有用户态到内核态的数据拷贝和上下文切换带来的开销,所以称为零拷贝技术。
Linux2.4 内核对 sendfile 系统调用做了改进:
sendfile 改进
- 用户进程发起 sendfile() 系统调用,当前上下文从用户态切换至内核态,DMA 将数据拷贝到内核缓冲区
- 向 Socket 缓冲区中发送当前数据在内核缓冲区的地址和偏移量两个值
- 根据 Socket 缓冲区的地址和偏移量,直接将内核缓冲区的数据拷贝到网卡,当前上下文从内核态切换至用户态
零拷贝流程图
相比较于传统的读写模式, sendfile + DMA 把上下文的切换次数从 4 次减少到 2 次,同时把数据拷贝的次数从 4 次降低到了 2 次 (2 次均为 DMA 拷贝),完全消除了数据从用户态和内核态之间拷贝数据带来的开销。
sendfile + DMA 虽然已经足够高效,但是依然存在两个不足之处:
- 方案本身需要引入新的硬件支持
- 输入文件描述符仅支持文件类型
splice
针对 sendfile + DMA 方案存在的不足,Linux 引入了 splice() 系统调用, splice() 不需要硬件支持,能够实现在任意的两个文件描述符时之间传输数据。
splice() 是基于管道缓冲区机制实现的,所以两个参数文件描述符必须有一个是管道设备。在实际开发中,splice() 作为实现零拷贝的首选,因此 sendfile() 的内部实现也替换为了 splice()。
Go 语言中的零拷贝
go1.19 linux/amd64
sendfile
sendfile 的方法原型为 syscall.Sendfile,文件路径为 syscall/syscall_unix.go。
一个简单的使用示例:
splice
splice 的方法原型为 syscall.Splice,文件路径为 syscall/zsyscall_linux_amd64.go。
一个简单的使用示例: