位元組跳動在 Go 網路庫上的實踐

PureWhiteWu發表於2020-05-11

本文選自 “位元組跳動基礎架構實踐” 系列文章。
“位元組跳動基礎架構實踐” 系列文章是由位元組跳動基礎架構部門各技術團隊及專家傾力打造的技術乾貨內容,和大家分享團隊在基礎架構發展和演進過程中的實踐經驗與教訓,與各位技術同學一起交流成長。
RPC 框架作為研發體系中重要的一環,承載了幾乎所有的服務流量。本文將簡單介紹位元組跳動自研網路庫 netpoll 的設計及實踐;以及我們實際遇到的問題和解決思路,希望能為大家提供一些參考。

前言

位元組跳動框架組主要負責公司內 RPC 框架的開發與維護。RPC 框架作為研發體系中重要的一環,承載了幾乎所有的服務流量。隨著公司內 Go 語言使用越來越廣,業務對框架的要求越來越高,而 Go 原生 net 網路庫卻無法提供足夠的效能和控制力,如無法感知連線狀態、連線數量多導致利用率低、無法控制協程數量等。為了能夠獲取對於網路層的完全控制權,同時先於業務做一些探索並最終賦能業務,框架組推出了全新的基於 epoll 的自研網路庫 —— netpoll,並基於其之上開發了位元組內新一代 Golang 框架 KiteX。

由於 epoll 原理已有較多文章描述,本文將僅簡單介紹 netpoll 的設計;隨後,我們會嘗試梳理一下我們基於 netpoll 所做的一些實踐;最後,我們將分享一個我們遇到的問題,以及我們解決的思路。同時,歡迎對於 Go 語言以及框架感興趣的同學加入我們!

新型網路庫設計

Reactor - 事件監聽和排程核心

netpoll 核心是 Reactor 事件監聽排程器,主要功能為使用 epoll 監聽連線的檔案描述符(fd),通過回撥機制觸發連線上的 讀、寫、關閉 三種事件。

image.png

Server - 主從 Reactor 實現

netpoll 將 Reactor 以 1:N 的形式組合成主從模式。

  1. MainReactor 主要管理 Listener,負責監聽埠,建立新連線;
  2. SubReactor 負責管理 Connection,監聽分配到的所有連線,並將所有觸發的事件提交到協程池裡進行處理。
  3. netpoll 在 I/O Task 中引入了主動的記憶體管理,向上層提供 NoCopy 的呼叫介面,由此支援 NoCopy RPC。
  4. 使用協程池集中處理 I/O Task,減少 goroutine 數量和排程開銷。

image.png

Client - 共享 Reactor 能力

client 端和 server 端共享 SubReactor,netpoll 同樣實現了 dialer,提供建立連線的能力。client 端使用上和 net.Conn 相似,netpoll 提供了 write -> wait read callback 的底層支援。

image.png

Nocopy Buffer

為什麼需要 Nocopy Buffer ?

在上述提及的 Reactor 和 I/O Task 設計中,epoll 的觸發方式會影響 I/O 和 buffer 的設計,大體來說分為兩種方式:

  1. 採用水平觸發 (LT),則需要同步的在事件觸發後主動完成 I/O,並向上層程式碼直接提供 buffer。
  2. 採用邊沿觸發 (ET),可選擇只管理事件通知 (如 go net 設計),由上層程式碼完成 I/O 並管理 buffer。

兩種方式各有優缺,netpoll 採用前者策略,水平觸發時效性更好,容錯率高,主動 I/O 可以集中記憶體使用和管理,提供 nocopy 操作並減少 GC。事實上一些熱門開源網路庫也是採用方式一的設計,如 easygo、evio、gnet 等。

但使用 LT 也帶來另一個問題,即底層主動 I/O 和上層程式碼併發操作 buffer,引入額外的併發開銷。比如: I/O 讀資料寫 buffer 和上層程式碼讀 buffer 存在併發讀寫,反之亦然。為了保證資料正確性,同時不引入鎖競爭,現有的開源網路庫通常採取 同步處理 buffer(easygo, evio) 或者將 buffer 再 copy 一份提供給上層程式碼 (gnet) 等方式,均不適合業務處理或存在 copy 開銷。

另一方面,常見的 bytes、bufio、ringbuffer 等 buffer 庫,均存在 growth 需要 copy 原陣列資料,以及只能擴容無法縮容,佔用大量記憶體等問題。因此我們希望引入一種新的 Buffer 形式,一舉解決上述兩方面的問題。

Nocopy Buffer 設計和優勢

Nocopy Buffer 基於連結串列陣列實現,如下圖所示,我們將 [] byte 陣列抽象為 block,並以連結串列拼接的形式將 block 組合為 Nocopy Buffer,同時引入了引用計數、nocopy API 和物件池。

image.png

Nocopy Buffer 相比常見的 bytes、bufio、ringbuffer 等有以下優勢:

  1. 讀寫並行無鎖,支援 nocopy 地流式讀寫
    • 讀寫分別操作頭尾指標,相互不干擾。
  2. 高效擴縮容
    • 擴容階段,直接在尾指標後新增新的 block 即可,無需 copy 原陣列。
    • 縮容階段,頭指標會直接釋放使用完畢的 block 節點,完成縮容。每個 block 都有獨立的引用計數,當釋放的 block 不再有引用時,主動回收 block 節點。
  3. 靈活切片和拼接 buffer (連結串列特性)
    • 支援任意讀取分段 (nocopy),上層程式碼可以 nocopy 地並行處理資料流分段,無需關心生命週期,通過引用計數 GC。
    • 支援任意拼接 (nocopy),寫 buffer 支援通過 block 拼接到尾指標後的形式,無需 copy,保證資料只寫一次。
  4. Nocopy Buffer 池化,減少 GC
    • 將每個 [] byte 陣列視為 block 節點,構建物件池維護空閒 block,由此複用 block,減少記憶體佔用和 GC。

基於該 Nocopy Buffer,我們實現了 Nocopy Thrift,使得編解碼過程記憶體零分配零拷貝。

連線多路複用

RPC 呼叫通常採用短連線或者長連線池的形式,一次呼叫繫結一個連線,那麼當上下游規模很大的情況下,網路中存在的連線數以 MxN 的速度擴張,帶來巨大的排程壓力和計算開銷,給服務治理造成困難。因此,我們希望引入一種 "在單一長連線上並行處理呼叫" 的形式,來減少網路中的連線數,這種方案即稱為 "連線多路複用"。

當前業界也存在一些開源的連線多路複用方案,掣肘於程式碼層面的束縛,這些方案均需要 copy buffer 來實現資料分包和合並,導致實際效能並不理想。而上述 Nocopy Buffer 基於其靈活切片和拼接的特性,很好的支援了 nocopy 的資料分包和合並,使得實現高效能連線多路複用方案成為可能。

基於 netpoll 的連線多路複用設計如下圖所示,我們將 Nocopy Buffer(及其分片) 抽象為虛擬連線,使得上層程式碼保持同 net.Conn 相同的呼叫體驗。與此同時,在底層程式碼上通過協議分包將真實連線上的資料靈活的分配到虛擬連線上;或通過協議編碼合併傳送虛擬連線資料。

image.png

連線多路複用方案包含以下核心要素:

  1. 虛擬連線
    • 實質上是 Nocopy Buffer,目的是替換真正的連線,規避記憶體 copy。
    • 上層的業務邏輯/編解碼 均在虛擬連線上完成,上層邏輯可以非同步獨立並行執行。
  2. Shared map
    • 引入分片鎖來減少鎖力度。
    • 在呼叫端使用 sequence id 來標記請求,並使用分片鎖儲存 id 對應的回撥。
    • 在接收響應資料後,根據 sequence id 來找到對應回撥並執行。
  3. 協議分包和編碼
    • 如何識別完整的請求響應資料包是連線多路複用方案可行的關鍵,因此需要引入協議。
    • 這裡採用 thrift header protocol 協議,通過訊息頭判斷資料包完整性,通過 sequence id 標記請求和響應的對應關係。

ZeroCopy

這裡所說的 ZeroCopy,指的是 Linux 所提供的 ZeroCopy 的能力。上一章中我們說了業務層的零拷貝,而眾所周知,當我們呼叫 sendmsg 系統呼叫發包的時候,實際上仍然是會產生一次資料的拷貝的,並且在大包場景下這個拷貝的消耗非常明顯。以 100M 為例,perf 可以看到如下結果:

image.png

這還僅僅是普通 tcp 發包的佔用,在我們的場景下,大部分服務都會接入 Service Mesh,所以在一次發包中,一共會有 3 次拷貝:業務程式到核心、核心到 sidecar、sidecar 再到核心。這使得有大包需求的業務,拷貝所導致的 cpu 佔用會特別明顯,如下圖:

image.png

為了解決這個問題,我們選擇了使用 Linux 提供的 ZeroCopy API(在 4.14 以後支援 send;5.4 以後支援 receive)。但是這引入了一個額外的工程問題:ZeroCopy send API 和原先呼叫方式不相容,無法很好地共存。這裡簡單介紹一下 ZeroCopy send 的工作方式:業務程式呼叫 sendmsg 之後,sendmsg 會記錄下 iovec 的地址並立即返回,這時候業務程式不能釋放這段記憶體,需要通過 epoll 等待核心回撥一個訊號表明某段 iovec 已經傳送成功之後才能釋放。由於我們並不希望更改業務方的使用方法,需要對上層提供同步收發的介面,所以很難基於現有的 API 同時提供 ZeroCopy 和非 ZeroCopy 的抽象;而由於 ZeroCopy 在小包場景下是有效能損耗的,所以也不能將這個作為預設的選項。

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

在使用了 ZeroCopy send 後,perf 可以看到核心不再有 copy 的佔用:

image.png從 cpu 佔用數值上看,大包場景下 ZeroCopy 能夠比非 ZeroCopy 節省一半的 cpu。

Go 排程導致的延遲問題分享

在我們實踐過程中,發現我們新寫的 netpoll 雖然在 avg 延遲上表現勝於 Go 原生的 net 庫,但是在 p99 和 max 延遲上要普遍略高於 Go 原生的 net 庫,並且尖刺也會更加明顯,如下圖(Go 1.13,藍色為 netpoll + 多路複用,綠色為 netpoll + 長連線,黃色為 net 庫 + 長連線):

image.png

我們嘗試了很多種辦法去優化,但是收效甚微。最終,我們定位出這個延遲並非是由於 netpoll 本身的開銷導致的,而是由於 go 的排程導致的,比如說:

  1. 由於在 netpoll 中,SubReactor 本身也是一個 goroutine,受排程影響,不能保證 EpollWait 回撥之後馬上執行,所以這一塊會有延遲;
  2. 同時,由於用來處理 I/O 事件的 SubReactor 和用來處理連線監聽的 MainReactor 本身也是 goroutine,所以實際上很難保證在多核情況之下,這些 Reactor 能並行執行;甚至在最極端情況之下,可能這些 Reactor 會掛在同一個 P 下,最終變成了序列執行,無法充分利用多核優勢;
  3. 由於 EpollWait 回撥之後,SubReactor 內是序列處理 I/O 事件的,導致排在最後的事件可能會有長尾問題;
  4. 在連線多路複用場景下,由於每個連線繫結了一個 SubReactor,故延遲完全取決於這個 SubReactor 的排程,導致尖刺會更加明顯。

由於 Go 在 runtime 中對於 net 庫有做特殊優化,所以 net 庫不會有以上情況;同時 net 庫是 goroutine-per-connection 的模型,所以能確保請求能並行執行而不會相互影響。

對於以上這個問題,我們目前解決的思路有兩個:

  1. 修改 Go runtime 原始碼,在 Go runtime 中註冊一個回撥,每次排程時呼叫 EpollWait,把獲取到的 fd 傳遞給回撥執行;
  2. 與位元組跳動核心組合作,支援同時批量讀/寫多個連線,解決序列問題。

另外,經過我們的測試,Go 1.14 能夠使得延遲略有降低同時更加平穩,但是所能達到的極限 QPS 更低。 希望我們的思路能夠給業界同樣遇到此問題的同學提供一些參考。

後記

希望以上的分享能夠對社群有所幫助。同時,我們也在加速建設 netpoll 以及基於 netpoll 的新框架 KiteX。歡迎各位感興趣的同學加入我們,共同建設 Go 語言生態!

參考資料

  1. http://man7.org/linux/man-pages/man7/epoll.7.html

  2. https://golang.org/src/runtime/proc.go

  3. https://github.com/panjf2000/gnet

  4. https://github.com/tidwall/evio

更多分享

Kernel trace tools(二):核心態執行時間跟蹤

位元組跳動混沌工程實踐總結

gdb 提示 coredump 檔案 truncated 問題排查

位元組跳動基礎架構團隊

位元組跳動基礎架構團隊是支撐位元組跳動旗下包括抖音、今日頭條、西瓜視訊、火山小視訊在內的多款億級規模使用者產品平穩執行的重要團隊,為位元組跳動及旗下業務的快速穩定發展提供了保證和推動力。

公司內,基礎架構團隊主要負責位元組跳動私有云建設,管理數以萬計伺服器規模的叢集,負責數萬臺計算/儲存混合部署和線上/離線混合部署,支援若干 EB 海量資料的穩定儲存。

文化上,團隊積極擁抱開源和創新的軟硬體架構。我們長期招聘基礎架構方向的同學,可以直接通過 https://job.toutiao.com/s/EagQEh 進行投遞,也可以聯絡郵箱 wudi.daniel@bytedance.com 。

歡迎加入位元組跳動技術團隊

更多原創文章乾貨分享,請關注公眾號
  • 位元組跳動在 Go 網路庫上的實踐
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章