Go 語(yǔ)言中的零拷貝優(yōu)化
- 導(dǎo)言
- splice
- pipe pool for splice
- pipe pool in HAProxy
- pipe pool in Go
- 小結(jié)
- 參考&延伸
導(dǎo)言
相信那些曾經(jīng)使用 Go 寫(xiě)過(guò) proxy server 的同學(xué)應(yīng)該對(duì) io.Copy()/io.CopyN()/io.CopyBuffer()/io.ReaderFrom 等接口和方法不陌生,它們是使用 Go 操作各類 I/O 進(jìn)行數(shù)據(jù)傳輸經(jīng)常需要使用到的 API,其中基于 TCP 協(xié)議的 socket 在使用上述接口和方法進(jìn)行數(shù)據(jù)傳輸時(shí)利用到了 Linux 的零拷貝技術(shù) sendfile 和 splice。
我前段時(shí)間為 Go 語(yǔ)言內(nèi)部的 Linux splice 零拷貝技術(shù)做了一點(diǎn)優(yōu)化:為 splice 實(shí)現(xiàn)了一個(gè) pipe pool,復(fù)用管道,減少頻繁創(chuàng)建和銷毀 pipe buffers 所帶來(lái)的系統(tǒng)開(kāi)銷,理論上來(lái)說(shuō)能夠大幅提升 Go 的 io 標(biāo)準(zhǔn)庫(kù)中基于 splice 零拷貝實(shí)現(xiàn)的 API 的性能。因此,我想從這個(gè)優(yōu)化工作出發(fā),分享一些我個(gè)人對(duì)多線程編程中的一些不成熟的優(yōu)化思路。
因本人才疏學(xué)淺,故行文之間恐有紕漏,望諸君海涵,不吝賜教,若能予以斧正,則感激不盡。
splice
縱觀 Linux 的零拷貝技術(shù),相較于mmap、sendfile和 MSG_ZEROCOPY 等其他技術(shù),splice 從使用成本、性能和適用范圍等維度綜合來(lái)看更適合在程序中作為一種通用的零拷貝方式。
splice() 系統(tǒng)調(diào)用函數(shù)定義如下:
- #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 也是分別代表了輸入端和輸出端的文件描述符,這兩個(gè)文件描述符必須有一個(gè)是指向管道設(shè)備的,這算是一個(gè)不太友好的限制。
off_in 和 off_out 則分別是 fd_in 和 fd_out 的偏移量指針,指示內(nèi)核從哪里讀取和寫(xiě)入數(shù)據(jù),len 則指示了此次調(diào)用希望傳輸?shù)淖止?jié)數(shù),最后的 flags 是系統(tǒng)調(diào)用的標(biāo)記選項(xiàng)位掩碼,用來(lái)設(shè)置系統(tǒng)調(diào)用的行為屬性的,由以下 0 個(gè)或者多個(gè)值通過(guò)『或』操作組合而成:
- SPLICE_F_MOVE:指示 splice() 嘗試僅僅是移動(dòng)內(nèi)存頁(yè)面而不是復(fù)制,設(shè)置了這個(gè)值不代表就一定不會(huì)復(fù)制內(nèi)存頁(yè)面,復(fù)制還是移動(dòng)取決于內(nèi)核能否從管道中移動(dòng)內(nèi)存頁(yè)面,或者管道中的內(nèi)存頁(yè)面是否是完整的;這個(gè)標(biāo)記的初始實(shí)現(xiàn)有很多 bug,所以從 Linux 2.6.21 版本開(kāi)始就已經(jīng)無(wú)效了,但還是保留了下來(lái),因?yàn)樵谖磥?lái)的版本里可能會(huì)重新被實(shí)現(xiàn)。
- SPLICE_F_NONBLOCK:指示 splice() 不要阻塞 I/O,也就是使得 splice() 調(diào)用成為一個(gè)非阻塞調(diào)用,可以用來(lái)實(shí)現(xiàn)異步數(shù)據(jù)傳輸,不過(guò)需要注意的是,數(shù)據(jù)傳輸?shù)膬蓚€(gè)文件描述符也最好是預(yù)先通過(guò) O_NONBLOCK 標(biāo)記成非阻塞 I/O,不然 splice() 調(diào)用還是有可能被阻塞。
- SPLICE_F_MORE:通知內(nèi)核下一個(gè) splice() 系統(tǒng)調(diào)用將會(huì)有更多的數(shù)據(jù)傳輸過(guò)來(lái),這個(gè)標(biāo)記對(duì)于輸出端是 socket 的場(chǎng)景非常有用。
splice() 是基于 Linux 的管道緩沖區(qū) (pipe buffer) 機(jī)制實(shí)現(xiàn)的,所以 splice() 的兩個(gè)入?yún)⑽募枋龇乓蟊仨氂幸粋€(gè)是管道設(shè)備,一個(gè)典型的 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);
數(shù)據(jù)傳輸過(guò)程圖:
使用 splice() 完成一次磁盤文件到網(wǎng)卡的讀寫(xiě)過(guò)程如下:
- 用戶進(jìn)程調(diào)用 pipe(),從用戶態(tài)陷入內(nèi)核態(tài),創(chuàng)建匿名單向管道,pipe() 返回,上下文從內(nèi)核態(tài)切換回用戶態(tài);
- 用戶進(jìn)程調(diào)用 splice(),從用戶態(tài)陷入內(nèi)核態(tài);
- DMA 控制器將數(shù)據(jù)從硬盤拷貝到內(nèi)核緩沖區(qū),從管道的寫(xiě)入端"拷貝"進(jìn)管道,splice() 返回,上下文從內(nèi)核態(tài)回到用戶態(tài);
- 用戶進(jìn)程再次調(diào)用 splice(),從用戶態(tài)陷入內(nèi)核態(tài);
- 內(nèi)核把數(shù)據(jù)從管道的讀取端"拷貝"到套接字緩沖區(qū),DMA 控制器將數(shù)據(jù)從套接字緩沖區(qū)拷貝到網(wǎng)卡;
- splice() 返回,上下文從內(nèi)核態(tài)切換回用戶態(tài)。
上面是 splice 的基本工作流程和原理,簡(jiǎn)單來(lái)說(shuō)就是在數(shù)據(jù)傳輸過(guò)程中傳遞內(nèi)存頁(yè)指針而非實(shí)際數(shù)據(jù)來(lái)實(shí)現(xiàn)零拷貝,如果有意了解其更底層的實(shí)現(xiàn)原理請(qǐng)移步:《Linux I/O 原理和 Zero-copy 技術(shù)全面揭秘》。
pipe pool for splice
pipe pool in HAProxy
從上面對(duì) splice 的介紹可知,通過(guò)它實(shí)現(xiàn)數(shù)據(jù)零拷貝需要利用到一個(gè)媒介 -- pipe 管道(2005 年由 Linus 引入),大概是因?yàn)樵? Linux 的 IPC 機(jī)制中對(duì) pipe 的應(yīng)用已經(jīng)比較成熟,于是借助了 pipe 來(lái)實(shí)現(xiàn) splice,雖然 Linux Kernel 團(tuán)隊(duì)曾在 splice 誕生之初便說(shuō)過(guò)在未來(lái)可以移除掉 pipe 這個(gè)限制,但十幾年過(guò)去了也依然沒(méi)有付諸實(shí)施,因此 splice 至今還是和 pipe 死死綁定在一起。
那么問(wèn)題就來(lái)了,如果僅僅是使用 splice 進(jìn)行單次的大批量數(shù)據(jù)傳輸,則創(chuàng)建和銷毀 pipe 開(kāi)銷幾乎可以忽略不計(jì),但是如果是需要頻繁地使用 splice 來(lái)進(jìn)行數(shù)據(jù)傳輸,比如需要處理大量網(wǎng)絡(luò) sockets 的數(shù)據(jù)轉(zhuǎn)發(fā)的場(chǎng)景,則 pipe 的創(chuàng)建和銷毀的頻次也會(huì)隨之水漲船高,每調(diào)用一次 splice 都創(chuàng)建一對(duì) pipe 管道描述符,并在隨后銷毀掉,對(duì)一個(gè)網(wǎng)絡(luò)系統(tǒng)來(lái)說(shuō)是一個(gè)巨大的消耗。
對(duì)于這問(wèn)題的解決方案,自然而然就會(huì)想到 -- 『復(fù)用』,比如大名鼎鼎的 HAProxy。
HAProxy 是一個(gè)使用 C 語(yǔ)言編寫(xiě)的自由及開(kāi)放源代碼軟件,其提供高可用性、負(fù)載均衡,以及基于 TCP 和 HTTP 的應(yīng)用程序代理。它非常適用于那些有著極高網(wǎng)絡(luò)流量的 Web 站點(diǎn)。GitHub、Bitbucket、Stack Overflow、Reddit、Tumblr、Twitter 和 Tuenti 在內(nèi)的知名網(wǎng)站,及亞馬遜網(wǎng)絡(luò)服務(wù)系統(tǒng)都在使用 HAProxy。
因?yàn)樾枰隽髁哭D(zhuǎn)發(fā),可想而知,HAProxy 不可避免地要高頻地使用 splice,因此對(duì) splice 帶來(lái)的創(chuàng)建和銷毀 pipe buffers 的開(kāi)銷無(wú)法忍受,從而需要實(shí)現(xiàn)一個(gè) pipe pool,復(fù)用 pipe buffers 減少系統(tǒng)調(diào)用消耗,下面我們來(lái)詳細(xì)剖析一下 HAProxy 的 pipe pool 的設(shè)計(jì)思路。
首先我們來(lái)自己思考一下,一個(gè)最簡(jiǎn)單的 pipe pool 應(yīng)該如何實(shí)現(xiàn),最直接且簡(jiǎn)單的實(shí)現(xiàn)無(wú)疑就是:一個(gè)單鏈表+一個(gè)互斥鎖。鏈表和數(shù)組是用來(lái)實(shí)現(xiàn) pool 的最簡(jiǎn)單的數(shù)據(jù)結(jié)構(gòu),數(shù)組因?yàn)閿?shù)據(jù)在內(nèi)存分配上的連續(xù)性,能夠更好地利用 CPU 高速緩存加速訪問(wèn),但是首先,對(duì)于運(yùn)行在某個(gè) CPU 上的線程來(lái)說(shuō),一次只需要取一個(gè) pipe buffer 使用,所以高速緩存在這里的作用并不十分明顯;其次,數(shù)組不僅是連續(xù)而且是固定大小的內(nèi)存區(qū),需要預(yù)先分配好固定大小的內(nèi)存,而且還要?jiǎng)討B(tài)伸縮這個(gè)內(nèi)存區(qū),期間需要對(duì)數(shù)據(jù)進(jìn)行搬遷等操作,增加額外的管理成本。鏈表則是更加適合的選擇,因?yàn)樽鳛? pool 來(lái)說(shuō)其中所有的資源都是等價(jià)的,并不需要隨機(jī)訪問(wèn)去獲取其中某個(gè)特定的資源,而且鏈表天然是動(dòng)態(tài)伸縮的,隨取隨棄。
鎖通常使用 mutex,在 Linux 上的早期實(shí)現(xiàn)是一種 sleep-waiting 也就是休眠等待的鎖,kernel 維護(hù)一個(gè)對(duì)所有進(jìn)程/線程都可見(jiàn)的共享資源對(duì)象 mutex,多線程/進(jìn)程的加鎖解鎖其實(shí)就是對(duì)這個(gè)對(duì)象的競(jìng)爭(zhēng)。如果現(xiàn)在有 AB 兩個(gè)進(jìn)程/線程,A 首先進(jìn)入 kernel space 檢查 mutex,看看有沒(méi)有別的進(jìn)程/線程正在占用它,搶占 mutex 成功之后則直接進(jìn)入臨界區(qū),B 嘗試進(jìn)入臨界區(qū)的時(shí)候,檢測(cè)到 mutex 已被占用,就由運(yùn)行態(tài)切換成睡眠態(tài),等待該共享對(duì)象釋放,A 出臨界區(qū)的時(shí)候,需要再次進(jìn)入 kernel space 查看有沒(méi)有別的進(jìn)程/線程在等待進(jìn)入臨界區(qū),然后 kernel 會(huì)喚醒等待的進(jìn)程/線程并在合適的時(shí)間把 CPU 切換給該進(jìn)程/線程運(yùn)行。由于最初的 mutex 是一種完全內(nèi)核態(tài)的互斥量實(shí)現(xiàn),在并發(fā)量大的情況下會(huì)產(chǎn)生大量的系統(tǒng)調(diào)用和上下文切換的開(kāi)銷,在 Linux kernel 2.6.x 之后都是使用 futex (Fast Userspace Mutexes) 實(shí)現(xiàn),也即是一種用戶態(tài)和內(nèi)核態(tài)混用的實(shí)現(xiàn),通過(guò)在用戶態(tài)共享一段內(nèi)存,并利用原子操作讀取和修改信號(hào)量,在沒(méi)有競(jìng)爭(zhēng)的時(shí)候只需檢查這個(gè)用戶態(tài)的信號(hào)量而無(wú)需陷入內(nèi)核,信號(hào)量存儲(chǔ)在進(jìn)程內(nèi)的私有內(nèi)存則是線程鎖,存儲(chǔ)在通過(guò) mmap 或者 shmat 創(chuàng)建的共享內(nèi)存中則是進(jìn)程鎖。
即便是基于 futex 的互斥鎖,如果是一個(gè)全局的鎖,這種最簡(jiǎn)單的 pool + mutex 實(shí)現(xiàn)在競(jìng)爭(zhēng)激烈的場(chǎng)景下會(huì)有可預(yù)見(jiàn)的性能瓶頸,因此需要進(jìn)一步的優(yōu)化,優(yōu)化手段無(wú)非兩個(gè):降低鎖的粒度或者減少搶(全局)鎖的頻次。因?yàn)?pipe pool 中的資源本來(lái)就是全局共享的,也就是無(wú)法對(duì)鎖的粒度進(jìn)行降級(jí),因此只能是盡量減少多線程搶鎖的頻次,而這種優(yōu)化常用方案就是在全局資源池之外引入本地資源池,對(duì)多線程訪問(wèn)資源的操作進(jìn)行錯(cuò)開(kāi)。
HAProxy 實(shí)現(xiàn)的 pipe pool 就是依據(jù)上述的思路進(jìn)行設(shè)計(jì)的,將單一的全局資源池拆分成全局資源池+本地資源池。
全局資源池利用單鏈表和自旋鎖實(shí)現(xiàn),本地資源池則是基于線程私有存儲(chǔ)(Thread Local Storage, TLS)實(shí)現(xiàn),TLS 是一種線程的私有的變量,它的主要作用是在多線程編程中避免鎖競(jìng)爭(zhēng)的開(kāi)銷。TLS 由編譯器提供支持,我們知道編譯 C 程序得到的 obj 或者鏈接得到的 exe,其中的 .text 段保存代碼文本,.data 段保存已初始化的全局變量和已初始化的靜態(tài)變量,.bss 段則保存未初始化的全局變量和未初始化的局部靜態(tài)變量。
而 TLS 私有變量則會(huì)存入 TLS 幀,也就是 .tdata 和 .tboss 段,與.data 和 .bss 不一樣的是,運(yùn)行時(shí)程序不會(huì)直接訪問(wèn)這些段,而是在程序啟動(dòng)后,動(dòng)態(tài)鏈接器會(huì)對(duì)這兩個(gè)段進(jìn)行動(dòng)態(tài)初始化 (如果有聲明 TLS 的話),之后這兩個(gè)段不會(huì)再改變,而是作為 TLS 的初始鏡像保存起來(lái)。每次啟動(dòng)一個(gè)新線程的時(shí)候都會(huì)將 TLS 塊作為線程堆棧的一部分進(jìn)行分配并將初始的 TLS 鏡像拷貝過(guò)來(lái),也就是說(shuō)最終每個(gè)線程啟動(dòng)時(shí) TLS 塊中的內(nèi)容都是一樣的。
HAProxy 的 pipe pool 實(shí)現(xiàn)原理:
- 聲明 thread_local 修飾的一個(gè)單鏈表,節(jié)點(diǎn)是 pipe buffer 的兩個(gè)管道描述符,那么每個(gè)需要使用 pipe buffer 的線程都會(huì)初始化一個(gè)基于 TLS 的單鏈表,用以存儲(chǔ) pipe buffers;
- 設(shè)置一個(gè)全局的 pipe pool,使用自旋鎖保護(hù)。
每個(gè)線程去取 pipe 的時(shí)候會(huì)先從自己的 TLS 中去嘗試獲取,獲取不到則加鎖進(jìn)入全局 pipe pool 去找;使用 pipe buffer 過(guò)后將其放回:先嘗試放回 TLS,根據(jù)一定的策略計(jì)算當(dāng)前 TLS 的本地 pipe pool 鏈表中的節(jié)點(diǎn)是否已經(jīng)過(guò)多,是的話則放到全局的 pipe pool 中去,否則直接放回本地 pipe pool。
HAProxy 的 pipe pool 實(shí)現(xiàn)雖然只有短短的 100 多行代碼,但是其中蘊(yùn)含的設(shè)計(jì)思想?yún)s包含了許多非常經(jīng)典的多線程優(yōu)化思路,值得細(xì)讀。
pipe pool in Go
受到 HAProxy 的 pipe pool 的啟發(fā),我嘗試為 Golang 的 io 標(biāo)準(zhǔn)庫(kù)里底層的 splice 實(shí)現(xiàn)了一個(gè) pipe pool,不過(guò)熟悉 Go 的同學(xué)應(yīng)該知道 Go 有一個(gè) GMP 并發(fā)調(diào)度器,提供了強(qiáng)大并發(fā)調(diào)度能力的同時(shí)也屏蔽了操作系統(tǒng)層級(jí)的線程,所以 Go 沒(méi)有提供類似 TLS 的機(jī)制,倒是有一些開(kāi)源的第三方庫(kù)提供了類似的功能,比如 gls,雖然實(shí)現(xiàn)很精巧,但畢竟不是官方標(biāo)準(zhǔn)庫(kù)而且會(huì)直接操作底層堆棧,所以其實(shí)也并不推薦在線上使用。
一開(kāi)始,因?yàn)?Go 缺乏 TLS 機(jī)制,所以我提交的第一版 go pipe pool 就是一個(gè)很簡(jiǎn)陋的單鏈表+全局互斥鎖的實(shí)現(xiàn),因?yàn)檫@個(gè)方案在進(jìn)程的生命周期中并不會(huì)去釋放資源池里的 pipe buffers(實(shí)際上 HAProxy 的 pipe pool 也會(huì)有這個(gè)問(wèn)題),也就是說(shuō)那些未被釋放的 pipe buffers 將一直存在于用戶進(jìn)程的生命周期中,直到進(jìn)程結(jié)束之后才由 kernel 進(jìn)行釋放,這明顯不是一個(gè)令人信服的解決方案,結(jié)果不出意料地被 Go team 的核心大佬 Ian (委婉地)否決了,于是我馬上又想了兩個(gè)新的方案:
基于這個(gè)現(xiàn)有的方案加上一個(gè)獨(dú)立的 goroutine 定時(shí)去掃描 pipe pool,關(guān)閉并釋放 pipe buffers;
基于 sync.Pool 標(biāo)準(zhǔn)庫(kù)來(lái)實(shí)現(xiàn) pipe pool,并利用 runtime.SetFinalizer 來(lái)解決定期釋放 pipe buffers 的問(wèn)題。
第一個(gè)方案需要引入額外的 goroutine,并且該 goroutine 也為這個(gè)設(shè)計(jì)增加了不確定的因素,而第二個(gè)方案則更加優(yōu)雅,因?yàn)槔昧?Go 的 runtime 來(lái)解決定時(shí)釋放 pipe buffers 的問(wèn)題,實(shí)現(xiàn)上更加的優(yōu)雅,所以很快,我和其他 reviewers 就達(dá)成一致決定采用第二個(gè)方案。
sync.Pool 是 Go 語(yǔ)言提供的臨時(shí)對(duì)象緩存池,一般用來(lái)復(fù)用資源對(duì)象,減輕 GC 壓力,合理使用它能對(duì)程序的性能有顯著的提升。很多頂級(jí)的 Go 開(kāi)源庫(kù)都會(huì)重度使用 sync.Pool 來(lái)提升性能,比如 Go 領(lǐng)域最流行的第三方 HTTP 框架 fasthttp 就在源碼中大量地使用了 sync.Pool,并且收獲了比 Go 標(biāo)準(zhǔn) HTTP 庫(kù)高出近 10 倍的性能提升(當(dāng)然不僅僅靠這一個(gè)優(yōu)化點(diǎn),還有很多其他的),fasthttp 的作者 Aliaksandr Valialkin 作為 Go 領(lǐng)域的大神(Go contributor,給 Go 貢獻(xiàn)過(guò)很多代碼,也優(yōu)化過(guò) sync.Pool),在 fasthttp 的 best practices 中極力推薦使用 sync.Pool,所以 Go 的 pipe pool 使用 sync.Pool 來(lái)實(shí)現(xiàn)也算是水到渠成。
sync.Pool 底層原理簡(jiǎn)單來(lái)說(shuō)就是:私有變量+共享雙向鏈表。
Google 了一張圖來(lái)展示 sync.Pool 的底層實(shí)現(xiàn):
在某個(gè) P 上的 goroutine 在從 sync.Pool 嘗試獲取緩存的對(duì)象時(shí),內(nèi)部會(huì)先嘗試去取本地私有變量 private,如果沒(méi)有則去 shared 雙向鏈表表頭取,該鏈表可以被其他 P 消費(fèi),鏈表的節(jié)點(diǎn)是一個(gè)環(huán)形隊(duì)列,復(fù)用內(nèi)存,共享雙向鏈表在 Go 1.13 之前使用互斥鎖 sync.Mutex 保護(hù),Go 1.13 之后改用 atomic CAS 實(shí)現(xiàn)無(wú)鎖并發(fā),原子操作無(wú)鎖并發(fā)適用于那些臨界區(qū)極小的場(chǎng)景,性能會(huì)被互斥鎖好很多,正好很貼合 sync.Pool 的場(chǎng)景,因?yàn)榇嫒∨R時(shí)對(duì)象的操作是非??焖俚?,如果使用 mutex,則在競(jìng)爭(zhēng)時(shí)需要掛起那些搶鎖失敗的 goroutines 到 wait queue,等后續(xù)解鎖之后喚醒并放入 run queue,等待調(diào)度執(zhí)行,還不如直接忙輪詢等待,反正很快就能搶占到臨界區(qū)。
sync.Pool 基于 victim cache 會(huì)保證緩存在其中的資源對(duì)象最多不超過(guò)兩個(gè) GC 周期就會(huì)被回收掉。
因此我使用了 sync.Pool 來(lái)實(shí)現(xiàn) Go 的 pipe pool,把 pipe 的管道文件描述符對(duì)存儲(chǔ)在其中,并發(fā)之時(shí)進(jìn)行復(fù)用,而且會(huì)定期自動(dòng)回收,但是還有一個(gè)問(wèn)題,當(dāng) sync.Pool 中的對(duì)象被回收的時(shí)候,只是回收了管道的文件描述符對(duì),也就是兩個(gè)整型的 fd 數(shù),并沒(méi)有在操作系統(tǒng)層面關(guān)閉掉 pipe 管道。
因此,還需要有一個(gè)方法來(lái)關(guān)閉 pipe 管道,這時(shí)候可以利用 runtime.SetFinalizer 來(lái)實(shí)現(xiàn)。這個(gè)方法其實(shí)就是對(duì)一個(gè)即將放入 sync.Pool 的資源對(duì)象設(shè)置一個(gè)回調(diào)函數(shù),當(dāng) Go 的 三色標(biāo)記 GC 算法檢測(cè)到 sync.Pool 中的對(duì)象已經(jīng)變成白色(unreachable,也就是垃圾)并準(zhǔn)備回收時(shí),如果該白色對(duì)象已經(jīng)綁定了一個(gè)關(guān)聯(lián)的回調(diào)函數(shù),則 GC 會(huì)先解綁該回調(diào)函數(shù)并啟動(dòng)一個(gè)獨(dú)立的 goroutine 去執(zhí)行該回調(diào)函數(shù),因?yàn)榛卣{(diào)函數(shù)使用該對(duì)象作為函數(shù)入?yún)?,也就是?huì)引用到該對(duì)象,那么就會(huì)導(dǎo)致該對(duì)象重新變成一個(gè) reachable 的對(duì)象,所以在本輪 GC 中不會(huì)被回收,從而使得這個(gè)對(duì)象的生命得以延續(xù)一個(gè) GC 周期。
在每一個(gè) pipe buffer 放回 pipe pool 之前通過(guò) runtime.SetFinalizer 指定一個(gè)回調(diào)函數(shù),在函數(shù)中使用系統(tǒng)調(diào)用關(guān)閉管道,則可以利用 Go 的 GC 機(jī)制定期真正回收掉 pipe buffers,從而實(shí)現(xiàn)了一個(gè)優(yōu)雅的 pipe pool in Go,相關(guān)的 commits 如下:
- internal/poll: implement a pipe pool for splice() call
- internal/poll: fix the intermittent build failures with pipe pool
- internal/poll: ensure that newPoolPipe doesn't return a nil pointer
- internal/poll: cast off the last reference of SplicePipe in test
為 Go 的 splice 引入 pipe pool 之后,對(duì)性能的提升效果如下:
- 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)
直接創(chuàng)建 pipe buffer 和利用 pipe pool 復(fù)用相比,耗時(shí)下降在 99% 以上,內(nèi)存使用則是下降了 100%。
當(dāng)然,這個(gè) benchmark 只是一個(gè)純粹的存取操作,并未加入具體的業(yè)務(wù)邏輯,所以是一個(gè)非常理想化的壓測(cè),不能完全代表生產(chǎn)環(huán)境,但是 pipe pool 的引入對(duì)使用 Go 的 io 標(biāo)準(zhǔn)庫(kù)并基于 splice 進(jìn)行高頻的零拷貝操作的性能必定會(huì)有數(shù)量級(jí)的提升。
這個(gè)特性最快應(yīng)該會(huì)在今年下半年的 Go 1.17 版本發(fā)布,到時(shí)就可以享受到 pipe pool 帶來(lái)的性能提升了。
小結(jié)
通過(guò)給 Go 語(yǔ)言實(shí)現(xiàn)一個(gè) pipe pool,期間涉及了多種并發(fā)、同步的優(yōu)化思路,我們?cè)賮?lái)歸納總結(jié)一下。
- 資源復(fù)用,提升并發(fā)編程性能最有效的手段一定是資源復(fù)用,也是最立竿見(jiàn)影的優(yōu)化手段。
- 數(shù)據(jù)結(jié)構(gòu)的選取,數(shù)組支持 O(1) 隨機(jī)訪問(wèn)并且能更好地利用 CPU cache,但是這些優(yōu)勢(shì)在 pool 的場(chǎng)景下并不明顯,因?yàn)?pool 中的資源具有等價(jià)性和單個(gè)存取(非批量)操作,數(shù)組需要預(yù)先分配固定內(nèi)存并且伸縮的時(shí)候會(huì)有額外的內(nèi)存管理負(fù)擔(dān),鏈表隨取隨棄,天然支持動(dòng)態(tài)伸縮。
- 全局鎖的優(yōu)化,兩種思路,一種是根據(jù)資源的特性嘗試對(duì)鎖的粒度進(jìn)行降級(jí),一種是通過(guò)引入本地緩存,嘗試錯(cuò)開(kāi)多線程對(duì)資源的訪問(wèn),減少競(jìng)爭(zhēng)全局鎖的頻次。
- 利用語(yǔ)言的 runtime,像 Go、Java 這類自帶一個(gè)龐大的 GC 的編程語(yǔ)言,在性能上一般比不過(guò) C/C++/Rust 這種無(wú) GC 的語(yǔ)言,但是凡事有利有弊,自帶 runtime 的語(yǔ)言也具備獨(dú)特的優(yōu)勢(shì),比如 HAProxy 的 pipe pool 是 C 實(shí)現(xiàn)的,在進(jìn)程的生命周期中創(chuàng)建的 pipe buffers 會(huì)一直存在占用資源(除非主動(dòng)關(guān)閉,但是很難準(zhǔn)確控制時(shí)機(jī)),而 Go 實(shí)現(xiàn)的 pipe pool 則可以利用自身的 runtime 進(jìn)行定期的清理工作,進(jìn)一步減少資源占用。
參考&延伸
sync.Pool
pipe pool in HAProxy
internal/poll: implement a pipe pool for splice() call
internal/poll: fix the intermittent build failures with pipe pool
internal/poll: ensure that newPoolPipe doesn't return a nil pointer
internal/poll: cast off the last reference of SplicePipe in test
Use Thread-local Storage to Reduce Synchronization
ELF Handling For Thread-Local Storage
References
[1] 《Linux I/O 原理和 Zero-copy 技術(shù)全面揭秘》: https://strikefreedom.top/linux-io-and-zero-copy
[2] gls: https://github.com/jtolio/gls
[3] fasthttp: https://github.com/valyala/fasthttp
[4] best practices: https://github.com/valyala/fasthttp#fasthttp-best-practices
[5] internal/poll: implement a pipe pool for splice() call: https://github.com/golang/go/commit/643d240a11b2d00e1718b02719707af0708e7519
[6] internal/poll: fix the intermittent build failures with pipe pool: https://github.com/golang/go/commit/6382ec1aba1b1c7380cb525217c1bd645c4fd41b
[7] internal/poll: ensure that newPoolPipe doesn't return a nil pointer: https://github.com/golang/go/commit/8b859be9c3fd1068b659afa1db76dadb210c63de
[8] internal/poll: cast off the last reference of SplicePipe in test: https://github.com/golang/go/commit/832c70e33d8265116f0abce436215b8e9ee4bb08
[9] sync.Pool: https://github.com/golang/go/blob/master/src/sync/pool.go
[10] pipe pool in HAProxy: https://github.com/haproxy/haproxy/blob/v2.4.0/src/pipe.c
[11] internal/poll: implement a pipe pool for splice() call: https://github.com/golang/go/commit/643d240a11b2d00e1718b02719707af0708e7519
[12] internal/poll: fix the intermittent build failures with pipe pool: https://github.com/golang/go/commit/6382ec1aba1b1c7380cb525217c1bd645c4fd41b
[13] internal/poll: ensure that newPoolPipe doesn't return a nil pointer: https://github.com/golang/go/commit/8b859be9c3fd1068b659afa1db76dadb210c63de
[14] internal/poll: cast off the last reference of SplicePipe in test: https://github.com/golang/go/commit/832c70e33d8265116f0abce436215b8e9ee4bb08
[15] Use Thread-local Storage to Reduce Synchronization: https://software.intel.com/content/www/us/en/develop/articles/use-thread-local-storage-to-reduce-synchronization.html
[16] ELF Handling For Thread-Local Storage: https://akkadia.org/drepper/tls.pdf