Go 語言中的零拷貝

傳統讀寫模式

傳統讀寫模式流程圖

  1. 第一次數據拷貝: 用戶進程發起 read() 系統調用,當前上下文從用戶態切換至內核態,DMA(Direct Memory Access) 引擎從文件中讀取數據,並存儲到內核態緩衝區 (DMA 拷貝)

  2. 第二次數據拷貝: 將數據從內核態緩衝區拷貝到用戶態緩衝區 (CPU 拷貝),然後返回給用戶進程,拷貝數據時會發生一次上下文切換 (從內核態切換到用戶態)

  3. 第三次數據拷貝: 用戶進程發起 write() 系統調用,當前上下文從用戶態切換至內核態,數據從用戶態緩衝區被拷貝到 Socket 緩衝區 (CPU 拷貝)

  4. 第四次數據拷貝: write() 系統調用結束返回到用戶進程,當前上下文從內核態切換至用戶態,第四次數據拷貝爲異步執行,從 Socket 緩衝區拷貝到網卡 (DMA 拷貝)

transferTo

transferTo() 和 send() 類似,也是一個系統調用,用於在文件之間高效地傳輸數據。

transferTo 在操作系統層面實現了零拷貝技術,允許將數據直接從一個文件傳輸到另一個文件,而無需通過用戶空間進行中轉。

transferTo 流程圖

  1. 第一次數據拷貝: 用戶進程發起 transferTo() 調用,將文件數據拷貝到一個 Read buffer(內核態)中,當前上下文從用戶態切換至內核態

  2. 第二次數據拷貝: 內核將 Read buffer 中的數據拷貝到 Socket 緩衝區

  3. 第三次數據拷貝: 數據從 Socket 緩衝區拷貝到網卡,當前上下文從內核態切換至用戶態

相比較於傳統的讀寫模式, transferTo 把上下文的切換次數從 4 次減少到 2 次,同時把數據拷貝的次數從 4 次降低到了 3 次, 雖然已經前進了一大步,但是作爲過渡階段,transferTo 距離零拷貝還有一些距離。

零拷貝

零拷貝是相對於用戶態來講的,數據在用戶態不發生任何拷貝。

sendfile + DMA

sendfile() 是作用於兩個文件描述符之間的數據拷貝的系統調用,這個拷貝操作是直接在內核中進行的,沒有用戶態到內核態的數據拷貝和上下文切換帶來的開銷,所以稱爲零拷貝技術。

Linux2.4 內核對 sendfile 系統調用做了改進:

sendfile 改進

  1. 用戶進程發起 sendfile() 系統調用,當前上下文從用戶態切換至內核態,DMA 將數據拷貝到內核緩衝區

  2. 向 Socket 緩衝區中發送當前數據在內核緩衝區的地址和偏移量兩個值

  3. 根據 Socket 緩衝區的地址和偏移量,直接將內核緩衝區的數據拷貝到網卡,當前上下文從內核態切換至用戶態

零拷貝流程圖

相比較於傳統的讀寫模式, sendfile + DMA 把上下文的切換次數從 4 次減少到 2 次,同時把數據拷貝的次數從 4 次降低到了 2 次 (2 次均爲 DMA 拷貝),完全消除了數據從用戶態和內核態之間拷貝數據帶來的開銷。

sendfile + DMA 雖然已經足夠高效,但是依然存在兩個不足之處:

  1. 方案本身需要引入新的硬件支持

  2. 輸入文件描述符僅支持文件類型

splice

針對 sendfile + DMA 方案存在的不足,Linux 引入了 splice() 系統調用, splice() 不需要硬件支持,能夠實現在任意的兩個文件描述符時之間傳輸數據。

splice() 是基於管道緩衝區機制實現的,所以兩個參數文件描述符必須有一個是管道設備。在實際開發中,splice() 作爲實現零拷貝的首選,因此 sendfile() 的內部實現也替換爲了 splice()。

Go 語言中的零拷貝

現在有了前文的理論基礎後,我們來看下在 Go 語言中標準庫的零拷貝方法原型和應用方法,筆者的 Go 版本爲 go1.19 linux/amd64

sendfile

sendfile 的方法原型爲 syscall.Sendfile,文件路徑爲 syscall/syscall_unix.go。

func Sendfile(outfd int, infd int, offset *int64, count int) (written int, err error)

一個簡單的使用示例:

package main

import (
 "fmt"
 "os"
 "syscall"
)

func main() {
 // 設置源文件
 src, err := os.Open("/tmp/source.txt")
 if err != nil {
  panic(err)
 }
 defer src.Close()

 // 設置目標文件
 target, err := os.Create("/tmp/target.txt")
 if err != nil {
  panic(err)
 }
 defer target.Close()

 // 獲取源文件的文件描述符
 srcFd := int(src.Fd())

 // 獲取目標文件的文件描述符
 targetFd := int(target.Fd())

 // 使用 Sendfile 實現零拷貝 (拷貝 10 個字節)
 // 如果因爲字符編碼導致的字符截斷問題 (如中文亂碼問題), 結果自動保留到截斷前的最後完整字節
 // 例如文件內容爲 “星期三四五六七”,count 參數爲 4, 那麼只會拷貝第一個字 (一個漢字 3 個字節)
 // 但是需要注意的是,方法的返回值 written 不受影響 (和 count 參數保持一致)
 // 所以實際開發中,第三個參數 offset 必須設置正確,否則就可能引起亂碼或數據丟失問題
 n, err := syscall.Sendfile(targetFd, srcFd, nil, 4)
 if err != nil {
  fmt.Println(err)
  return
 }

 fmt.Printf("寫入字節數: %d", n)
}

splice

splice 的方法原型爲 syscall.Splice,文件路徑爲 syscall/zsyscall_linux_amd64.go。

func Splice(rfd int, roff *int64, wfd int, woff *int64, len int, flags int) (n int64, err error)

一個簡單的使用示例:

package main

import (
 "fmt"
 "os"
 "syscall"
)

func main() {
 // 設置源文件
 src, err := os.Open("/tmp/source.txt")
 if err != nil {
  panic(err)
 }
 defer src.Close()

 // 設置目標文件
 target, err := os.Create("/tmp/target.txt")
 if err != nil {
  panic(err)
 }
 defer target.Close()

 // 創建管道文件
 // 作爲兩個文件傳輸數據的中介
 pipeReader, pipeWriter, err := os.Pipe()
 if err != nil {
  panic(err)
 }
 defer pipeReader.Close()
 defer pipeWriter.Close()

 // 設置文件讀寫模式
 // 筆者在標準庫中沒有找到對應的常量說明
 // 讀者可以參考這個文檔:
 //   https://pkg.go.dev/golang.org/x/sys/unix#pkg-constants
 //   SPLICE_F_NONBLOCK = 0x2
 spliceNonBlock := 0x02

 // 使用 Splice 將數據從源文件描述符移動到管道 writer
 _, err = syscall.Splice(int(src.Fd()), nil, int(pipeWriter.Fd()), nil, 1024, spliceNonBlock)

 if err != nil {
  panic(err)
 }

 // 使用 Splice 將數據從管道 reader 移動到目標文件描述符
 n, err := syscall.Splice(int(pipeReader.Fd()), nil, int(target.Fd()), nil, 1024, spliceNonBlock)
 if err != nil {
  panic(err)
 }

 fmt.Printf("寫入字節數: %d", n)
}

擴展閱讀

鏈接

[1]

Why Kafka Is so Fast: https://medium.com/swlh/why-kafka-is-so-fast-bde0d987cd03

[2]

Efficient data transfer through zero copy: https://developer.ibm.com/articles/j-zerocopy/

[3]

Optimizing Large File Transfers in Linux with Go — An Exploration of TCP and Syscall: https://itnext.io/optimizing-large-file-transfers-in-linux-with-go-an-exploration-of-tcp-and-syscall-ebe1b93fb72f

[4]

directio: https://github.com/ncw/directio

[5]

Go 語言中的零拷貝優化: https://strikefreedom.top/archives/pipe-pool-for-splice-in-go

[6]

Linux I/O 原理和 Zero-copy 技術全面揭祕: https://strikefreedom.top/archives/linux-io-and-zero-copy#toc-head-15

[7]

零拷貝技術第一篇:綜述: https://colobu.com/2022/11/19/zero-copy-and-how-to-use-it-in-go/

[8]

direct io: https://github.com/cch123/golang-notes/blob/master/io.md

[9]

sys/unix: https://pkg.go.dev/golang.org/x/sys/unix#Splice

[10]

sendfile: https://github.com/hslam/sendfile

[11]

splice: https://github.com/hslam/splice

[12]

zerocopy: https://github.com/acln0/zerocopy

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