书接上回:零拷贝技术第一篇:综述 , 我们留了一个小尾巴,还没有介绍Go语言中零拷贝技术的应用,那么本文将带你了解Go标准库中零拷贝技术。
Go标准库中的零拷贝 在Go标准库中,也广泛使用了零拷贝技术来提高性能。因为零拷贝相关的技术很多都是通过系统调用提供的,所以在Go标准库中,也封装了这些系统调用,相关封装的代码可以在internal/poll 找到。
我们以Linux为例,毕竟我们大部分的业务都是在Linux运行的。
sendfile 在internal/poll/sendfile_linux.go文件中,封装了sendfile系统调用,我删除了一部分的代码,这样更容易看到它是如何封装的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 / SendFile wraps the sendfile system call. func SendFile (dstFD *FD, src int , remain int64 ) (int64 , error ) { ...... dst := dstFD.Sysfd var written int64 var err error for remain > 0 { n := maxSendfileSize if int64 (n) > remain { n = int (remain) } n, err1 := syscall.Sendfile(dst, src, nil , n) if n > 0 { written += int64 (n) remain -= int64 (n) } else if n == 0 && err1 == nil { break } ...... } return written, err }
可以看到SendFile调用senfile批量写入数据。sendfile系统调用一次最多会传输 0x7ffff00(2147479552) 字节的数据。这里Go语言设置maxSendfileSize为 0<<20 (4194304)字节。
net/sendfile_linux.go文件中会使用到它:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 func sendFile (c *netFD, r io.Reader) (written int64 , err error , handled bool ) { var remain int64 = 1 << 62 lr, ok := r.(*io.LimitedReader) ...... f, ok := r.(*os.File) if !ok { return 0 , nil , false } sc, err := f.SyscallConn() if err != nil { return 0 , nil , false } var werr error err = sc.Read(func (fd uintptr ) bool { written, werr = poll.SendFile(&c.pfd, int (fd), remain) return true }) if err == nil { err = werr } if lr != nil { lr.N = remain - written } return written, wrapSyscallError("sendfile" , err), written > 0 }
这个函数谁又会调用呢?是TCPConn 。
1 2 3 4 5 6 7 8 9 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) }
这个方法又会被ReadFrom方法封装。 记住这个ReadFrom 方法,我们待会再说。
1 2 3 4 5 6 7 8 9 10 func (c *TCPConn) ReadFrom(r io.Reader) (int64 , error ) { if !c.ok() { return 0 , syscall.EINVAL } n, err := c.readFrom(r) if err != nil && err != io.EOF { err = &OpError{Op: "readfrom" , Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err} } return n, err }
TCPConn.readFrom方法实现很有意思。它首先检查是否满足使用splice系统调用进行零拷贝优化,在目的是TCP connection, 源是TCP或者是Unix connection才能调用splice。 否则才尝试使用sendfile。如果要使用sendfile优化,也有限制,要求源是*os.File文件。 再否则使用不同的拷贝方式。
ReadFrom又会在什么情况下被调用?实际上你经常会用到,io.Copy就会调用ReadFrom。也许在不经意之间,当你在将文件写入到socket过程中,就不经意使用到了零拷贝。当然这不是唯一的调用和被使用的方式。
如果我们看一个调用链,就会把脉络弄清楚:io.Copy -> *TCPConn.ReadFrom -> *TCPConn.readFrom -> net.sendFile -> poll.sendFile。
splice 上面你也看到了,*TCPConn.readFrom初始就是尝试使用splice,使用的场景和限制也提到了。net.splice函数其实是调用poll.Splice:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 func Splice (dst, src *FD, remain int64 ) (written int64 , handled bool , sc string , err error ) { p, sc, err := getPipe() if err != nil { return 0 , false , sc, err } defer putPipe(p) var inPipe, n int for err == nil && remain > 0 { max := maxSpliceSize if int64 (max) > remain { max = int (remain) } inPipe, err = spliceDrain(p.wfd, src, max) handled = handled || (err != syscall.EINVAL) if err != nil || inPipe == 0 { break } p.data += inPipe n, err = splicePump(dst, p.rfd, inPipe) if n > 0 { written += int64 (n) remain -= int64 (n) p.data -= n } } if err != nil { return written, handled, "splice" , err } return written, true , "" , nil }
在上一篇中讲到pipe如果每次都创建其实挺损耗性能的,所以这里使用了pip pool,也提到是潘少优化的。
所以你看到,不经意间你就会用到splice或者sendfile。
CopyFileRange copy_file_range_linux.go 封装了copy_file_range系统调用。因为这个系统调用非常的新,所以封装的时候首先要检查Linux的版本,看看是否支持此系统调用。 版本检查和调用批量拷贝的代码我们略过,具体看是怎么使用这个系统调用的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 func copyFileRange (dst, src *FD, max int ) (written int64 , err error ) { if err := dst.writeLock(); err != nil { return 0 , err } defer dst.writeUnlock() if err := src.readLock(); err != nil { return 0 , err } defer src.readUnlock() var n int for { n, err = unix.CopyFileRange(src.Sysfd, nil , dst.Sysfd, nil , max, 0 ) if err != syscall.EINTR { break } } return int64 (n), err }
哪里会使用到它呢?of.File的读取数据的时候:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 var pollCopyFileRange = poll.CopyFileRangefunc (f *File) readFrom(r io.Reader) (written int64 , handled bool , err error ) { if f.appendMode { return 0 , false , nil } remain := int64 (1 << 62 ) lr, ok := r.(*io.LimitedReader) if ok { remain, r = lr.N, lr.R if remain <= 0 { return 0 , true , nil } } src, ok := r.(*File) if !ok { return 0 , false , nil } if src.checkValid("ReadFrom" ) != nil { return 0 , false , nil } written, handled, err = pollCopyFileRange(&f.pfd, &src.pfd, remain) if lr != nil { lr.N -= written } return written, handled, NewSyscallError("copy_file_range" , err) }
同样的是*FIle.ReadFrom调用:
1 2 3 4 5 6 7 8 9 10 11 func (f *File) ReadFrom(r io.Reader) (n int64 , err error ) { if err := f.checkValid("write" ); err != nil { return 0 , err } n, handled, e := f.readFrom(r) if !handled { return genericReadFrom(f, r) } return n, f.wrapErr("write" , e) }
所以这个优化用在文件的拷贝中,一般的调用链路是 io.Copy -> *File.ReadFrom -> *File.readFrom -> poll.CopyFileRange -> poll.copyFileRange
标准库零拷贝的应用 Go标准库将零拷贝技术在底层做了封装,所以很多时候你是不知道的。比如你实现了一个简单的文件服务器:
main 1 2 3 4 5 6 7 8 9 import "net/http" func main() { // 绑定一个handler http.Handle("/", http.StripPrefix("/static/", http.FileServer(http.Dir("../root.img")))) // 监听服务 http.ListenAndServe(":8972", nil) }
调用链如左:http.FileServer -> *fileHandler.ServeHTTP -> http.serveFile -> http.serveContent -> io.CopyN -> io.Copy -> 和sendFile的调用链接上了。 可以看到访问文件的时候是调用了sendFile。
第三方库 有几个库提供了sendFile/splice的封装。
因为直接调用系统调用很方便,所以很多时候我们可以模仿标准库实现我们自己零拷贝的方法。 所以个人感觉这些传统的方式没有太多锦上添花的东西可做了,要做的就是新的零拷贝系统接口的封装或者自定义开发。