evio 原理解析
圖片拍攝於2022年11月26日 大屋頂
之前分析過go自帶的netpoll,以及自建的網絡框架gnet。這類框架還有:evio、gev、nbio、cloudwego/netpoll(字節的)
爲什麼會出現這麼多自建框架?
我覺得逃不過三點,
-
自帶的 netpoll 滿足不了一些特殊場景。
-
其他實現設計存在侷限性,存在優化空間。
-
程序員都喜歡自己造輪子。
另外,這類框架都是基於 syscall epoll 實現的事件驅動框架。主要區別我覺得在於,
-
對連接 conn 的管理
-
對讀寫數據管理
帶着這些問題,我打算把這些框架都看一遍。學習裏面優秀的設計以及對比他們的不同點,可以的話,做個整體的性能測試。
這幾個框架中,evio 是最早的開源實現,開源於 2017 年。
有意思的是,看到幾篇文章說 evio 存在當 loopWrite 在內核緩衝區滿,無法一次寫入時,會出現寫入數據丟失的 bug。
仔細閱讀了代碼,evio 並不存在這個 bug。
也不存在是作者後來修復了這個 bug,而是 evio 本身不存在這個 bug,下面會說明。
原理解析
根據代碼畫個簡易的 evio 架構圖。
簡單解釋一下,evio 啓動的時候可以指定 loops 個數,即多少個 epoll 實例。同時可以啓動多個監聽地址,比如圖中監聽了兩個端口。
程序會把每個 Listener fd 加入到每個 epoll 並註冊這些 fd 的讀事件。每個 epoll 會開啓一個 goroutine 等待事件到來。
當客戶端發起對應端口連接,程序會根據策略選擇一個 epoll,並把 conn fd 也加入到此 epoll 並註冊讀寫事件。
當一個 conn fd 讀事件 ready,那麼對應的 epoll 會被喚醒,然後執行相應的操作。
以上就是整理的流程,接下來我們來深入一些細節。
在此之前,根據上面所描述的,
-
當一個新的客戶端連接到來時,會發生什麼?
-
讀寫數據是如何流動的?
-
同一個 epoll 裏多個 fd 讀寫事件 ready,程序是如何處理的?
看完下面,再回來回答這三個問題。
代碼細節
運行一個簡單 demo,
我們指定 NumLoops 的數量是 3,然後傳入了兩個地址。
上面還有兩個閉包函數,當服務啓動的時候會回調 events.Serving 函數,然後返回一個 Action。
比如當你返回一個 Shutdown 的 action,那麼程序就直接退出了。
當有客戶端數據到來時,回調 events.Data 函數,返回 out 和 action,out 表示要發送的數據。
最終調用 evio.Serve 函數,傳入兩個地址,啓動服務。
我刪除了一些無關代碼。
Serve 函數里面遍歷傳入的地址識別協議,執行對應 listen 操作。udp 返回一個 PacketConn,而 tcp 返回一個 Listener。最終用自定義的 listener 統一表示。最終調用 serve。
這個函數邏輯:
-
先初始化一個自定義 server 結構,確定負載均衡算法。
-
回調自定義的 serving 閉包函數。
-
根據 numloops 值,創建對應數量的 epoll 封裝在自定義結構 loop 中。並把每一個 listener 對應的 fd 加入到每一個 epoll 同時註冊 fd 的讀事件,
- 遍歷 loops, 每個 loop 都用一個 g 執行 loopRun 函數。
至於 loopRun 函數,
每個 loop 都會調用 epoll.Wait 函數阻塞等待事件到來,參數時一個閉包函數,每一個到達的事件都會回調此閉包執行相應操作。
回到上一步,
注意這裏是每個 loop 都會調用自己的 epoll.Wait。當對應的 Listener 來了一個新客戶端連接,所有的 epoll 都會被 “驚醒”,這就是驚羣效應。
驚羣效應(thundering herd)是指多進程(多線程)在同時阻塞等待同一個事件的時候(休眠狀態),如果等待的這個事件發生,那麼它就會喚醒等待的所有進程(或者線程)
然後所有的 loop 都會執行 loopAccept 函數,
loopAccept 做了幾件事:
-
確定就緒的 fd 是哪個 listener
-
如果 loops 大於 1,通過策略選擇其中一個 loop, 包裝 conn fd 且設置 conn fd 爲非阻塞 (思考下如果讓 conn fd 保持阻塞狀態,會影響到什麼?)
-
最後把這個 conn fd 加入到當前 epoll 並且註冊讀寫事件
因此當一個新的客戶端連接到來時,會發生什麼?
會產生驚羣效應。
那如果是已存在的 conn fd 的可讀事件,會發生驚羣效應嗎?
不會,因爲一個 conn fd 只會加入到其中一個 epoll 中。
因爲 evio 創建 epoll 的時候默認是水平觸發 LT(level-triggered),當加入的 conn fd 包含寫事件時,如果此時內核寫緩衝區空間未滿,那麼 epoll 會再次被喚醒。
此時通過 f.fdconns[fd] 會找到對應的 conn,由於連接初始化並未設置 opened 的直,因此會進入 loopOpened 函數。
無非是一些簡單賦值操作,如果設置了 Opened 閉包函數,那麼回調它。
最後如果 out 沒有可發送的事件,那麼就重新把此 conn fd 修改成讀事件。
想象一下,如果在沒有可寫數據的情況下,加入 epoll 的 conn fd 註冊含有寫事件,那麼只要內核寫緩存區未滿,此 epoll 會不斷被喚醒,我稱它是空轉。
如果上面你設置了 Opened 閉包函數且最終 action 設置了值,那麼就還是保持此 conn fd 的讀寫事件。
這時候再次喚醒的 epoll 會執行 loopAction,
上面代碼很簡單。
當 client 發送數據,epoll 被喚醒,執行 loopRead。
先讀取數據,如果有數據要發送,又會修改 conn fd 爲讀寫事件。當 epoll 再次被喚醒,且 c.out 不爲空時,執行 loopWrite。
如果一次寫完數據,那麼說明暫時沒有可寫的數據了,重新修改 conn fd 爲讀事件。
如果一次沒寫完,那麼保留未寫完的數據,下次 epoll 喚醒的時候繼續寫。
上面提到,一些文章提到,evio 存在 loopWrite 在內核緩衝區滿,無法一次寫入時,會出現寫入數據丟失的 bug。
給的理由是,當內核寫緩衝區滿了,可數據並未寫完。此時另一個 conn 讀事件 ready,會執行 loopRead。
loopRead 有這麼一段代碼,
那麼此時之前未發送完的數據就會被覆蓋,導致數據丟失。
但是我仔細看了代碼,並不存在這個 bug。
因爲如果第一次沒寫完,假設此時同一個 epoll 下的另一個 conn 讀事件 ready,由於 out 還有未寫完的數據,只會執行 loopWrite 分支, 並不會走到默認分支 loopRead,也就不存在寫數據被覆蓋導致丟失的問題了。
有趣的是,這樣的情況會導致空轉。因爲執行 loopWrite 邏輯,由於內核寫緩衝區已滿,導致寫不進去數據,會出現 syscall.EAGAIN 直接返回。又因此時還有可讀的數據沒讀,會不斷喚醒 epoll。
調用 epoll_wait 會陷入內核態,所以會導致不斷的在用戶態和內核態切換。直到寫完數據,才能讀其他 conn 數據。
到這裏,核心的代碼已經分析完了。
Evio 存在的問題
驚羣效應
串行化
從上面的分析可以看出,如果一個 epoll 有兩個 fd 可讀事件 ready,那麼第二個 fd 必須等第一個執行完畢,纔開始執行。
換句話說,如果這個閉包函數里有外部依賴調用,第二個就得一直等。
不能在 Data 函數里用 go func 嗎?
還真不能,要是這樣的話又會涉及到數據併發問題,數據會發生錯亂。
同一個 epoll 下的 conn 是共享數據結構的,如果使用異步,必然又涉及到鎖的問題。
數據 copy 問題
evio 採用的是同步處理 buffer 數據,直接通過 syscall 讀寫操作存在 copy 開銷,這是 cpu 直接參與的。看字節的 netpoll 使用的 zero-copy 的技術,後面再看源碼。
頻繁喚醒 epoll
evio 會通過不斷修改 conn fd 的事件來喚醒 epoll,達到邏輯上的正確性。頻繁喚醒的方式並不是很妥,這種方式是存在開銷的。
總結
這篇文章到這裏就結束了。分析完 go 自建 netpoll "鼻祖" evio,以及它存在的侷限性,就可以繼續學習後續其他框架的設計了。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/fjHV5JmbY_8BzyRWmGFfxw