零拷贝原理以及实践
大家好,我是蓝胖子,零拷贝技术相信大家都有所耳闻,但是今天呢,我不仅会讲述零拷贝技术的原理,并将从实际代码出发,看看零拷贝技术在golang中的应用。现在让我们开始吧。
零拷贝原理
零拷贝技术的原理本质上就是减少数据的拷贝次数,因为当调用传统read write方法读取文件内容并返回给客户端的时候,会经过四次拷贝。我用golang代码举例如下
func main() {
http.HandleFunc("/tradition", func(writer http.ResponseWriter, request *http.Request) {
f, _ := os.Open("./testmmap.txt")
buf := make([]byte, 1024)
// 内核拷贝到buf
n, _ := f.Read(buf)
// buf拷贝到内核
writer.Write(buf[:n])
})
http.ListenAndServe(":8080", http.DefaultServeMux)
}
如上面代码所示,如果我们需要将本地testmmap.txt文件的内容读出来返回给客户端。
testmmap.txt里只有一个hello的单词,当服务启动以后访问接口便会返回hello。
(base) ➜ codelearning git:(master) ✗ cat testmmap.txt
hello
(base) ➜ codelearning git:(master) ✗ curl localhost:8080/tradition
hello
整个过程需要经过read和write两次系统调用,而每次read和write的调用将面临用户态和内核态缓冲区之间数据的拷贝。
整个拷贝过程如上图所示,磁盘和内核间的数据传递可以通过DMA技术让cpu不参与其中,但是内核态和用户态间的数据拷贝则需要经过cpu参与,涉及到了两次系统调用,和4次数据拷贝。
mmap+write
基于上述传统文件的访问方式,我们可以用mmap技术进行优化,mmap可以让用户缓冲区buf的地址和文件磁盘地址建立映射,这样访问用户缓冲区buf的数据就等效于访问磁盘文件上的数据。
用mmap优化后的文件访问代码如下:
http.HandleFunc("/mmap", func(writer http.ResponseWriter, request *http.Request) {
f, _ := os.Open("./testmmap.txt")
data, err := syscall.Mmap(int(f.Fd()), 0, 5, syscall.PROT_READ, syscall.MAP_SHARED)
if err != nil {
panic(err)
}
writer.Write(data)
})
可以看到mmap返回了一个data的字节数组,这个字节数组的内容就是映射了文件内容,之后将字节数组写入到响应体里。
syscall.Mmap(int(f.Fd()), 0, 5, syscall.PROT_READ, syscall.MAP_SHARED)
这里再解释下mmap涉及的参数含义:
其中第一个参数代表要映射的文件描述符。
接着是映射的范围是从0个字节到第5个字节。
第四个参数 代表映射的后的内存区域是只读的,类似的参数还有 syscall.PROT_WRITE表示内存区域可以被写入,syscall.PROT_NONE表示内存区域不可访问。
第五个参数表示 映射的内存区域可以被多个进程共享,这样一个进程修改了这个内存区域的数据,对其他进程是可见的,并且修改后的内容会自动被操作系统同步到磁盘文件里。
类似的参数还有syscall.MAP_PRIVATE表示内存区域是私有的,不可被其他进程访问,声明为私有后,每个进程拥有单独的一份内存映射拷贝,并且对此内存区域进行修改不会被同步到磁盘文件。
注意整个过程,我们是没有将文件内容读取到用户空间的任何缓冲区的。我们仅仅是在write系统调用时,告诉了内核一个地址(即字节数组的地址),而这个地址被mmap映射成了文件的地址。示意图如下:
整个过程是用户进程告诉内核需要拷贝的数据数据的地址,然后内核拷贝数据。
sendfile
基于上述mmap+write方式进行优化后的文件内容访问减少了一次拷贝过程,不过系统调用还是两次。如果用sendfile的话可以将系统调用减少到一次。
func Sendfile(outfd int, infd int, offset *int64, count int) (written int, err error)
Sendfile的系统调用可以将目的文件描述符和源文件描述符传递进去,剩下的拷贝过程就交给内核了。示意图如下:
但是sendfile对源文件描述符有要求,普通的文件可以,如果源文件描述符是socket则不能用sendfile了。
splice
splice系统调用则是为了解决源文件描述符和目的文件描述符都是socket的情况而产生的。splice系统调用的原理是通过管道让数据在源socket和目的socket之间进行传输。示意图如下:
splice的系统调用方法如下:
func Splice(rfd int, roff *int64, wfd int, woff *int64, len int, flags int) (n int64, err error)
注意splice系统调用需要保证传入的文件描述符,rfd或者wfd至少一个是管道的文件描述符。创建管道也是一个系统调用,如下:
func Pipe2(p []int, flags int) error
再回到通过splice系统调用的情况,可以看到要调用两次splice系统调用,才能完成socket间的数据传递,因为splice系统调用会根据源文件描述符或目的文件描述符是管道的情况做不同的动作。
第一次系统调用,目的文件描述符是管道,那么内核则会将管道和源文件描述符绑定在一起,注意此时是不会进行数据拷贝的。
第二次splice系统调用,源文件描述符是管道,那么内核才会将管道内的数据拷贝到目的文件描述符,由于在前一次,管道已经和源文件描述符进行了绑定,所以这次的splice系统调用,实际上会将源文件描述符的数据拷贝到目的文件描述符。
整个过程,抛开DMA技术拷贝的次数,一共只有一次数据拷贝的过程。
零拷贝在golang中的实践
讲完了零拷贝涉及的技术,我们来看看golang是如何运用这些技术的。拿一个比较常用的方法举例,io.Copy, 其底层调用了copyBuffer方法,copyBuffer会判断copy的目的接口Writer是否实现了ReaderFrom 接口,如果实现了则直接调用ReaderFrom 从src读取数据。
func Copy(dst Writer, src Reader) (written int64, err error) {
return copyBuffer(dst, src, nil)
}
func copyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
// If the reader has a WriteTo method, use it to do the copy.
// Avoids an allocation and a copy. if wt, ok := src.(WriterTo); ok {
return wt.WriteTo(dst)
}
// Similarly, if the writer has a ReadFrom method, use it to do the copy.
if rt, ok := dst.(ReaderFrom); ok {
return rt.ReadFrom(src)
}
// 进行传统的文件读取,代码较长,暂时省略了。
.......
return written, err
}
net.TcpConn实现了ReadFrom 接口,拿net.TcpConn举例,看看它的实现。
func (c *TCPConn) readFrom(r io.Reader) (int64, error) {
if n, err, handled := splice(c.fd, r); handled {
return n, err
}
if n, err, handled := sendFile(c.fd, r); handled {
return n, err
}
return genericReadFrom(c, r)
}
最终net.TcpConn 会调用readFrom方法从来源io.Reader读取数据,而readFrom读取数据用到的技术则是刚刚所讲的零拷贝技术,这里用到了splice和sendFile系统调用,如果来源io.Reader是一个tcp连接或者时unix 连接则会调用splice进行数据拷贝,否则就会调用sendFile进行数据拷贝,具体细节我就不在这里展开了。
总之,你可以看到,其实我们平时用到的方法就用到了零拷贝技术,这些经常说的底层原理离我们并不遥远,学习,永远怀着一颗谦卑的心。