零拷貝技術第一篇:綜述

零拷貝 (zero copy) 在一些語境下指代的意思有所不同, 本文講的零拷貝就是大家常說的,通過這個技術讓 CPU 釋放出來不去執行內存中數據拷貝的功能,或者避免不必要的拷貝,所以說零拷貝不是沒有數據的拷貝 (複製),而是廣義上講的減少和避免不必要的數據拷貝, 可以用來節省 CPU 使用和內帶寬等,比如通過網絡高速傳輸文件、實現網絡 proxy 等等,零拷技術可以極大的提高程序的性能。

本文總結零拷貝的各種技術,下一篇介紹常見的零拷貝技術在 Go 語言中的應用。

零拷貝技術

其實,零拷貝很久以來都被用在提升程序的性能上,比如 nginx、kafka 等,而且很多文章也詳細介紹了零拷貝就要解決的問題,我在這裏還是在總結一下,如果你已經瞭解了零拷貝的計數,不妨回顧一下。

我們來分析一個從網絡讀取文件的場景。服務器從磁盤讀取一個文件,並寫入到 socket 中返回給客戶端。我們看看服務端的數據拷貝情況:

程序開始使用系統調用 read[1] 告訴操作系統要從磁盤文件中讀取數據,它首先從用戶態切換到內核態,這個切換是有花費的,操作系統需要保存用戶態的狀態,一些寄存器的地址等,等 read 系統調用完成後返回,程序又需要從內核態切換到用戶態,把保存的用戶態的狀態恢復,所以一次系統調用需要兩次的用戶態 / 內核態的切換。同樣,把文件的內容寫入到 socket 的時候,程序調用 write[2] 系統調用,又進行了兩次用戶態 / 內核態的切換。

從操作的數據來看,這個數據還被拷貝了四次。在 read 系統調用的時候,DMA 方式從磁盤拷貝到內核緩衝區,又通過 CPU 拷貝從內核緩衝區拷貝到用戶的程序緩衝區,這裏發生了兩次拷貝。在寫入 socket 的時候,數據先從用戶程序緩衝區寫入到 socket 緩衝區,又通過 DMA 方式從 socket 緩衝區寫入到網卡。數據拷貝也發生了四次。

DMA(Direct Memory Access,直接存儲器訪問) 是計算機科學中的一種內存訪問技術。它允許某些電腦內部的硬件子系統(電腦外設),可以獨立地直接讀寫系統內存,允許不同速度的硬件設備來溝通,而不需要依於中央處理器的大量中斷負載。

你可以看到,傳統的 IO 讀寫方式,包括了四次用戶態 / 內核態的上下文切換,四次數據的拷貝,對性能的影響還是挺大的。廣義的零拷貝的技術,就是要儘量減少用戶態 / 內核態的上下文切換,以及數據的拷貝次數,爲此操作系統也提供了幾種方法。

mmap + write

通過 mmap 系統調用,將用戶空間的虛擬地址和內核空間的虛擬地址映射成同一個物理地址這樣可以減少內核空間和內核空間的數據拷貝。

通過 mmap 系統調用發起 IO 讀取,DMA 將磁盤數據寫入到內核緩衝區,此時 mmap 系統調用就返回了。程序調用 write 系統調用,CPU 將內核緩衝區的數據寫入到 socket 緩衝區,DMA 又將數據從 socket 緩衝區謝瑞到網卡。

可以看到,mmap+write 方式有兩次系統調用,發生四次用戶態 / 內核態的切換,三次數據拷貝。

相對傳統的 IO 方式,減少了一次數據拷貝,但是應該還有優化的空間。

sendfile

sendfile[3] 是 Linux2.1 內核版本後引入的一個系統調用函數, 用來優化數據傳輸。它可以在文件描述符之間傳遞數據,因爲都是在內核之間傳遞數據,所以非常高效。Linux 2.6.33 之前目的文件描述符必須是文件,以後的版本就沒有限制了,可以是任意的文件。

但是源文件描述符要求必須是支持 mmap[4] 操作的文件描述符,普通的文件可以,但是 socket 就不行了。所以 sendfile 適合從文件讀取數據寫 socket 場景,所以 sendfile 這個名字還是很貼切的,發送文件。

用戶調用 sendfile 系統調用,數據通過 DMA 拷貝到內核緩衝區,CPU 將數據從內核緩衝區再寫入到 socket 緩衝區,DMA 將 socket 緩衝區數據寫入到網卡,然後 sendfile 系統調用返回。

可以看到,這裏只有一次系統調用,也就是兩次用戶態 / 內核態的切換,三次數據拷貝。

相對來說,這種方式對性能已經有所提升。

linux 2.4 之後,又對 sendfile 做了優化,對於支持 dms scatter/gather 功能的網卡,只把關於數據的位置和長度的信息的描述符被追加到了 socket 緩衝區中。DMA 引擎直接把數據從內核緩衝區傳輸到網卡 (protocol engine),從而消除了僅有的一次 CPU 拷貝。

splice、tee、vmsplice

sendfile 性能雖好,但是還是有些場景下是不能使用的,比如我們想做一個 socket proxy, 源和目的都是 socket, 就不能直接使用 sendfile 了。這個時候我們可以考慮 splice[5]。

Linux 2.6.30 版本之前,源和目的只能有一個是管道 (pipe), 自 2.6.31 開始, 源和目的只要保證有一個是就行。

當然,如果我們處理的源和目的不是管道的話,我們可以先建立一個管道,這樣就可以使用 splice 系統調用來實現零拷貝了。

但是,如果每次都創建一個管道,你會發現每次都會多一次系統調用,也就是兩次用戶態 / 內核態的切換,所以你如果頻繁的拷貝數據,那麼可以建立一個管道池就像潘建給 Go 的標準庫提供的一個補丁一樣,利用 pipe pool 對 Go 語言中的 splice 做了優化。

tee 系統調用用來在兩個管道中拷貝數據。vmsplice 系統調用 pipe 指向的內核緩衝區和用戶程序的緩衝區之間的數據拷貝。

MSG_ZEROCOPY

Linux v4.14 版本接受了在 TCP send 系統調用中實現的支持零拷貝 (MSG_ZEROCOPY[6]) 的 patch,通過這個 patch,用戶進程就能夠把用戶緩衝區的數據通過零拷貝的方式經過內核空間發送到網絡套接字中去,在 5.0 中支持 UDP。Willem de Bruijn 在他的論文裏給出的壓測數據是:採用 netperf 大包發送測試,性能提升 39%,而線上環境的數據發送性能則提升了 5%~8%,官方文檔陳述說這個特性通常只在發送 10KB 左右大包的場景下才會有顯著的性能提升。一開始這個特性只支持 TCP,到內核 v5.0 版本之後才支持 UDP。這裏也有一篇官方文檔介紹:Zero-copy networking[7]

首先你需要設置 socket 選項:

if (setsockopt(fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one)))
        error(1, errno, "setsockopt zerocopy");

然後調用 send 系統調用是傳入MSG_ZEROCOPY參數:

ret = send(fd, buf, sizeof(buf), MSG_ZEROCOPY);

這裏我們傳入了 buf, 但是啥時候 buf 可以重用呢?這個內核會通知程序進程。它將完成通知放在 socket error 隊列中,所以你需要讀取這個隊列,知道拷貝啥時候完成 buf 可釋放或者重用了:

pfd.fd = fd;
pfd.events = 0;
if (poll(&pfd, 1, -1) != 1 || pfd.revents & POLLERR == 0)
        error(1, errno, "poll");

ret = recvmsg(fd, &msg, MSG_ERRQUEUE);
if (ret == -1)
        error(1, errno, "recvmsg");

read_notification(msg);

因爲它可能異步發送數據,你需要檢查 buf 啥時候釋放,增加代碼複雜度,以及會導致多次用戶態和內核態的上下文切換;

Linux 4.18 中也支持的 receive MSG_ZEROCOPY 機制 (Zero-copy TCP receive[8]).

字節跳動的同學 2021 年 10 曾寫過文章,通過修改內核的方式兼容先前的 send 調用方式。這畢竟是特殊的優化,不適合大衆的使用方式,所以這個零拷貝的方式還是隻在一些特殊的場景下進行優化:

字節跳動框架組和字節跳動內核組合作,由內核組提供了同步的接口:當調用 sendmsg 的時候,內核會監聽並攔截內核原先給業務的回調,並且在回調完成後纔會讓 sendmsg 返回。這使得我們無需更改原有模型,可以很方便地接入 ZeroCopy send。同時,字節跳動內核組還實現了基於 unix domain socket 的 ZeroCopy,可以使得業務進程與 Mesh sidecar 之間的通信也達到零拷貝。

字節跳動在 Go 網絡庫上的實踐 [9]

copy_file_range

Linux 4.5 增加了一個新的 API: copy_file_range[10], 它在內核態進行文件的拷貝,不再切換用戶空間,所以會比 cp 少塊一些,在一些場景下會提升性能。

其它

AF_XDP[11] 是 Linux 4.18 新增加的功能,以前稱爲 AF_PACKETv4(從未包含在主線內核中),是一個針對高性能數據包處理優化的原始套接字,並允許內核和應用程序之間的零拷貝。由於套接字可用於接收和發送,因此它僅支持用戶空間中的高性能網絡應用。

當然零拷貝技術和數據拷貝的優化一直是大家追求性能優化的方式之一,相關技術也在不斷研究之中,歡迎在原文的評論中寫出你的看法。

參考文章 以下文章是我整理的關於零拷貝技術一部分文章,如果你想深入瞭解零拷貝技術,可以閱讀這些更多的文章。

  1. https://www.zhihu.com/question/35093238?utm_id=0

  2. https://strikefreedom.top/archives/pipe-pool-for-splice-in-go

  3. https://www.modb.pro/db/212924

  4. https://blog.lpflpf.cn/passages/golang-zerocopy/

  5. https://medium.com/swlh/linux-zero-copy-using-sendfile-75d2eb56b39b

  6. https://www.cloudwego.io/zh/blog/2021/10/09/%E5%AD%97%E8%8A%82%E8%B7%B3%E5%8A%A8%E5%9C%A8-go-%E7%BD%91%E7%BB%9C%E5%BA%93%E4%B8%8A%E7%9A%84%E5%AE%9E%E8%B7%B5/#zerocopy

  7. https://zhuanlan.zhihu.com/p/360343446

  8. https://blog.devgenius.io/linux-zero-copy-d61d712813fe

  9. https://www.kernel.org/doc/html/v4.18/networking/msg_zerocopy.html

  10. https://lwn.net/Articles/879724/

  11. https://www.phoronix.com/news/Linux-5.20-IO_uring-ZC-Send

  12. https://en.wikipedia.org/wiki/Zero-copy

  13. https://aijishu.com/a/1060000000149804

  14. https://github.com/golang/go/issues/48530

  15. https://juejin.cn/post/6863264864140935175

  16. https://www.linuxjournal.com/article/6345

  17. https://jishuin.proginn.com/p/763bfbd47570

參考資料

[1]

read: https://man7.org/linux/man-pages/man2/read.2.html

[2]

write: https://man7.org/linux/man-pages/man2/write.2.html

[3]

sendfile: https://man7.org/linux/man-pages/man2/sendfile.2.html

[4]

mmap: https://man7.org/linux/man-pages/man2/mmap.2.html

[5]

splice: https://man7.org/linux/man-pages/man2/splice.2.html

[6]

MSG_ZEROCOPY: https://www.kernel.org/doc/html/v4.17/networking/msg_zerocopy.html

[7]

Zero-copy networking: https://lwn.net/Articles/726917/

[8]

Zero-copy TCP receive: https://lwn.net/Articles/752188/

[9]

字節跳動在 Go 網絡庫上的實踐: https://www.cloudwego.io/zh/blog/2021/10/09/%E5%AD%97%E8%8A%82%E8%B7%B3%E5%8A%A8%E5%9C%A8-go-%E7%BD%91%E7%BB%9C%E5%BA%93%E4%B8%8A%E7%9A%84%E5%AE%9E%E8%B7%B5/

[10]

copy_file_range: https://man7.org/linux/man-pages/man2/copy_file_range.2.html

[11]

AF_XDP: https://lwn.net/Articles/750845/

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