一篇學(xué)會(huì) Go 網(wǎng)絡(luò)庫(kù) Gnet 解析
開篇
我們分析了Go原生網(wǎng)絡(luò)模型以及部分源碼,絕大部分場(chǎng)景下(99%),使用原生netpoll已經(jīng)足夠了。
但是在一些海量并發(fā)連接下,原生netpoll會(huì)為每一個(gè)連接都開啟一個(gè)goroutine處理,也就是1千萬的連接就會(huì)創(chuàng)建一千萬個(gè)goroutine。
這就給了這些特殊場(chǎng)景下的優(yōu)化空間,這也是像gnet和cloudwego/netpoll誕生的原因之一吧。
本質(zhì)上他們的底層核心都是一樣的,都是基于epoll(linux)實(shí)現(xiàn)的。只是事件發(fā)生后,每個(gè)庫(kù)的處理方式會(huì)有所不同。
本篇文章主要分析gnet的。至于使用姿勢(shì)就不發(fā)了,gnet有對(duì)應(yīng)的demo庫(kù),可以自行體驗(yàn)。
架構(gòu)
直接引用gnet官網(wǎng)的一張圖:
gnet采用的是『主從多 Reactors』。也就是一個(gè)主線程負(fù)責(zé)監(jiān)聽端口連接,當(dāng)一個(gè)客戶端連接到來時(shí),就把這個(gè)連接根據(jù)負(fù)載均衡算法分配給其中一個(gè)sub線程,由對(duì)應(yīng)的sub線程去處理這個(gè)連接的讀寫事件以及管理它的死亡。
下面這張圖就更清晰了。
核心結(jié)構(gòu)
我們先解釋gnet的一些核心結(jié)構(gòu)。
engine就是程序最上層的結(jié)構(gòu)了。
- ln對(duì)應(yīng)的listener就是服務(wù)啟動(dòng)后對(duì)應(yīng)監(jiān)聽端口的監(jiān)聽器。
- lb對(duì)應(yīng)的loadBalancer就是負(fù)載均衡器。也就是當(dāng)客戶端連接服務(wù)時(shí),負(fù)載均衡器會(huì)選擇一個(gè)sub線程,把連接交給此線程處理。
- mainLoop 就是我們的主線程了,對(duì)應(yīng)的結(jié)構(gòu)eventloop。當(dāng)然我們的sub線程結(jié)構(gòu)也是eventloop。結(jié)構(gòu)相同,不同的是職責(zé)。主線程負(fù)責(zé)的是監(jiān)聽端口發(fā)生的客戶端連接事件,然后再由負(fù)載均衡器把連接分配給一個(gè)sub線程。而sub線程負(fù)責(zé)的是綁定分配給他的連接(不止一個(gè)),且等待自己管理的所有連接后續(xù)讀寫事件,并進(jìn)行處理。
接著看eventloop。
- netpoll.Poller:每一個(gè) eventloop都對(duì)應(yīng)一個(gè)epoll或者kqueue。
- buffer用來作為讀消息的緩沖區(qū)。
- connCoun記錄當(dāng)前eventloop存儲(chǔ)的tcp連接數(shù)。
- udpSockets和connetcions分別管理著這個(gè)eventloop下所有的udp socket和tcp連接,注意他們的結(jié)構(gòu)map。這里的int類型存儲(chǔ)的就是fd。
對(duì)應(yīng)conn結(jié)構(gòu)。
這里面有幾個(gè)字段介紹下:
- buffer:存儲(chǔ)當(dāng)前conn對(duì)端(client)發(fā)送的最新數(shù)據(jù),比如發(fā)送了三次,那個(gè)此時(shí)buffer存儲(chǔ)的是第三次的數(shù)據(jù),代碼里有。
- inboundBuffer:存儲(chǔ)對(duì)端發(fā)送的且未被用戶讀取的剩余數(shù)據(jù),還是個(gè)Ring Buffer。
- outboundBuffer:存儲(chǔ)還未發(fā)送給對(duì)端的數(shù)據(jù)。(比如服務(wù)端響應(yīng)客戶端的數(shù)據(jù),由于conn fd是不阻塞的,調(diào)用write返回不可寫的時(shí)候,就可以先把數(shù)據(jù)放到這里)
conn相當(dāng)于每個(gè)連接都會(huì)有自己獨(dú)立的緩存空間。這樣做是為了減少集中式管理內(nèi)存帶來的鎖問題。使用Ring buffer是為了增加空間的復(fù)用性。
整體結(jié)構(gòu)就這些。
核心邏輯
當(dāng)程序啟動(dòng)時(shí),
會(huì)根據(jù)用戶設(shè)置的options明確eventloop循環(huán)的數(shù)量,也就是有多少個(gè)sub線程。再進(jìn)一步說,在linux環(huán)境就是會(huì)創(chuàng)建多少個(gè)epoll對(duì)象。
那么整個(gè)程序的epoll對(duì)象數(shù)量就是count(sub)+1(main Listener)。
上圖就是我說的,會(huì)根據(jù)設(shè)置的數(shù)量創(chuàng)建對(duì)應(yīng)的eventloop,把對(duì)應(yīng)的eventloop 注冊(cè)到負(fù)載均衡器中。
當(dāng)新連接到來時(shí),就可以根據(jù)一定的算法(gnet提供了輪詢、最少連接以及hash)挑選其中一個(gè)eventloop把連接分配給它。
我們先來看主線程,(由于我使用的是mac,所以后面關(guān)于IO多路復(fù)用,實(shí)現(xiàn)部分就是kqueue代碼了,當(dāng)然原理是一樣的)。
Polling就是等待網(wǎng)絡(luò)事件到來,傳遞了一個(gè)閉包參數(shù),更確切的說是一個(gè)事件到來時(shí)的回調(diào)函數(shù),從名字可以看出,就是處理新連接的。
至于Polling函數(shù)。
邏輯很簡(jiǎn)單,一個(gè)for循環(huán)等待事件到來,然后處理事件。
主線程的事件分兩種:
一種是正常的fd發(fā)生網(wǎng)絡(luò)連接事件。
一種是通過NOTE_TRIGGER立即激活的事件。
通過NOTE_TRIGGER觸發(fā)告訴你隊(duì)列里有task任務(wù),去執(zhí)行task任務(wù)。
如果是正常的網(wǎng)絡(luò)事件到來,就處理閉包函數(shù),主線程處理的就是上面的accept連接函數(shù)。
accept連接邏輯很簡(jiǎn)單,拿到連接的fd。設(shè)置fd非阻塞模式(想想連接是阻塞的會(huì)咋么樣?),然后根據(jù)負(fù)載均衡算法選擇一個(gè)sub 線程,通過register函數(shù)把此連接分配給它。
register做了兩件事,首先需要把當(dāng)前連接注冊(cè)到當(dāng)前sub 線程的epoll or kqueue 對(duì)象中,新增read的flag。
接著就是把當(dāng)前連接放入到connections的map結(jié)構(gòu)中 fd->conn。
這樣當(dāng)對(duì)應(yīng)的sub線程事件到來時(shí),可以通過事件的fd找到是哪個(gè)連接,進(jìn)行相應(yīng)的處理。
如果是可讀事件。
到這里分析差不多就結(jié)束了。
總結(jié)
在gnet里面,你可以看到,基本上所有的操作都無鎖的。
那是因?yàn)槭录絹頃r(shí),采取的都是非阻塞的操作,且是串行處理對(duì)應(yīng)的每個(gè)fd(conn)。每個(gè)conn操作的都是自身持有的緩存空間。同時(shí)處理完一輪觸發(fā)的所有事件才會(huì)循環(huán)進(jìn)入下一次等待,在此層面上解決了并發(fā)問題。
當(dāng)然這樣用戶在使用的時(shí)候也需要注意一些問題,比如用戶在自定義EventHandler中,如果要異步處理邏輯,就不能像下面這樣開一個(gè)g然后在里面獲取本次數(shù)據(jù)。
而應(yīng)該先拿到數(shù)據(jù),再異步處理。
issues上有提到,連接是使用map[int]*conn存儲(chǔ)的。gnet本身的場(chǎng)景就是海量并發(fā)連接,內(nèi)存會(huì)很大。進(jìn)而big map存指針會(huì)對(duì) GC造成很大的負(fù)擔(dān),畢竟它不像數(shù)組一樣,是連續(xù)內(nèi)存空間,易于GC掃描。
還有一點(diǎn),在處理buffer數(shù)據(jù)的時(shí)候,就像上面看到的,本質(zhì)上是將buffer數(shù)據(jù)copy給用戶一份,那么就存在大量copy開銷,在這一點(diǎn)上,字節(jié)的netpoll實(shí)現(xiàn)了Nocopy Buffer,改天研究一下。