Go 網絡庫 Gnet 解析
圖片拍攝於 2022 年 6 月 5 日 杭州 勇士總冠軍!!!
距離上次寫文章過了一月有餘,這段時間着實太躺了。以至於昨晚做了一個噩夢,醒來的時候狠狠的抽了自己兩巴掌,不能這麼躺了。
上面當然是個笑話。
開篇
上一篇 Go netpoll 大解析我們分析了 Go 原生網絡模型以及部分源碼,絕大部分場景下 (99%),使用原生 netpoll 已經足夠了。
但是在一些海量併發連接下,原生 netpoll 會爲每一個連接都開啓一個 goroutine 處理,也就是 1 千萬的連接就會創建一千萬個 goroutine。
這就給了這些特殊場景下的優化空間,這也是像 gnet 和 cloudwego/netpoll 誕生的原因之一吧。
本質上他們的底層核心都是一樣的,都是基於 epoll(linux) 實現的。只是事件發生後,每個庫的處理方式會有所不同。
本篇文章主要分析 gnet 的。至於使用姿勢就不發了,gnet 有對應的 demo 庫,可以自行體驗。
架構
直接引用 gnet 官網的一張圖
gnet 採用的是『主從多 Reactors』。也就是一個主線程負責監聽端口連接,當一個客戶端連接到來時,就把這個連接根據負載均衡算法分配給其中一個 sub 線程,由對應的 sub 線程去處理這個連接的讀寫事件以及管理它的死亡。
下面這張圖就更清晰了。
核心結構
我們先解釋 gnet 的一些核心結構。
engine 就是程序最上層的結構了。
-
ln 對應的 listener 就是服務啓動後對應監聽端口的監聽器。
-
lb 對應的 loadBalancer 就是負載均衡器。也就是當客戶端連接服務時,負載均衡器會選擇一個 sub 線程,把連接交給此線程處理。
-
mainLoop 就是我們的主線程了,對應的結構 eventloop。當然我們的 sub 線程結構也是 eventloop。結構相同,不同的是職責。主線程負責的是監聽端口發生的客戶端連接事件,然後再由負載均衡器把連接分配給一個 sub 線程。而 sub 線程負責的是綁定分配給他的連接 (不止一個),且等待自己管理的所有連接後續讀寫事件,並進行處理。
接着看 eventloop。
-
netpoll.Poller: 每一個 eventloop 都對應一個 epoll 或者 kqueue。
-
buffer 用來作爲讀消息的緩衝區。
-
connCoun 記錄當前 eventloop 存儲的 tcp 連接數。
-
udpSockets 和 connetcions 分別管理着這個 eventloop 下所有的 udp socket 和 tcp 連接,注意他們的結構 map。這裏的 int 類型存儲的就是 fd。
對應 conn 結構,
這裏面有幾個字段介紹下,
-
buffer: 存儲當前 conn 對端 (client) 發送的最新數據,比如發送了三次,那個此時 buffer 存儲的是第三次的數據, 代碼裏有。
-
inboundBuffer: 存儲對端發送的且未被用戶讀取的剩餘數據,還是個 Ring Buffer。
-
outboundBuffer: 存儲還未發送給對端的數據。(比如服務端響應客戶端的數據,由於 conn fd 是不阻塞的,調用 write 返回不可寫的時候,就可以先把數據放到這裏)
conn 相當於每個連接都會有自己獨立的緩存空間。這樣做是爲了減少集中式管理內存帶來的鎖問題。使用 Ring buffer 是爲了增加空間的複用性。
整體結構就這些。
核心邏輯
當程序啓動時,
會根據用戶設置的 options 明確 eventloop 循環的數量,也就是有多少個 sub 線程。再進一步說,在 linux 環境就是會創建多少個 epoll 對象。
那麼整個程序的 epoll 對象數量就是 count(sub)+1(main Listener)。
上圖就是我說的,會根據設置的數量創建對應的 eventloop, 把對應的 eventloop 註冊到負載均衡器中。
當新連接到來時,就可以根據一定的算法 (gnet 提供了輪詢、最少連接以及 hash) 挑選其中一個 eventloop 把連接分配給它。
我們先來看主線程,(由於我使用的是 mac, 所以後面關於 IO 多路複用,實現部分就是 kqueue 代碼了,當然原理是一樣的)
Polling 就是等待網絡事件到來,傳遞了一個閉包參數,更確切的說是一個事件到來時的回調函數,從名字可以看出,就是處理新連接的。
至於 Polling 函數,
邏輯很簡單,一個 for 循環等待事件到來,然後處理事件。
主線程的事件分兩種,
一種是正常的 fd 發生網絡連接事件,
一種是通過 NOTE_TRIGGER 立即激活的事件。
通過 NOTE_TRIGGER 觸發告訴你隊列裏有 task 任務,去執行 task 任務。
如果是正常的網絡事件到來,就處理閉包函數,主線程處理的就是上面的 accept 連接函數。
accept 連接邏輯很簡單,拿到連接的 fd。設置 fd 非阻塞模式 (想想連接是阻塞的會咋麼樣?), 然後根據負載均衡算法選擇一個 sub 線程,通過 register 函數把此連接分配給它。
register 做了兩件事,首先需要把當前連接註冊到當前 sub 線程的 epoll or kqueue 對象中, 新增 read 的 flag。
接着就是把當前連接放入到 connections 的 map 結構中 fd->conn。
這樣當對應的 sub 線程事件到來時,可以通過事件的 fd 找到是哪個連接,進行相應的處理。
如果是可讀事件,
到這裏分析差不多就結束了。
總結
在 gnet 裏面,你可以看到,基本上所有的操作都無鎖的。
那是因爲事件到來時,採取的都是非阻塞的操作,且是串行處理對應的每個 fd(conn)。每個 conn 操作的都是自身持有的緩存空間。同時處理完一輪觸發的所有事件纔會循環進入下一次等待,在此層面上解決了併發問題。
當然這樣用戶在使用的時候也需要注意一些問題,比如用戶在自定義 EventHandler 中,如果要異步處理邏輯,就不能像下面這樣開一個 g 然後在裏面獲取本次數據,
而應該先拿到數據,再異步處理。
issues 上有提到,連接是使用 map[int]*conn 存儲的。gnet 本身的場景就是海量併發連接,內存會很大。進而 big map 存指針會對 GC 造成很大的負擔,畢竟它不像數組一樣,是連續內存空間,易於 GC 掃描。
還有一點,在處理 buffer 數據的時候,就像上面看到的,本質上是將 buffer 數據 copy 給用戶一份,那麼就存在大量 copy 開銷, 在這一點上,字節的 netpoll 實現了 Nocopy Buffer,改天研究一下。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/aBdvYvoIO2FTMTPDY_IFYQ