在一些高性能的IO场景下我们经常能听到零拷贝
技术,这是个不错的话题。
零拷贝指的是内核态与用户态之间的数据拷贝,而这两个区域的数据拷贝只能依靠CPU,但是CPU最重要的作用应该是运算。
一、DMA的由来
在没有DMA之前,磁盘的IO是这样的。
- 用户进程调用read操作,CPU收到指令后,发出对应的指令给磁盘控制器,然后返回。
- 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断。
- CPU收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间CPU 是无法执行其他任务的。
可以看到整个过程占用了大量的CPU时间,如果数据量大的话那整个的性能将不敢想象。
如果将这种搬运数据的工作交给单独的组件来执行,那么就可以解放CPU,使其专注于运算。于是就有了 DMA (直接内存访问 Direct Memory Access),顾名思义,就是允许外部设备直接读写内存,既不通过CPU,也不需要CPU干预。主存和DMA控制器之间有一条数据通路,因此主存和I/O设备之间交换信息时,不通过CPU。在数据块传送时,主存地址的确定、传送数据的计数等都由外设的硬件电路直接实现。主存中要开辟专用缓冲区,及时供给和接收外设的数据。
有了DMA之后传输数据就变成了
二、文件传输案例
在不适用任何零拷贝技术的情况下,我们将一个文件从服务器上发送给客户端的过程大致如下。
要经过两次系统调用(read & write)加四次数据拷贝,而每次系统调用都有两次用户态和内核态的切换。具体看图。
三、如何实现零拷贝
1、mmap (memory-map)
它可以把文件映射到进程的虚拟内存空间。通过对这段内存的读取和修改,可以实现对文件的读取和修改,而不需要用read和write系统调用,但是这一切都需要操作系统在幕后工作(异步处理)。如下图所示,为mmap实现原理的示意图。
可以看到,用户进程空间中某一块虚拟内存与内核中的物理内存(PageCache)形成映射,而这块物理内存与目标文件的某一块形成映射。用户进程读取文件的过程不是传统的read系统调用,而是直接访问的PageCache,如果没有数据,系统会把文件的内容读取过来缓存起来,应该说就是利用的内核中的缓存区。
为什么传统的read操作会有一个从内核缓存中把数据拷贝到用户态的操作呢,我的理解是,内核态中的数据属于高速缓存,在有效期内它可以被重复读取,向用户态中拷贝一份也是方便各自程序区处理数据,形成隔离效果。
void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);
addr:指定映射的起始地址,通常设为NULL,由内核来分配
length:代表将文件中映射到内存的部分的长度。
prot:映射区域的保护方式。可以为以下几种方式的组合:
PROT_EXEC 映射区域可被执行
PROT_READ 映射区域可被读取
PROT_WRITE 映射区域可被写入
PROT_NONE 映射区域不能存取
flags:映射区的特性标志位,常用的两个选项是:
MAP_SHARD:写入映射区的数据会复制回文件,且运行其他映射文件的进程共享
MAP_PRIVATE:对映射区的写入操作会产生一个映射区的复制,对此区域的修改不会写会原文件
fd:要映射到内存中的文件描述符,有open函数打开文件时返回的值。
offset:文件映射的偏移量,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍。
函数返回值:实际分配的内存的起始地址。
与mmap函数成对使用的是munmap函数,它是用来解除映射的函数
int munmap(void *start, size_t length)
start:映射的起始地址
length:文件中映射到内存的部分的长度
返回值:解除成功返回0,失败返回-1。
package main
import (
"fmt"
"golang.org/x/exp/mmap"
)
func main() {
at, _ := mmap.Open("./tmp.txt")
defer at.Close()
buf := make([]byte, 1024)
at.ReadAt(buf, 0)
fmt.Println(string(buf))
}
mmap 除了减少一次数据拷贝外,还有一个优势在于,不同的虚拟内存地址可以指向同一个物理内存,这样多个进程之间就可以来共享这块数据。
2、sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile()。
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
该系统调用直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态。不管怎么说,文件内容是要先读到内核缓存中才能进行其他操作。
sendfile 的应用场景是:用户从磁盘读取一些文件数据后不需要经过任何计算与处理就通过网络传输出去。此场景的典型应用是消息队列。
3、sendfile + SG-DMA
linux2.4版本后,对sendfile做了优化升级,引入SG-DMA技术,其实就是对DMA拷贝加入了scatter-gather操作,它可以直接从内核空间缓冲区中将数据读取到网卡,这样的话还可以省去CPU拷贝。注意,SG-DMA技术只有网卡支持(通过命令ethtool -k eth0 | grep scatter-gather
查看)。
可以发现sendfile + DMA scatter/gather实现的零拷贝发生了2次上下文切换以及2次数据拷贝,这就是真正的零拷贝技术,全程没有通过CPU来搬运数据,所有的数据都是通过DMA进行传输的。
三、Golang实现零拷贝
1、mmap
package main
import (
"fmt"
"golang.org/x/exp/mmap"
)
func main() {
at, _ := mmap.Open("./tmp.txt")
defer at.Close()
buf := make([]byte, 1024)
at.ReadAt(buf, 0)
fmt.Println(string(buf))
}
2、sendfile
暂时没有找到Golang如何调用sendfile。