零拷貝原理以及實踐

大家好,我是藍胖子,零拷貝技術相信大家都有所耳聞,但是今天呢,我不僅會講述零拷貝技術的原理,並將從實際代碼出發,看看零拷貝技術在 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([]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 進行數據拷貝,具體細節我就不在這裏展開了。

總之,你可以看到,其實我們平時用到的方法就用到了零拷貝技術,這些經常說的底層原理離我們並不遙遠,學習,永遠懷着一顆謙卑的心。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/POz7jox0fFMBRf4Q78bNbA