Go 語言中的零拷貝優化

博客原文

Go 語言中的零拷貝優化html

導言

相信那些曾經使用 Go 寫過 proxy server 的同窗應該對 io.Copy()/io.CopyN()/io.CopyBuffer()/io.ReaderFrom 等接口和方法不陌生,它們是使用 Go 操做各種 I/O 進行數據傳輸常常須要使用到的 API,其中基於 TCP 協議的 socket 在使用上述接口和方法進行數據傳輸時利用到了 Linux 的零拷貝技術 sendfilesplicelinux

我前段時間爲 Go 語言內部的 Linux splice 零拷貝技術作了一點優化:爲 splice 系統調用實現了一個 pipe pool,複用管道,減小頻繁建立和銷燬 pipe buffers 所帶來的系統開銷,理論上來講可以大幅提高 Go 的 io 標準庫中基於 splice 零拷貝實現的 API 的性能。所以,我想從這個優化工做出發,分享一些我我的對多線程編程中的一些不成熟的優化思路。git

因本人才疏學淺,故行文之間恐有紕漏,望諸君海涵,不吝賜教,若能予以斧正,則感激涕零github

splice

縱觀 Linux 的零拷貝技術,相較於mmapsendfileMSG_ZEROCOPY 等其餘技術,splice 從使用成本、性能和適用範圍等維度綜合來看更適合在程序中做爲一種通用的零拷貝方式。golang

splice() 系統調用函數定義以下:算法

#include <fcntl.h>
#include <unistd.h>

int pipe(int pipefd[2]);
int pipe2(int pipefd[2], int flags);

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

fd_in 和 fd_out 也是分別表明了輸入端和輸出端的文件描述符,這兩個文件描述符必須有一個是指向管道設備的,這算是一個不太友好的限制。編程

off_in 和 off_out 則分別是 fd_in 和 fd_out 的偏移量指針,指示內核從哪裏讀取和寫入數據,len 則指示了這次調用但願傳輸的字節數,最後的 flags 是系統調用的標記選項位掩碼,用來設置系統調用的行爲屬性的,由如下 0 個或者多個值經過『或』操做組合而成:數組

  • SPLICE_F_MOVE:指示 splice() 嘗試僅僅是移動內存頁面而不是複製,設置了這個值不表明就必定不會複製內存頁面,複製仍是移動取決於內核可否從管道中移動內存頁面,或者管道中的內存頁面是不是完整的;這個標記的初始實現有不少 bug,因此從 Linux 2.6.21 版本開始就已經無效了,但仍是保留了下來,由於在將來的版本里可能會從新被實現。
  • SPLICE_F_NONBLOCK:指示 splice() 不要阻塞 I/O,也就是使得 splice() 調用成爲一個非阻塞調用,能夠用來實現異步數據傳輸,不過須要注意的是,數據傳輸的兩個文件描述符也最好是預先經過 O_NONBLOCK 標記成非阻塞 I/O,否則 splice() 調用仍是有可能被阻塞。
  • SPLICE_F_MORE:通知內核下一個 splice() 系統調用將會有更多的數據傳輸過來,這個標記對於輸出端是 socket 的場景很是有用。

splice() 是基於 Linux 的管道緩衝區 (pipe buffer) 機制實現的,因此 splice() 的兩個入參文件描述符纔要求必須有一個是管道設備,一個典型的 splice() 用法是:緩存

int pfd[2];

pipe(pfd);

ssize_t bytes = splice(file_fd, NULL, pfd[1], NULL, 4096, SPLICE_F_MOVE);
assert(bytes != -1);

bytes = splice(pfd[0], NULL, socket_fd, NULL, bytes, SPLICE_F_MOVE | SPLICE_F_MORE);
assert(bytes != -1);

數據傳輸過程圖:bash

使用 splice() 完成一次磁盤文件到網卡的讀寫過程以下:

  1. 用戶進程調用 pipe(),從用戶態陷入內核態,建立匿名單向管道,pipe() 返回,上下文從內核態切換回用戶態;
  2. 用戶進程調用 splice(),從用戶態陷入內核態;
  3. DMA 控制器將數據從硬盤拷貝到內核緩衝區,從管道的寫入端"拷貝"進管道,splice() 返回,上下文從內核態回到用戶態;
  4. 用戶進程再次調用 splice(),從用戶態陷入內核態;
  5. 內核把數據從管道的讀取端"拷貝"到套接字緩衝區,DMA 控制器將數據從套接字緩衝區拷貝到網卡;
  6. splice() 返回,上下文從內核態切換回用戶態。

上面是 splice 的基本工做流程和原理,簡單來講就是在數據傳輸過程當中傳遞內存頁指針而非實際數據來實現零拷貝,若是有意瞭解其更底層的實現原理請移步:《Linux I/O 原理和 Zero-copy 技術全面揭祕》

pipe pool for splice

pipe pool in HAProxy

從上面對 splice 的介紹可知,經過它實現數據零拷貝須要利用到一個媒介 -- pipe 管道(2005 年由 Linus 引入),大概是由於在 Linux 的 IPC 機制中對 pipe 的應用已經比較成熟,因而藉助了 pipe 來實現 splice,雖然 Linux Kernel 團隊曾在 splice 誕生之初便說過在將來能夠移除掉 pipe 這個限制,但十幾年過去了也依然沒有付諸實施,所以 splice 至今仍是和 pipe 死死綁定在一塊兒。

那麼問題就來了,若是僅僅是使用 splice 進行單次的大批量數據傳輸,則建立和銷燬 pipe 開銷幾乎能夠忽略不計,可是若是是須要頻繁地使用 splice 來進行數據傳輸,好比須要處理大量網絡 sockets 的數據轉發的場景,則 pipe 的建立和銷燬的頻次也會隨之水漲船高,每調用一次 splice 都建立一對 pipe 管道描述符,並在隨後銷燬掉,對一個網絡系統來講是一個巨大的消耗。

對於這問題的解決方案,天然而然就會想到 -- 『複用』,好比大名鼎鼎的 HAProxy。

HAProxy 是一個使用 C 語言編寫的自由及開放源代碼軟件,其提供高可用性、負載均衡,以及基於 TCP 和 HTTP 的應用程序代理。它很是適用於那些有着極高網絡流量的 Web 站點。GitHub、Bitbucket、Stack Overflow、Reddit、Tumblr、Twitter 和 Tuenti 在內的知名網站,及亞馬遜網絡服務系統都在使用 HAProxy。

由於須要作流量轉發,可想而知,HAProxy 不可避免地要高頻地使用 splice,所以對 splice 帶來的建立和銷燬 pipe buffers 的開銷沒法忍受,從而須要實現一個 pipe pool,複用 pipe buffers 減小系統調用消耗,下面咱們來詳細剖析一下 HAProxy 的 pipe pool 的設計思路。

首先咱們來本身思考一下,一個最簡單的 pipe pool 應該如何實現,最直接且簡單的實現無疑就是:一個單鏈表+一個互斥鎖。鏈表和數組是用來實現 pool 的最簡單的數據結構,數組由於數據在內存分配上的連續性,可以更好地利用 CPU 高速緩存加速訪問,可是首先,對於運行在某個 CPU 上的線程來講,一次只須要取一個 pipe buffer 使用,因此高速緩存在這裏的做用並不十分明顯;其次,數組不只是連續並且是固定大小的內存區,須要預先分配好固定大小的內存,並且還要動態伸縮這個內存區,期間須要對數據進行搬遷等操做,增長額外的管理成本。鏈表則是更加適合的選擇,由於做爲 pool 來講其中全部的資源都是等價的,並不須要隨機訪問去獲取其中某個特定的資源,並且鏈表自然是動態伸縮的,隨取隨棄。

鎖一般使用 mutex,在 Linux 上的早期實現是一種徹底基於內核態的 sleep-waiting 也就是休眠等待的鎖,kernel 維護一個對全部進程/線程均可見的共享資源對象 mutex,多線程/進程的加鎖解鎖其實就是對這個對象的競爭。若是如今有 AB 兩個進程/線程,A 首先進入 kernel space 檢查 mutex,看看有沒有別的進程/線程正在佔用它,搶佔 mutex 成功以後則直接進入臨界區,B 嘗試進入臨界區的時候,檢測到 mutex 已被佔用,就由運行態切換成睡眠態,等待該共享對象釋放,A 出臨界區的時候,須要再次進入 kernel space 查看有沒有別的進程/線程在等待進入臨界區,而後 kernel 會喚醒等待的進程/線程並在合適的時間把 CPU 切換給該進程/線程運行。因爲最初的 mutex 是一種徹底內核態的互斥量實現,在併發量大的狀況下會產生大量的系統調用和上下文切換的開銷,在 Linux kernel 2.6.x 以後都是使用 futex (Fast Userspace Mutexes) 實現,也便是一種用戶態和內核態混用的實現,經過在用戶態共享一段內存,並利用原子操做讀取和修改信號量,在沒有競爭的時候只需檢查這個用戶態的信號量而無需陷入內核,信號量存儲在進程內的私有內存則是線程鎖,存儲在經過 mmap 或者 shmat 建立的共享內存中則是進程鎖。

即使是基於 futex 的互斥鎖,若是是一個全局的鎖,這種最簡單的 pool + mutex 實如今競爭激烈的場景下會有可預見的性能瓶頸,所以須要進一步的優化,優化手段無非兩個:下降鎖的粒度或者減小搶(全局)鎖的頻次。由於 pipe pool 中的資源原本就是全局共享的,也就是沒法對鎖的粒度進行降級,所以只能是儘可能減小多線程搶鎖的頻次,而這種優化經常使用方案就是在全局資源池以外引入本地資源池,對多線程訪問資源的操做進行錯開。

至於鎖自己的優化,因爲 mutex 是一種休眠等待鎖,即使是基於 futex 優化以後在鎖競爭時依然須要涉及內核態開銷,此時能夠考慮使用自旋鎖(Spin Lock),也便是用戶態的鎖,共享資源對象存在用戶進程的內存中,避免在鎖競爭的時候陷入到內核態等待,自旋鎖比較適合臨界區極小的場景,而 pipe pool 的臨界區裏只是對鏈表的增刪操做,很是匹配。

HAProxy 實現的 pipe pool 就是依據上述的思路進行設計的,將單一的全局資源池拆分紅全局資源池+本地資源池。

全局資源池利用單鏈表和自旋鎖實現,本地資源池則是基於線程私有存儲(Thread Local Storage, TLS)實現,TLS 是一種線程的私有的變量,它的主要做用是在多線程編程中避免鎖競爭的開銷。TLS 由編譯器提供支持,咱們知道編譯 C 程序獲得的 obj 或者連接獲得的 exe,其中的 .text 段保存代碼文本,.data 段保存已初始化的全局變量和已初始化的靜態變量,.bss 段則保存未初始化的全局變量和未初始化的局部靜態變量。

TLS 私有變量則會存入 TLS 幀,也就是 .tdata.tboss 段,與.data.bss 不一樣的是,運行時程序不會直接訪問這些段,而是在程序啓動後,動態連接器會對這兩個段進行動態初始化 (若是有聲明 TLS 的話),以後這兩個段不會再改變,而是做爲 TLS 的初始鏡像保存起來。每次啓動一個新線程的時候都會將 TLS 塊做爲線程堆棧的一部分進行分配並將初始的 TLS 鏡像拷貝過來,也就是說最終每一個線程啓動時 TLS 塊中的內容都是同樣的。

HAProxy 的 pipe pool 實現原理:

  1. 聲明 thread_local 修飾的一個單鏈表,節點是 pipe buffer 的兩個管道描述符,那麼每一個須要使用 pipe buffer 的線程都會初始化一個基於 TLS 的單鏈表,用以存儲 pipe buffers;
  2. 設置一個全局的 pipe pool,使用自旋鎖保護。

每一個線程去取 pipe 的時候會先從本身的 TLS 中去嘗試獲取,獲取不到則加鎖進入全局 pipe pool 去找;使用 pipe buffer 事後將其放回:先嚐試放回 TLS,根據必定的策略計算當前 TLS 的本地 pipe pool 鏈表中的節點是否已通過多,是的話則放到全局的 pipe pool 中去,不然直接放回本地 pipe pool。

HAProxy 的 pipe pool 實現雖然只有短短的 100 多行代碼,可是其中蘊含的設計思想卻包含了許多很是經典的多線程優化思路,值得細讀。

pipe pool in Go

受到 HAProxy 的 pipe pool 的啓發,我嘗試爲 Golang 的 io 標準庫裏底層的 splice 實現了一個 pipe pool,不過熟悉 Go 的同窗應該知道 Go 有一個 GMP 併發調度器,提供了強大併發調度能力的同時也屏蔽了操做系統層級的線程,因此 Go 沒有提供相似 TLS 的機制,卻是有一些開源的第三方庫提供了相似的功能,好比 gls,雖然實現很精巧,但畢竟不是官方標準庫並且會直接操做底層堆棧,因此其實也並不推薦在線上使用。

一開始,由於 Go 缺少 TLS 機制,因此我提交的初版 go pipe pool 就是一個很簡陋的單鏈表+全局互斥鎖的實現,由於這個方案在進程的生命週期中並不會去釋放資源池裏的 pipe buffers(實際上 HAProxy 的 pipe pool 也會有這個問題),也就是說那些未被釋放的 pipe buffers 將一直存在於用戶進程的生命週期中,直到進程結束以後才由 kernel 進行釋放,這明顯不是一個使人信服的解決方案,結果不出意料地被 Go team 的核心大佬 Ian (委婉地)否決了,因而我立刻又想了兩個新的方案:

  1. 基於這個現有的方案加上一個獨立的 goroutine 定時去掃描 pipe pool,關閉並釋放 pipe buffers;
  2. 基於 sync.Pool 標準庫來實現 pipe pool,並利用 runtime.SetFinalizer 來解決按期釋放 pipe buffers 的問題。

第一個方案須要引入額外的 goroutine,而且該 goroutine 也爲這個設計增長了不肯定的因素,而第二個方案則更加優雅,首先由於基於 sync.Pool 實現,其底層也能夠說是基於 TLS 的思想,其次利用了 Go 的 runtime 來解決定時釋放 pipe buffers 的問題,實現上更加的優雅,因此很快,我和其餘的 Go reviewers 就達成一致決定採用第二個方案。

sync.Pool 是 Go 語言提供的臨時對象緩存池,通常用來複用資源對象,減輕 GC 壓力,合理使用它能對程序的性能有顯著的提高。不少頂級的 Go 開源庫都會重度使用 sync.Pool 來提高性能,好比 Go 領域最流行的第三方 HTTP 框架 fasthttp 就在源碼中大量地使用了 sync.Pool,而且收穫了比 Go 標準 HTTP 庫高出近 10 倍的性能提高(固然不只僅靠這一個優化點,還有不少其餘的),fasthttp 的做者 Aliaksandr Valialkin 做爲 Go 領域的大神(Go contributor,給 Go 貢獻過不少代碼,也優化過 sync.Pool),在 fasthttp 的 best practices 中極力推薦使用 sync.Pool,因此 Go 的 pipe pool 使用 sync.Pool 來實現也算是水到渠成。

sync.Pool 底層原理簡單來講就是:私有變量+共享雙向鏈表。

Google 了一張圖來展現 sync.Pool 的底層實現:

  • 獲取對象時:當某個 P 上的 goroutine 從 sync.Pool 嘗試獲取緩存的對象時,須要先把當前的 goroutine 鎖死在 P 上,防止操做期間忽然被調度走,而後先嚐試去取本地私有變量 private,若是沒有則去 shared 雙向鏈表的表頭取,該鏈表能夠被其餘 P 消費(或者說"偷"),若是當前 P 上的 shared 是空則去"偷"其餘 P 上的 shared 雙向鏈表的表尾,最後解除鎖定,若是仍是沒有取到緩存的對象,則直接調用 New 建立一個返回。
  • 放回對象時:先把當前的 goroutine 鎖死在 P 上,若是本地的 private 爲空,則直接將對象存入,不然就存入 shared 雙向鏈表的表頭,最後解除鎖定。

shared 雙向鏈表的每一個節點都是一個環形隊列,主要是爲了高效複用內存,共享雙向鏈表在 Go 1.13 以前使用互斥鎖 sync.Mutex 保護,Go 1.13 以後改用 atomic CAS 實現無鎖併發,原子操做無鎖併發適用於那些臨界區極小的場景,性能會被互斥鎖好不少,正好很貼合 sync.Pool 的場景,由於存取臨時對象的操做是很是快速的,若是使用 mutex,則在競爭時須要掛起那些搶鎖失敗的 goroutines 到 wait queue,等後續解鎖以後喚醒並放入 run queue,等待調度執行,還不如直接忙輪詢等待,反正很快就能搶佔到臨界區。

sync.Pool 的設計也具備部分的 TLS 思想,因此從某種意義上來講它是就 Go 語言的 TLS 機制。

sync.Pool 基於 victim cache 會保證緩存在其中的資源對象最多不超過兩個 GC 週期就會被回收掉

所以我使用了 sync.Pool 來實現 Go 的 pipe pool,把 pipe 的管道文件描述符對存儲在其中,併發之時進行復用,並且會按期自動回收,可是還有一個問題,當 sync.Pool 中的對象被回收的時候,只是回收了管道的文件描述符對,也就是兩個整型的 fd 數,並無在操做系統層面關閉掉 pipe 管道。

所以,還須要有一個方法來關閉 pipe 管道,這時候能夠利用 runtime.SetFinalizer 來實現。這個方法其實就是對一個即將放入 sync.Pool 的資源對象設置一個回調函數,當 Go 的三色標記 GC 算法檢測到 sync.Pool 中的對象已經變成白色(unreachable,也就是垃圾)並準備回收時,若是該白色對象已經綁定了一個關聯的回調函數,則 GC 會先解綁該回調函數並啓動一個獨立的 goroutine 去執行該回調函數,由於回調函數使用該對象做爲函數入參,也就是會引用到該對象,那麼就會致使該對象從新變成一個 reachable 的對象,因此在本輪 GC 中不會被回收,從而使得這個對象的生命得以延續一個 GC 週期。

在每個 pipe buffer 放回 pipe pool 以前經過 runtime.SetFinalizer 指定一個回調函數,在函數中使用系統調用關閉管道,則能夠利用 Go 的 GC 機制按期真正回收掉 pipe buffers,從而實現了一個優雅的 pipe pool in Go,相關的 commits 以下:

爲 Go 的 splice 引入 pipe pool 以後,對性能的提高效果以下:

goos: linux
goarch: amd64
pkg: internal/poll
cpu: AMD EPYC 7K62 48-Core Processor

name                  old time/op    new time/op    delta
SplicePipe-8            1.36µs ± 1%    0.02µs ± 0%   -98.57%  (p=0.001 n=7+7)
SplicePipeParallel-8     747ns ± 4%       4ns ± 0%   -99.41%  (p=0.001 n=7+7)

name                  old alloc/op   new alloc/op   delta
SplicePipe-8             24.0B ± 0%      0.0B       -100.00%  (p=0.001 n=7+7)
SplicePipeParallel-8     24.0B ± 0%      0.0B       -100.00%  (p=0.001 n=7+7)

name                  old allocs/op  new allocs/op  delta
SplicePipe-8              1.00 ± 0%      0.00       -100.00%  (p=0.001 n=7+7)
SplicePipeParallel-8      1.00 ± 0%      0.00       -100.00%  (p=0.001 n=7+7)

基於 pipe pool 複用和直接建立&銷燬 pipe buffers 相比,耗時降低在 99% 以上,內存使用則是降低了 100%。

固然,這個 benchmark 只是一個純粹的存取操做,並未加入具體的業務邏輯,因此是一個很是理想化的壓測,不能徹底表明生產環境,可是 pipe pool 的引入對使用 Go 的 io 標準庫並基於 splice 進行高頻的零拷貝操做的性能一定會有數量級的提高。

這個特性最快應該會在今年下半年的 Go 1.17 版本發佈,到時就能夠享受到 pipe pool 帶來的性能提高了。

小結

經過給 Go 語言實現一個 pipe pool,期間涉及了多種併發、同步的優化思路,咱們再來概括總結一下。

  1. 資源複用,提高併發編程性能最有效的手段必定是資源複用,也是最立竿見影的優化手段。
  2. 數據結構的選取,數組支持 O(1) 隨機訪問而且能更好地利用 CPU cache,可是這些優點在 pool 的場景下並不明顯,由於 pool 中的資源具備等價性和單個存取(非批量)操做,數組須要預先分配固定內存而且伸縮的時候會有額外的內存管理負擔,鏈表隨取隨棄,自然支持動態伸縮。
  3. 全局鎖的優化,兩種思路,一種是根據資源的特性嘗試對鎖的粒度進行降級,一種是經過引入本地緩存,嘗試錯開多線程對資源的訪問,減小競爭全局鎖的頻次;還有就是根據實際場景適當地選擇用戶態鎖。
  4. 利用語言的 runtime,像 Go、Java 這類自帶一個龐大的 GC 的編程語言,在性能上通常不是 C/C++/Rust 這種無 GC 語言的對手,可是凡事有利有弊,自帶 runtime 的語言也具有獨特的優點,好比 HAProxy 的 pipe pool 是 C 實現的,在進程的生命週期中建立的 pipe buffers 會一直存在佔用資源(除非主動關閉,可是很難準確把控時機),而 Go 實現的 pipe pool 則能夠利用自身的 runtime 進行按期的清理工做,進一步減小資源佔用。

參考&延伸