Go netpoll 大解析

圖片拍攝於 2022 年 4 月 3 日 杭州

開篇

之前簡單看過一點 go 原生 netpoll,沒注意太多細節。最近從頭到尾看了一遍,特寫篇文章記錄下。文章很長,請耐心看完,一定有所收穫。

內核空間和用戶空間

在 linux 中,經常能看到兩個詞語: User space(用戶空間) 和 Kernel space (內核空間)。

簡單地說, Kernel space 是 linux 內核運行的空間,User space 是用戶程序運行的空間。它們之間是相互隔離的。

現代操作系統都是採用虛擬存儲器。那麼對 32 位操作系統而言,它的尋址空間(虛擬存儲空間)爲 4G(2 的 32 次方)。

操作系統的核心是內核,獨立於普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的所有權限。

爲了保證用戶進程不能直接操作內核,保證內核的安全,系統將虛擬空間劃分爲兩部分,一部分爲內核空間,一部分爲用戶空間。

針對 linux 操作系統而言,將最高的 1G 字節(從虛擬地址 0xC0000000 到 0xFFFFFFFF),供內核使用,稱爲內核空間,而將較低的 3G 字節(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個進程使用,稱爲用戶空間。空間分配如下圖所示:

Kernel space 可以調用系統的一切資源。User space 不能直接調用系統資源,在 Linux 系統中,所有的系統資源管理都是在內核空間中完成的。

比如讀寫磁盤文件、分配回收內存、從網絡接口讀寫數據等等。應用程序無法直接進行這樣的操作,但是用戶程序可以通過內核提供的接口來完成這樣的任務。

像下面這樣,

應用程序要讀取磁盤上的一個文件,它可以向內核發起一個 “系統調用” 告訴內核:” 我要讀取磁盤上的某某文件”。其實就是通過一個特殊的指令讓進程從用戶態進入到內核態。

在內核空間中,CPU 可以執行任何的指令,當然也包括從磁盤上讀取數據。

具體過程是先把數據讀取到內核空間中,然後再把數據拷貝到用戶空間並從內核態切換到用戶態。

此時應用程序已經從系統調用中返回並且拿到了想要的數據,繼續往下執行用戶空間執行邏輯。

這樣的話,一旦涉及到對 I/O 的處理,就必然會涉及到在用戶態和內核態之間來回切換。

io 模型

網上有太多關於 I/O 模型的文章,看着看着有可能就跑偏了,所以我還是從 <<UNIX 網絡編程>> 中總結的 5 中 I/O 模型說起吧。

Unix 可用的 5 種 I/O 模型。

阻塞 I/O

阻塞式 I/O 下,進程調用 recvfrom,直到數據到達且被複制到應用程序的緩衝區中或者發生錯誤才返回,在整個過程進程都是被阻塞的。

非阻塞 I/O

從圖中可以看出,前三次調用 recvfrom 中沒有數據可返回,因此內核轉而立即返回一個 EWOULDBLOCK 錯誤。

第四次調用 recvfrom 時已有一個數據報準備好,它被複制到應用程序緩衝區,於是 recvfrom 成功返回。

當一個應用程序像這樣對一個非阻塞描述符循環調用 recvfrom 時,我們通常稱爲輪詢 (polling),持續輪詢內核,以這種方式查看某個操作是否就緒。

I/O 多路複用

有了 I/O 多路複用 (I/O multiplexing),我們就可以調用 select 或者 poll,阻塞在這兩個系統調用中的某一個之上,而不是阻塞在真正的 I/O 系統調用上。

上面這句話難理解是吧。

說白了這裏指的是,在第一步中,我們只是阻塞在 select 調用上,直到數據報套接字變爲可讀,返回可讀條件,這裏並沒有發生 I/O 事件,所以說這一步,並沒有阻塞在真正的 I/O 系統調用上。

其他兩種就不過多介紹了。還有一點,我們會經常提到同步 I/O 和異步 I/O。

POSIX 把這兩種術語定義如下:

基於上面的定義,

異步 I/O 的關鍵在於第二步的 recrfrom 是否會阻塞住用戶進程,如果不阻塞,那它就是異步 I/O。從上面彙總圖中可以看出,只有異步 I/O 滿足 POSIX 中對異步 I/O 的定義。

Go netpoller

Go netpoller 底層就是對 I/O 多路複用的封裝。不同平臺對 I/O 多路複用有不同的實現方式。比如 Linux 的 select、poll 和 epoll。

在 MacOS 則是 kqueue, 而 Windows 是基於異步 I/O 實現的 icop......,基於這些背景,Go 針對不同的平臺調用實現了多版本的 netpoller。

下面我們通過一個 demo 開始講解。

很簡單一個 demo,開啓一個 tcp 服務。然後每來一個連接,就啓動一個 g 去處理連接。處理完畢,關閉連接。

而且我們使用的是同步的模式去編寫異步的邏輯,一個連接對應一個 g 處理,極其簡單和易於理解。go 標準庫中的 http.server 也是這麼幹的。

針對上面的 tcp 服務 demo,我們需要關注這段代碼底層都發生了什麼。

上面代碼中主要涉及底層的一些結構。

先簡單解釋一波。

當然圖上面結構字段都是閹割版的,但是不影響我們這篇文章。

還有一個問題,爲什麼結構上需要一層一層嵌入呢?我的理解是每下一層都是更加抽象的一層。它是可以作爲上一層具體的一種應用體現。

是不是跟沒說一樣?哈哈。

舉例,比如這裏的 netFD 表示網絡描述符。

它的上一層可以是用於 TCP 的網絡監聽器 TCPListener,那麼對應的接口我們能想到的有兩個 Accept 以及 close。

對於 Accept 動作,一定是返回一個連接類型 Conn ,針對這個連接,它本身也存在一個自己的 netFD,那麼可想而知一定會有 Write 和 Read 兩個操作。

而所有的網絡操作都是以 netFD 實現的。這樣,netFD 在這裏就有兩種不同的上層應用體現了。

好了, 我們需要搞清楚幾件事:

Listen 解析

帶着這些問題,我們接着看流程。

上圖已經把當你調用 Listen 操作的完整流程全部羅列出來了。

就像我上面列出的結構關係一樣,從結構層次來說,每調用下一層,都是爲了創建並獲取下一層的依賴,因爲內部的高度抽象與封裝,才使得使用者往往只需調用極少數簡單的 API 接口。

現在我們已經知道事例代碼涉及到的結構以及對應流程了。

在傳統印象中,創建一個網絡服務。需要經過: 創建一個 socket、bind 、listen 這基本的三大步。

前面我們說過,Go 中所有的網絡操作都是以 netFD 實現的。go 也是在這一層封裝這三大步的。所以我們直接從 netFD 邏輯開始說。

上圖是在調用 socket 函數這一步返回的 netFD,可想而知核心邏輯都在這裏面。

我們可以把這個函數核心點看成三步。

在 sysSocket 函數中,首先會通過 socketFunc 來創建一個 socket,通過層層查看,最終是通過 system call 來完成這一步。

當獲取到對應 fd 時,會通過 syscall.SetNonblock 函數把當前這個 fd 設置成非阻塞模式,這樣當這個 Listener 調用 accept 函數就不會被阻塞了。

第二步,通過第一步創建 socket 拿到的 fd,創建一個新的 netFD。這段代碼沒啥好解釋的。

第三步,也就是最核心的一步,調用 netFD 自身的 listenStream 方法。

listenStream 裏面也有核心的三步:

我們主要看 fd.init 邏輯。

最終是調用的 pollDesc 的 init 函數。這個函數有重要的兩步。

更具體的流程,

首先 serviceInit.Do 保證當中的 runtime_pollServerInit 只會初始化一次。這很好理解,類似 epoll 實例全局初始化一次即可。

接着我們看下 runtime_pollServerInit 函數,

這是咋回事,和我們平常看過的函數長的不太一樣,執行體呢?

其實這個函數是通過 go:linkname 連接到具體實現的函數 poll_runtime_pollServerInit。找起來也很簡單,

看到 poll_runtime_pollServerInit() 上面的 //go:linkname xxx 了嗎?不瞭解的可以看看 Go 官方文檔 `go:linkname。

所以最終 runtime_pollServerInit 調用的是,

通過調用 poll_runtime_pollServerInit->netpollGenericInit,netpollGenericInit 裏調用 netpollinit 函數完成初始化。

注意。這裏的 netpollinit,是基於當前系統來調用對應系統的 netpollinit 函數的。

什麼意思?

文章開始有提到 Go 底層網絡模型是基於 I/O 多路複用。

不同平臺對 I/O 多路複用有不同的實現方式。比如 Linux 的 epoll,MacOS 的 kqueue, 而 Windows 的 icop。

所以對應,如果你當前是 Linux,那麼最終調用的是 src/runtime/netpoll_epoll.go 下的 netpollinit 函數,然後會創建一個 epoll 實例,並把值賦給 epfd,作爲整個 runtime 中唯一的 event-loop 使用。

其他的,比如 MacOS 下的 kqueue, 也存在 netpollinit 函數。

以及 Windows 下的 icop。

我們回到 pollDesc.init 操作,

完成第一步初始化操作後,第二步就是調用 runtime_pollOpen。

老套路通過 //go:linkname 找到對應的實現,實際上是調用的 poll_runtime_pollOpen 函數。

這個函數里面再調用 netpollopen 函數,netpollopen 函數和上面的 netpollinit 函數一樣,不同平臺都有它的實現。linux 平臺下,

netpollopen 函數,首先會通過指針把 pollDesc 保存到 epollevent 的一個字節數組 data 裏。

然後會把傳遞進來的 fd(剛纔初始化完成的那個 Listener 監聽器)註冊到 epoll 當中,且通過指定 _EPOLLET 將 epoll 設置爲邊緣觸發 (Edge Triggered) 模式。

如果讓我用一句話來說明 epoll 水平觸發和邊緣觸發的區別,那就是,

水平觸發下 epoll_wait 在文件描述符沒有讀寫完會一直觸發,而邊緣觸發只在是在變成可讀寫時觸發一次。

到這裏整個 Listen 動作也就結束了,然後層層返回。最終到業務返回的是一個 Listener,按照本篇的例子,本質上還是一個 TCPListener。

Accept 解析

接着當我們調用 listen.Accept 的時候,

最終 netFD 的 accept 函數。netFD 中通過調用 fd.pfd(實際上是 FD) 的 Accept 函數獲取到 socket fd,通過這個 fd 創建新的 netFD 表示這是一個新連接的 fd。

並且會和 Listen 時一樣調用 netFD.init 做初始化,因爲當前 epoll 已經初始化一次了,所以這次只是把這個新連接的 fd 也加入到 epoll 事件隊列當中,用於監聽 conn fd 的讀寫 I/O 事件。

具體我們看 FD.Accept 是咋麼執行的。

首先是一個死循環 for,死循環裏調用了 accept 函數,本質上通過 systcall 調用系統 accept 接收新連接。當有新連接時,最終返回一個文件描述符 fd。

當 accept 獲取到一個 fd,會調用 systcall.SetNonblock 把這個 fd 設置成非阻塞的 I/O。然後返回這個連接 fd。

因爲我們在 Listen 的時候已經把對應的 Listener fd 設置成非阻塞 I/O 了。

所以調用 accept 這一步是不會阻塞的。只是下面會進行判斷,根據判斷 err ==syscall.EAGAIN 來調用 fd.pd.waitRead 阻塞住用戶程序。

直到 I/O 事件 ready,被阻塞在 fd.pd.waitRead 的代碼會繼續執行 continue,重新一輪的 accept, 此時對應 fd 上的 I/O 已然 ready,最終就返回一個 conn 類型的 fd。

我剛纔說的調用 fd.pd.waitRead 會被阻塞,直到對應 I/O 事件 ready。我們來看它具體邏輯,

最終到 runtime_pollWait 函數,老套路了,我們找到具體的實現函數。

poll_runtime_pollWait 裏的 for 循環就是爲了等待對應的 I/O ready 纔會返回,否則的話一直調用 netpollblock 函數。

pollDesc 結構我們之前提到,它就是底層事件驅動的封裝。

其中有兩個重要字段: rg 和 wg,都是指針類型,實際這兩個字段存儲的就是 Go 底層的 g,更具體點是等待 i/O ready 的 g。

比如當創建完一個 Listener,調用 Accept 開始接收客戶端連接。如果沒有對應的請求,那麼最終會把 g 放入到 pollDesc 的 rg。

如果是一個 conn 類型的 fd 等待可寫 I/O,那麼會把 g 放入到 pollDesc 的 wg 中。

具體就是根據 mode 來判斷當前是什麼類型的等待事件。

netpollblock 裏也有一個 for 循環,如果已經 ready 了,那麼直接返回給上一層就行了。否則的話,設置 gpp 爲等待狀態 pdWait。

這裏還有一點 atomic.Loaduintptr(gpp),這是爲了防止異常情況下出現死循環問題。比如如果 gpp 的值不是 pdReady 也不是 0,那麼意味着值是 pdWait,那就成了 double wait,必然導致死循環。

如果 gpp 未 ready 且成功設置成 pdWait,正常情況下,最終會調用 gopark,會掛起 g 且把對應的 g 放入到 pollDesc 的 wg|rg 當中。

進入 gopark。

這一塊代碼不是很難,基本的字段打了備註,核心還是要看 park_m 這個函數。

在 park_m 函數中,首先會通過 CAS 併發安全地修改 g 的狀態。

然後調用 dropg 解綁 g 和 m 的關係,也就是 m 把當前運行的 g 置空,g 把當前綁定的 m 置空。

後面的代碼是根據當前場景來解釋的。我們知道此時 m 的 waitunlockf 其實就是 netpollblockcommit。

netpollblockcommit 會把當前已經是_Gwaiting 狀態下的 g 賦值給 gpp。如果賦值成功,netpollWaiters 會加 1。

這個全局變量表示當前等待 I/O 事件 ready 的 g 數量,調度器再進行調度的時候可以根據此變量判斷是否存在等待 I/O 事件的 g。

如果此時當前 gpp 下的 fd 的 I/O 已經 ready。那麼 gpp 的狀態必然已不是 pdWait,賦值失敗。返回 false。

回到 park_m,

如果 netpollblockcommit 返回 true,那麼直接觸發新一輪的調度。

如果 netpollblockcommit 返回 false,意味着當前 g 已經不需要被掛起了,所以需要把狀態調整爲_Grunnable,然後安排 g 還是在當前 m 上執行。

當 I/O 事件 ready,會一層層返回,獲取到新的 socket fd,創建 conn 類型的 netFD,初始化 netFD(其實就是把這個 conn 類型的 fd 也加入 epoll 事件隊列,用於監聽),最終最上游會獲取到一個 Conn 類型的網絡連接,就可以基於這個連接做 Read、Write 等操作了。

Read/Write 解析

後續的 Conn.Read 和 Conn.Write 原理和 Accept 類似。

上圖給出了 Write 操作,可以看出核心部分和 accept 操作時一樣的。對於 Read 操作,就不再重複了。

從上面的分析中我們已經知道,Go 的 netpoller 底層通過對 epoll|kqueue|iocp 的封裝,使用同步的編程手法達到異步執行的效果,無論是一個 Listener 還是一個 Conn,它的核心都是 netFD。

netFD 又和底層的 PollDesc 結構綁定,當讀寫出現 EAGAIN 錯誤時,會通過調用 gopark 把當前 g 給 park 住,同時會將當前的 g 存儲到對應 netFD 的 PollDesc 的 wg|rg 當中。

直到這個 netFD 再次發生對應的讀寫事件,纔會重新把當前 g 放入到調度系統進行調度。

還有最後一個問題,我們咋麼知道哪些 FD 發生讀寫事件了?

I/O 已就緒

答案就是 netpoll() 函數。

此函數會調用 epollwait 函數,本質上就是 Linux 中 epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)。

在之前調用 epoll_ctl,註冊 fd 對應的 I/O 事件到 epoll 實例當中。

這裏的 epoll_wait 實際上會阻塞監聽 epoll 實例上所有 fd 的 I/O 事件,通過傳入的第二個參數 (用戶內存地址 events)。

當有對應的 I/O 事件到來時,內核就會把發生事件對應的 fd 複製到這塊用戶內存地址 (events),解除阻塞。

然後我們遍歷這個 events,去獲取到對應的事件類型、pollDesc,再通過調用 netpollready 函數獲取到 pollDesc 對應被 gopark 的 g,最終把這些 g 加入到一個鏈表當中,返回。

也就是說只要調用這個函數,我們就能獲取到之前因爲 I/O 未 ready 而被 gopark 掛起,現在 I/O 已 ready 的 g 鏈表了。

我們可以找到四個調用處,如下,

這和 go 的調度有關,當然這不是本章的內容。

當這四種方法調用 netpoll 函數得到一個可運行的 g 鏈表時,都會調用同一個函數 injectglist。

這個函數本質上就是把鏈表中所有 g 的狀態從 Gwaiting->Grunnable。然後按照策略,把這些 g 推送到本地處理器 p 或者全家運行隊列中等待被調度器執行。

到這裏,整個流程就已經剖析完畢。不能再寫了。

總結

Go netpoller 通過在底層對 epoll/kqueue/iocp 這些不同平臺下對 I/O 多路複用實現的封裝,加上自帶的 goroutine(上文我一直用 g 表達),從而實現了使用同步編程模式達到異步執行的效果。

代碼很長,涉及到的模塊也很多,整體看完代碼還是非常爽的。

另外早有人提出,由於一個連接對應一個 goroutine,瞬時併發場景下,大量的 goroutine 會被不斷創建。

原生 netpoller 無法提供足夠的性能和控制力,如無法感知連接狀態、連接數量多導致利用率低、無法控制協程數量等。針對這些問題,可以參考下 gnet 以及 KiteX 這兩個項目的網絡模型。

參考資料

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