深入解析 nbio 框架

圖片拍攝於 2023 年 3 月 18 日 杭州大屋頂

所到之處處處皆 Ai,所見之人人人皆唸咒。

之前更新的一系列,好久沒更新了,差點爛尾,我不允許這樣的事情在我身上發生 (雖然已經爛尾好幾次了😭)。

evio 原理解析~有彩蛋

Go 網絡庫 Gnet 解析

Go netpoll 大解析

在上一篇文章中,我們探討了基於 epoll 的 Go Netpoll 框架的早期實現——evio。我們還指出了它存在的一些問題。在本篇文章中,我們將繼續深入分析另一個高性能的網絡編程框架:nbio。

nbio 項目裏也包含了在 nbio 之上構建的 nbhttp,這個不在我們討論範圍。

nbio 同樣採用了經典的 Reactor 模式,事實上,Go 語言中的許多異步網絡框架都是基於這種模式設計的。

老規矩,先運行 nbio 程序代碼,

Server:

使用 nbio.NewGopher() 函數創建一個新的 Engine 實例。傳入 nbio.Config 結構體來配置 Engine 實例,包括:

其他配置可以自行查看。

然後使用 g.OnData() 方法爲 Engine 實例註冊一個數據接收回調函數。這個回調函數在收到數據時被調用。

回調函數接收兩個參數:連接對象 c 和收到的數據 data。在回調函數內部,我們使用 c.Write() 方法將收到的數據原樣寫回給客戶端。

Client:

乍一看有點麻煩。其實是服務端和客戶端共用了一套結構。

客戶端通過 nbio.Dial 連接服務端,連接成功封裝成 nbio.Conn,AddConn 這個 conn。

這裏的 nbio.Conn 是實現了標準庫的 net.Conn 接口的

接着調用 Write 往服務端寫數據,當服務端接收到數據後,Server 端的處理邏輯是把數據原樣發送給客戶端,當客戶端接收到數據一樣 OnData 會被回調,最後客戶端主動關閉這個連接。

下面來看幾個主要結構。

Engine 本質上就是核心管理器。會管理所有的 listener poller 和 worker poller。這兩種 poller 有什麼區別嗎?

區別在職責上。

listener poller 只負責 accept 新的連接,當一個新的客戶端 conn 到來時,會從 pollers 挑選一個 worker poller,然後把 conn 加入到對應的 worker poller,之後 worker poller 負責處理此 conn 的讀寫事件。

所以當我們啓動程序的時候,如果只監聽一個地址的情況下,那麼程序的 poll 數 = 1(listener poller) + pollerNum。

從上面的字段也可以看出,你可以自定義一些配置和回調。比如你可以設置當新連接到來時的回調函數 onOpen,也可以設置一個 conn 數據到來時的回調函數 onData 等。

Conn 結構體,用於表示一個網絡連接。一個 conn 只屬於一個 poller。對應的 writeBuffer: 當數據一次沒寫完時,剩下的先存在 writeBuffer,等待下次可寫事件到來繼續寫入。

至於 poller 結構,這裏就是一個抽象的概念,用於管理底層的多路複用 I/O(如 linux 的 epoll、darwin 上的 kqueue 等)

注意這裏的 pollType,nbio 默認 epoll 採用的是水平觸發 (LT),當然用戶也可以設置成邊緣觸發 (ET)。

介紹完基本的結構,接下來進入代碼的流程。

上面服務端的代碼,當你調用 Start 啓動程序後,

代碼還是易懂的,整體看就四個部分。

第一部分:初始化 listener

根據 g.network 的值(如 "unix", "tcp", "tcp4", "tcp6"),爲每個要監聽的地址(g.addrs)創建一個新的 poller。這裏的 poller 主要用於管理監聽套接字上的事件。如果創建 poller 時出錯,將停止之前創建的所有監聽器並返回錯誤。

第二部分:初始化一定數量的 poller

根據 pollerNum 創建對應個數的 worker poller。這些 poller 用於處理已連接套接字上的讀 / 寫事件。如果在創建過程中遇到錯誤,將停止所有監聽器和之前創建的工作 poller,然後返回錯誤。

第三部分:啓動所有的 worker poller

爲每個工作 poller 分配一個讀緩衝區(由 g.readBufferSize 決定大小),併發地啓動這些 poller。

第四部分:啓動所有的 listener

啓動所有之前創建的監聽器。開始監聽對應地址的連接請求。

至於 poller 的啓動,

分爲兩種,如果是一個 listener poller,

listener poller 就是等待新連接,然後通過 NBConn 封裝成 nbio conn 結構,最後通過取模操作獲取其中一個 woker poller。把連接加入到對應的 poller 中。

這裏一個有趣的設計,在管理 conns 上,結構是 slice,作者直接使用的 conn 的 fd 來作爲下標。

這樣還是有好處的,

最後通過調用 addRead 把對應的 conn fd 加入到 epoll。

這裏沒有註冊寫事件是合理的,因爲在新連接上還沒有收到任何數據,所以暫時沒有需要發送的數據。這種做法可以避免一些不必要的系統調用,從而提高程序的性能。

如果是 worker poller 的啓動,它的工作就是等待加入的那些 conns 的事件到來,進行對應的處理。

這段代碼也很好理解。等待事件到來,遍歷對應的事件列表,判斷事件類型,相對應的處理。

EpollWait 中只有 msec 是可以用戶動態修改的,通常情況下,我們主動調用 EpollWait 都會設置 msec=-1,msce=-1 會使得函數一直等待,直到至少有一個事件發生,否則的話一直阻塞。這種方法在事件發生較少的情況下非常有用,因爲它可以最大限度地減少 CPU 佔用率。

如果希望儘可能快速響應事件,可以將 msec 設置爲 0。這將使 EpollWait 立即返回,不等待任何事件。這種情況下,你的程序可能會更頻繁地調用 EpollWait,但能夠在事件發生後立即處理它們。當然,這就會導致 CPU 佔用率較高。

如果你的程序可以承受一定的延遲,並希望減少 CPU 佔用率,可以將 msec 設置爲一個正數。這將使得 EpollWait 在指定的時間內等待事件。如果在這段時間內沒有事件發生,函數將返回,你可以選擇在稍後再次調用 EpollWait。這種方法可以降低 CPU 佔用率,但可能會導致較長的響應時間。

nbio 對應這個值的調整策略是: 當事件數量大於 0 時,msec=20(這個 20 應該是作者測試後綜合考量?)。

字節跳動的 netpoll 代碼是這樣的,如果事件數量大於 0,會設置 msec 爲 0。如果事件小於等於 0,設置 msec=-1,然後調用 Gosched() 使得當前 Goroutine 主動讓出 P。

然而,nbio 中主動切換的代碼已經被註釋掉了。根據作者在 issue 中的解釋,最初他參考了字節的方法加入了主動切換。但在對 nbio 進行性能測試時,發現加入與不加入主動切換對性能沒有明顯區別,因此最終決定將其移除。

事件的處理部分,

如果是可讀事件,你可以通過內置或者自定義的內存分配器來得到相應的 buffer,然後調用 ReadAndGetConn 讀取數據,而不需要每次都申請一遍 buffer。

如果是可寫事件的話,會調用 flush 把 buffer 裏未發送的數據發送出去。

邏輯也很簡單,寫多少是多少,寫不進去把剩下的重新放入 writeBuffer,下輪 epollWait 觸發再寫。

如果寫完了,那就沒數據可寫了,重置這個 conn 的事件爲讀事件。

主邏輯就差不多是這樣了。

等等,我們一開始說一個新連接進來的時候,我們對一個連接只註冊了讀事件,沒註冊寫事件,寫事件是什麼時候註冊的?

當然是你調用 conn.Write 的時候,

當 conn 的數據到來時,底層讀完數據,會回調 OnData 函數,此時你可以調用 Write 向對端發送數據,

當數據沒寫完,就把剩餘數據放入到 writeBuffer,這樣就會觸發執行 modWrite,就會把 conn 的寫事件註冊到 epoll 中。

總結

相較於 evio,nbio 不會出現驚羣效應。

evio 是通過不斷無效的喚醒 epoll,來達到邏輯的正確性。而 nbio 是儘可能的減少系統調用,減少無謂的開銷。

易用性上,nbio 實現了標準庫 net.Conn,同時很多設置可配置化,用戶可以自由定製,自由度較高。

讀寫上會使用預先分配的 buffer,提高應用性能。

總之,nbio 是一款不錯的高性能非阻塞的網絡框架。

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