我們分析了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,改天研究一下。
X 關閉
X 關閉