如何用 Go 實現一個異步網絡庫?

導語 | 在需要高性能、節省資源的場景下,比如海量的連接、很高的併發,我們發現 Go 開始變得喫力,不但內存開銷大,而且還會有頻繁的 goroutine 調度。GC 時間也變得越來越長,甚至還會把系統搞掛。這時,我們就可以考慮用 Go 構建經典的 Reactor 網絡模型,來應對這種場景。

一、常見的服務端網絡編程模型

在具體講 Reactor 網絡庫的實現前,我們先快速回顧下常見的服務端網絡編程模型。

服務端網絡編程主要解決兩個問題,一個是服務端如何管理連接,特別是海量連接、高併發連接(經典的 c10k/c100k 問題),二是服務端如何處理請求(高併發時正常響應)。

針對這兩個問題,有三種解決方案,分別對應三種模型:

下面兩圖分別是傳統 IO 阻塞模型和 Reactor 模型,傳統 IO 阻塞模型的特點是每條連接都是由單獨的線 / 進程管理,業務邏輯(crud)跟數據處理(網絡連接上的 read 和 write)都在該線 / 進程完成。缺點很明顯,併發大時,需要創建大量的線 / 進程,系統資源開銷大;連接建立後,如果當前線 / 進程暫時還沒數據可讀,會阻塞在 Read 調用上,浪費系統資源。

Reactor 模型就是傳統 IO 阻塞模型的改進,Reactor 會起單獨的線 / 進程去監聽和分發事件,分發給其他 EventHandlers 處理數據讀寫和業務邏輯。這樣,與傳統 IO 阻塞模型不同的是,Reactor 的連接都先到一個 EventDispatcher 上,一個核心的事件分發器,同時 Reactor 會使用 IO 多路複用在事件分發器上非阻塞地處理多個連接。

這個 EventDispatcher 跟後面的 EventHandlers 可以都在一個線 / 進程,也可以分開,下文會有區分。整體來看,Reactor 就是一種事件分發機制,所以 Reactor 也被稱爲事件驅動模型。簡而言之,Reactor=IO 多路複用(I/O multiplexing)+ 非阻塞 IO(non-blocking I/O)。

(一)Reactor 模型的三種實現

根據 Reactor 的數量和業務線程的工作安排有 3 種典型實現:

先看兩個單 Reactor:

一個 Reactor 接管所有的事件安排,如果是建立連接事件,就交給 Acceptor 處理,接着創建對應的 Handler 處理該連接後續的讀寫事件。如果不是建立連接事件,就調用連接對應的 Event Handler 來響應。單 Reator1 和 2 的區別是 2 帶了個線程池,一定程度上解放 Event Handler 線程,讓 Handler 專注數據讀寫處理,特別是在遇到一些笨重、高耗時的業務邏輯時。

再來看多 Reactor,這個是本文的主角,第三節內容就是怎麼實現它。多 Reactor 就是主從多 Reactor,它的特點是多個 Reactor 在多個單獨的線 / 進程中運行,MainReactor 負責處理建立連接事件,交給它的 Acceptor 處理,處理完了,它再分配連接給 SubReactor;SubReactor 則處理這個連接後續的讀寫事件,SubReactor 自己調用 EventHandlers 做事情。

這種實現看起來職責就很明確,可以方便通過增加 SubReactor 數量來充分利用 CPU 資源,也是當前主流的服務端網絡編程模型。

(二)Proactor 模型自帶主角光環

儘管本文的主角是主從多 Reactor,但如果 Proactor 要當主角,就沒 Reactor 什麼事。

Proactor 模型跟 Reactor 模型的本質區別是異步 I/O 和同步 I/O 的區別,即底層 I/O 實現。

從上面兩張圖可以看出,Reactor 模型依賴的同步 I/O 需要不斷檢查事件發生,然後拷貝數據處理,而 Proactor 模型使用的異步 I/O 只需等待系統通知,直接處理內核拷貝過來的數據,孰優孰劣,一言便知。

基於異步 I/O 的 Proactor 模型實現如下圖:

那爲什麼主角光環如此明顯的 Proactor 不是當前主流的服務端網絡編程模型呢?

原因是在 Linux 下的 AIO API--io_uring 還沒有像同步 I/O 那樣能夠覆蓋和支持很多場景,即還沒成熟到被廣泛使用。

二、Go 原生網絡模型簡介

關於 Go 原生網絡模型的實現,網上已經有很多文章,這裏就不過多展開,讀者可以結合下圖追蹤整個代碼流程:

總結來說,Go 所有的網絡操作圍繞網絡描述符 netFD 展開,netFD 與底層 pollDesc 結構綁定,當在一個 netFD 上讀寫遇到 EAGAIN 錯誤時,就將當前 goroutine 存儲到綁定的 pollDesc 中,同時將 goroutine 給 park 住,直到這個 netFD 上的數據準備好,再喚醒 goroutine 完成數據讀寫。

再總結來說,Go 原生網絡模型就是個單 Reactor 多協程模型。

三、如何從 0 到 1 實現異步網絡庫

我們現在回顧了常見的服務端網絡編程模型,也知道 Go 處理連接的方式是一個連接給分配一個協程處理,即 goroutine-per-conn 模式。

那本節就到了我們的重點,怎麼去實現一個異步網絡庫(因爲 Reactor 模型的實現,一般是主線程 accept 一個連接後,分給其他的線 / 進程異步處理後續的業務邏輯和數據讀寫,所以一般 Reactor 模型的網絡庫被稱爲異步網絡庫,並不是使用異步 I/O 的 API)。

在具體實現之前,筆者先介紹下需求背景。

(一)需求背景

Go 的協程非常輕量,大部分場景下,基於 Go 原生網絡庫構建的應用都不會有什麼性能瓶頸,資源佔用也很可觀。

我們現在使用的網關是基於 C++ 自研的一款網關,我們想統一技術棧,換成 Go 的,我們現在峯值會在百萬連接上下,大概用了幾十臺機器,單機能穩定支撐幾十萬的連接。如果換成 Go 的話,我們一直疑惑,基於 Go 實現的網關單機能撐多少,內存跟 CPU 怎麼樣?能不能省點機器?

於是,筆者開始針對這種有大量連接的場景對 Go 做了一波壓測,得出的結論也顯而易見:隨着連接數上升,Go 的協程數也隨之線性上升,內存開銷增大,GC 時間佔比增加。當連接數到達一定數值時,Go 的強制 GC 還會把進程搞掛,服務不可用。(下文會有網絡庫的對比壓測數據)

接着,筆者翻閱內外網有同樣場景的解決方案,基本都是往經典 Reactor 模型實現上做文章。比如最早的 A Million WebSockets and Go,作者 Sergey Kamardin 使用 epoll 的方式代替 goroutine-per-conn 模式,百萬連接場景下用少量的 goroutine 去代替一百萬的 goroutine。

A Million WebSockets and Go:

https://www.freecodecamp.org/news/million-websockets-and-go-cc58418460bb/

Sergey Kamardin 的方案總結:

Let’s structure the optimizations I told you about.

又比如字節基於 Reactor 網絡庫 netpoll 開發了 RPC 框架 Kitex 來應對高併發場景。

筆者簡單用 Go 實現了一個網關,使用這些 Reactor 網絡庫再進行了一波壓測,結果符合預期:連接數上去後的 Go 網關確實比之前的穩定,內存佔用也很可觀。但最終都沒有選用這些開源 Reactor 庫,原因是這些開源庫都不是開箱即用,都沒有實現 HTTP/1.x、TLS 等常見協議;API 設計不夠靈活且專注的場景並不適合網關,比如 netpoll 目前主要專注於 RPC 場景(字節上週才正式對外開源 HTTP 框架 Hertz);整體改造成本高,難以適配運用到 Go 網關中。

Netpoll 的場景說明:

另一方面,開源社區目前缺少專注於 RPC 方案的 Go 網絡庫。類似的項目如:evio,gnet 等,均面向 Redis,HAProxy 這樣的場景。

(二)總體分層設計

終於到了實現部分,我們先看一個 Reactor 庫的總體分層設計,總體分爲三層:應用層、連接層和基礎層。

應用層就是常見的 EchoServer、HTTPServer、TLSServer 和 GRPCServer 等等,主要負責協議解析、執行業務邏輯,對應 Reactor 模型裏邊的 EventHandler。

在 Reactor 模型中,應用層會實現事件處理的接口,等待連接層調用。

// Handler Core 註冊接口
type Handler interface {
  OnOpen(c *Conn)              // happen on accept conn
  OnClose(c *Conn, err error)  // happen ob delete conn
  OnData(c *Conn, data []byte) // happen on epoll wait
  OnStop()
}

比如當連接建立後,可以調用 OnOpen 函數做些初始化邏輯,當連接上有新數據到來,可以調用 OnData 函數完成具體的協議解析和業務邏輯。

(三)連接層設計

連接層就是整個 Reactor 模型的核心,根據上文的主從 Reactor 多線程模型,連接層主要有兩種 Reactor,一主(Main Reactor)多從(Sub Reactor),也可以多主多從。

Main Reactor 主要負責監聽和接收連接,接着分配連接,它裏邊有個 for 循環,不斷去 accept 新連接,這裏的方法可以叫做 acceptorLoop;Sub Reactor 拿到 Main Reactor 分配的連接,它也是個 for 循環,一直等待着讀寫事件到來,然後幹活,即回調應用層執行具體業務邏輯,它的方法可以叫做 readWriteLoop。

根據連接層的工作安排,可以發現我們需要以下三個數據結構:

每個連接都會與一個 fd 綁定,當某個連接關閉後,它會釋放掉 fd,供新連接綁定,這也叫 fd 的複用。

通常我們的應用層會在一個協程池中執行它的業務邏輯,在連接層有個 Sub Reactor 在處理這個連接上的讀寫事件。

如果在應用層那邊關閉了連接,而在 Sub Reactor 那邊剛好在準備讀這個連接上的數據,即操作這個 fd。

當 Sub Reactor 還沒來得及讀,但被應用層關閉釋放掉的 fd,已經給到了一個新連接,這時 Sub Reactor 繼續讀這個 fd 上的數據,就會把新連接的數據讀走。

因此,我們需要針對 fd 的操作前後加個鎖,即在關閉連接跟在連接上讀寫前先上鎖,關閉後才釋放掉鎖,並且在連接上讀寫前判斷連接是否關閉,這樣纔會避免髒數據。

除了注意 fd 複用帶來的競態,還有一個不可忽略的負載均衡,在 Main Reactor 分配連接到 Sub Reactor 這個環節。

未來避免某個 Sub Reactor 過載,我們可以參考 Nginx 的負載均衡策略,大概有以下三種方式:

(四)基礎層設計

Reactor 的核心的活都在連接層幹完了,基礎層的作用是提供底層系統調用支持及做好內存管理。

系統調用就是常見的 listen/accept/read/write/epoll_create/epoll_ctl/epoll_wait 等,這裏不展開。但內存管理的方式會極大地影響網絡庫的性能。

筆者曾經在處理連接上讀事件的時候,先是用動態內存池的方式提供臨時 Buffer 承接,對比使用固定 Buffer 去承接,前者需要一借一還,在某個簡單 Echo 場景下壓測,後者較前者提升了 12wQPS,恐怖如斯。

以下是常見的內存管理方案,針對連接上讀寫處理時的內存使用優劣對比:

讀寫分離,節省內存,但頻繁擴容有性能損耗(擴容時需要搬遷老數據到新 RingBuffer 上)

這裏最理想的是第三種內存管理方案,字節的 netpoll 有實現。

這裏引用某個項目的實現說明,NoCopy 體現在連接層讀到的數據,可以不用拷貝給應用層使用,而是讓應用層引用 LinkBuffer 使用。

首先來講零拷貝讀取接口,我們將讀取操作分成了「引用讀」「釋放」兩個步驟,「引用讀」會把 Linked Buffer 中一定長度的字節數組以指針的形式取出,用戶使用完這些數據後,主動執行「釋放」告知 Linked Buffer 剛剛「引用讀」的數據空間不會再被使用,可以釋放掉,被「釋放」了的數據不能再被讀取和修改。

零拷貝寫入接口則是將用戶傳入的字節數組構造成一個個節點,每個節點裏包含了字節數組的指針,再將這些節點添加到 Linked Buffer 中,自始至終都是對字節數組的指針進行操作,沒有任何的拷貝行爲。

(五)性能測試

以上 3 小節就是一個 Reactor 網絡庫的框架和實現設計,流程並不複雜,筆者認爲真正考驗的是基於 Reactor 庫去實現常見的 HTTP/1.x 協議、TLS 協議甚至 HTTP/2.0 協議等等,筆者在實現 HTTP/1.x 的時候就試了很多開源解析器,很多性能都不盡人意;在嘗試直接使用 Go 官方自帶的 TLS 協議解析器,發現 TLS 四次握手並不是連續的包,第三次握手時,客戶端發送的信息可以等一會... 大部分問題都比較棘手,這估計也是很多開源庫沒有實現這些協議的原因吧~

在開發完 Reactor 網絡庫及在這個庫的基礎上實現常見的應用層協議後,我們需要一波壓測檢驗網絡庫的性能。

區別於網上大部分開源庫只做簡單的 Echo 壓測,筆者這裏構建了兩種場景壓測:

sum := 0
for i := 0; i < 100000; i++ {
    sum += i
}

最終的結果如下 4 張圖,可以忽略字節 netpoll 的數據,大概是因爲這兩種場景並不是 netpoll 的目標場景,即 RPC 場景,所以壓測的姿勢大概率不對。

Echo 場景下是 4 核機器跑的 EchoServer,HTTP 場景下是 8 核跑的 HTTPServer。

圖 1:Echo 場景下,固定 1KB 數據包,不斷增加連接數。

圖 2:Echo 場景下,固定 1K 連接數,不斷增加數據包大小。

圖 3 和圖 4:HTTP 場景下,固定 1KB 數據包,不斷增加連接數,QPS 和內存佔用情況。

通過壓測結果,可以看出大部分壓測,Go 原生網絡庫都沒有什麼拉胯表現,只有在連接數上去了之後,或者需要處理的數據包越來越大的情況下,Go 原生網絡庫才逐漸顯示出頹勢。尤其是當連接上到 30w 到 50w 之後,Go 原生網絡庫的內存開銷增大的同時,伴隨的 GC 時間也變長,到 50w 連接的時候,一波強制 GC 服務就 down 了。

這是 Go 原生網絡庫在 50w 連接時,強制 GC 後 Down 掉時的詳情:

GC forced
gc 13 @146.006s 0%: 0.12+105+0.004 ms clock, 0.99+0/207/620+0.033 ms cpu, 5877->5877->4197 MB, 7006 MB goal, 8 P
gc 14 @197.643s 1%: 0.084+1084+0.061 ms clock, 0.67+5299/2139/1.8+0.49 ms cpu, 8187->8218->4825 MB, 8394 MB goal, 8 P
gc 15 @220.972s 1%: 4.1+1057+0.039 ms clock, 33+5215/2087/0+0.31 ms cpu, 9412->9442->4794 MB, 9651 MB goal, 8 P
GC forced

這是 Reactor 網絡庫 (wnet) 100w 連接時,依然堅挺的 GC 詳情:

gc 23 @208.600s 1%: 0.20+374+0.090 ms clock, 1.6+233/723/0+0.72 ms cpu, 873->891->450MB, 896 MB goal, 8 P
gc 24 @213.872s 1%: 0.18+419+0.051 ms clock, 1.5+4.8/830/0+0.41 ms cpu, 878->899->453MB, 900 MB goal, 8 P
gc 25 @219.270s 1%: 1.2+403+0.071 ms clock, 10+160/790/0+0.57 ms cpu, 884->907->454 MB,907 MB goal, 8 P
gc 26 @224.601s 1%: 0.12+425+0.056 ms clock, 1.0+112/849/0+0.44 ms cpu, 885->906->452MB, 908 MB goal, 8 P
gc 27 @229.851s 1%: 0.20+424+0.079 ms clock, 1.6+107/836/0+0.63 ms cpu, 881->903->453MB, 904 MB goal, 8 P
gc 28 @235.256s 1%: 0.17+431+0.038 ms clock, 1.4+77/863/0+0.30 ms cpu, 884->907->454MB, 907 MB goal, 8 P
gc 29 @240.622s 1%: 0.15+402+0.039 ms clock, 1.2+117/804/0+0.31 ms cpu, 885->907->452MB, 908 MB goal, 8 P
GC forced

因此,綜合來看,大部分應用場景,Go 原生網絡庫就可以滿足。相比 Reactor 網絡庫而言,Go 原生網絡庫可以看作是以空間(內存、runtime)來換取時間(高吞吐量和低延時)。當空間緊張時,也就是連接數上來後,巨大的內存開銷和相應的 GC 會導致服務不可用,而這種海量連接場景纔是 Reactor 網絡庫的優勢所在。比如電商大促等活動型場景,有預期的流量高峯,在高峯期會有海量的連接,海量的請求;還有一種直播彈幕、消息推送等長連接場景,也是有大量的長連接。

四、後記

本文的最終實現項目並未開源,讀者朋友可以結合上述流程翻閱類似的開源實現,比如 gnet、gev 等項目理解 Reactor 網絡庫的設計,並基於第三部分的設計內容重構這些開源項目,相信讀者朋友會做出更好的網絡庫。

** 作者簡介**

劉祥裕

騰訊後臺開發工程師

騰訊後臺工程師,目前主要負責電競賽事相關服務開發。

騰訊雲開發者 騰訊雲官方社區公衆號,匯聚技術開發者羣體,分享技術乾貨,打造技術影響力交流社區。

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