Go netpoll大解析

圖片拍攝于2022年4月3日 杭州
開篇
之前簡單看過一點go原生netpoll,沒注意太多細節(jié)。最近從頭到尾看了一遍,特寫篇文章記錄下。文章很長,請耐心看完,一定有所收獲。
內核空間和用戶空間
在linux中,經常能看到兩個詞語:User space(用戶空間)和Kernel space (內核空間)。
簡單地說, Kernel space是linux內核運行的空間,User space是用戶程序運行的空間。它們之間是相互隔離的。
現(xiàn)代操作系統(tǒng)都是采用虛擬存儲器。那么對32位操作系統(tǒng)而言,它的尋址空間(虛擬存儲空間)為4G(2的32次方)。
操作系統(tǒng)的核心是內核,獨立于普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的所有權限。
為了保證用戶進程不能直接操作內核,保證內核的安全,系統(tǒng)將虛擬空間劃分為兩部分,一部分為內核空間,一部分為用戶空間。
針對linux操作系統(tǒng)而言,將最高的1G字節(jié)(從虛擬地址0xC0000000到0xFFFFFFFF),供內核使用,稱為內核空間,而將較低的3G字節(jié)(從虛擬地址0x00000000到0xBFFFFFFF),供各個進程使用,稱為用戶空間??臻g分配如下圖所示:

Kernel space可以調用系統(tǒng)的一切資源。User space 不能直接調用系統(tǒng)資源,在 Linux系統(tǒng)中,所有的系統(tǒng)資源管理都是在內核空間中完成的。
比如讀寫磁盤文件、分配回收內存、從網(wǎng)絡接口讀寫數(shù)據(jù)等等。應用程序無法直接進行這樣的操作,但是用戶程序可以通過內核提供的接口來完成這樣的任務。

像下面這樣,

應用程序要讀取磁盤上的一個文件,它可以向內核發(fā)起一個 “系統(tǒng)調用” 告訴內核:”我要讀取磁盤上的某某文件”。其實就是通過一個特殊的指令讓進程從用戶態(tài)進入到內核態(tài)。
在內核空間中,CPU 可以執(zhí)行任何的指令,當然也包括從磁盤上讀取數(shù)據(jù)。
具體過程是先把數(shù)據(jù)讀取到內核空間中,然后再把數(shù)據(jù)拷貝到用戶空間并從內核態(tài)切換到用戶態(tài)。
此時應用程序已經從系統(tǒng)調用中返回并且拿到了想要的數(shù)據(jù),繼續(xù)往下執(zhí)行用戶空間執(zhí)行邏輯。
這樣的話,一旦涉及到對I/O的處理,就必然會涉及到在用戶態(tài)和內核態(tài)之間來回切換。
io模型
網(wǎng)上有太多關于I/O模型的文章,看著看著有可能就跑偏了,所以我還是從 <
Unix可用的5種I/O模型。
阻塞I/O
非阻塞I/O
I/O復用
信號驅動式I/O(SIGIO)
異步I/O(POSIX的aio_系列函數(shù))
阻塞I/O

阻塞式I/O下,進程調用recvfrom,直到數(shù)據(jù)到達且被復制到應用程序的緩沖區(qū)中或者發(fā)生錯誤才返回,在整個過程進程都是被阻塞的。
非阻塞I/O

從圖中可以看出,前三次調用recvfrom中沒有數(shù)據(jù)可返回,因此內核轉而立即返回一個EWOULDBLOCK錯誤。
第四次調用recvfrom時已有一個數(shù)據(jù)報準備好,它被復制到應用程序緩沖區(qū),于是recvfrom成功返回。
當一個應用程序像這樣對一個非阻塞描述符循環(huán)調用recvfrom時,我們通常稱為輪詢(polling),持續(xù)輪詢內核,以這種方式查看某個操作是否就緒。
I/O多路復用

有了I/O多路復用(I/O multiplexing),我們就可以調用 select 或者 poll,阻塞在這兩個系統(tǒng)調用中的某一個之上,而不是阻塞在真正的I/O系統(tǒng)調用上。
上面這句話難理解是吧。
說白了這里指的是,在第一步中,我們只是阻塞在select調用上,直到數(shù)據(jù)報套接字變?yōu)榭勺x,返回可讀條件,這里并沒有發(fā)生I/O事件,所以說這一步,并沒有阻塞在真正的I/O系統(tǒng)調用上。
其他兩種就不過多介紹了。還有一點,我們會經常提到同步I/O和異步I/O。
POSIX 把這兩種術語定義如下:
同步I/O操作(synchronous I/O opetation) 導致請求進程被阻塞,直到I/O操作完成。
異步I/O(asynchronous opetation) 不導致請求進程被阻塞。
基于上面的定義,

異步I/O的關鍵在于第二步的recrfrom是否會阻塞住用戶進程,如果不阻塞,那它就是異步I/O。從上面匯總圖中可以看出,只有異步I/O滿足POSIX中對異步I/O的定義。
Go netpoller
Go netpoller 底層就是對I/O多路復用的封裝。不同平臺對I/O多路復用有不同的實現(xiàn)方式。比如Linux的select、poll和epoll。
在MacOS則是kqueue,而Windows是基于異步I/O實現(xiàn)的icop......,基于這些背景,Go針對不同的平臺調用實現(xiàn)了多版本的netpoller。

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

很簡單一個demo,開啟一個tcp服務。然后每來一個連接,就啟動一個g去處理連接。處理完畢,關閉連接。
而且我們使用的是同步的模式去編寫異步的邏輯,一個連接對應一個g處理,極其簡單和易于理解。go標準庫中的http.server也是這么干的。

針對上面的tcp服務demo,我們需要關注這段代碼底層都發(fā)生了什么。
上面代碼中主要涉及底層的一些結構。

先簡單解釋一波。
TCPListener:我們開啟的是一個TCP服務,那當然就是TCP服務的網(wǎng)絡監(jiān)聽器。
netFD:網(wǎng)絡描述符。Go中所有的網(wǎng)絡操作都是以netFD實現(xiàn)的,它和底層FD做綁定。
FD:文件描述符。net和os包把這個類型作為一個網(wǎng)絡連接或者操作系統(tǒng)文件。其中里面一個字段Sysfd就是具體文件描述符值。
pollDesc:I/O輪詢器。說白了它就是底層事件驅動的封裝。其中的runtimeCtx是一個指針類型,具體指向runtime/netpoll 中的pollDesc.
當然圖上面結構字段都是閹割版的,但是不影響我們這篇文章。
還有一個問題,為什么結構上需要一層一層嵌入呢?我的理解是每下一層都是更加抽象的一層。它是可以作為上一層具體的一種應用體現(xiàn)。
是不是跟沒說一樣?哈哈。
舉例,比如這里的netFD表示網(wǎng)絡描述符。
它的上一層可以是用于TCP的網(wǎng)絡監(jiān)聽器TCPListener,那么對應的接口我們能想到的有兩個Accept以及close。
對于Accept 動作,一定是返回一個連接類型 Conn ,針對這個連接,它本身也存在一個自己的netFD,那么可想而知一定會有 Write和Read兩個操作。
而所有的網(wǎng)絡操作都是以netFD實現(xiàn)的。這樣,netFD在這里就有兩種不同的上層應用體現(xiàn)了。
好了,我們需要搞清楚幾件事:
一般我們用其他語言寫一個tcp服務,必然會寫這幾步:socket->bind->listen,但是Go就一個Listen,那就意味著底層包裝了這些操作。它是在哪一步完成的?
Go是在什么時候初始化netpoll的,比如linux下初始化epoll實例。
當對應fd沒有可讀或者可寫的IO事件而對應被掛起的g,是如何知道fd上的I/O事件已ready,又是如何喚醒對應的g的?
Listen解析
帶著這些問題,我們接著看流程。

上圖已經把當你調用Listen操作的完整流程全部羅列出來了。
就像我上面列出的結構關系一樣,從結構層次來說,每調用下一層,都是為了創(chuàng)建并獲取下一層的依賴,因為內部的高度抽象與封裝,才使得使用者往往只需調用極少數(shù)簡單的API接口。
現(xiàn)在我們已經知道事例代碼涉及到的結構以及對應流程了。
在傳統(tǒng)印象中,創(chuàng)建一個網(wǎng)絡服務。需要經過:創(chuàng)建一個socket、bind 、listen這基本的三大步。
前面我們說過,Go中所有的網(wǎng)絡操作都是以netFD實現(xiàn)的。go也是在這一層封裝這三大步的。所以我們直接從netFD邏輯開始說。
上圖是在調用socket函數(shù)這一步返回的netFD,可想而知核心邏輯都在這里面。

我們可以把這個函數(shù)核心點看成三步。
調用sysSocket函數(shù)創(chuàng)建一個socket,返回一個文件描述符(file descriptor),簡稱fd下文。
通過sysSocket返回的fd,調用newFD函數(shù)創(chuàng)建一個新的netFD。
調用netFD自身的方法listenStream函數(shù),做初始化動作,具體詳情下面再說。
在sysSocket函數(shù)中,首先會通過socketFunc來創(chuàng)建一個socket,通過層層查看,最終是通過system call來完成這一步。
當獲取到對應fd時,會通過syscall.SetNonblock函數(shù)把當前這個fd設置成非阻塞模式,這樣當這個Listener調用accept函數(shù)就不會被阻塞了。

第二步,通過第一步創(chuàng)建socket拿到的fd,創(chuàng)建一個新的netFD。這段代碼沒啥好解釋的。
第三步,也就是最核心的一步,調用netFD自身的listenStream方法。

listenStream里面也有核心的三步:
通過syscall.Bind 完成綁定
通過listenFunc 完成監(jiān)聽
調用netFD自身init完成初始化操作:netFD.init ->poll.FD.init->FD.pollDesc.init
我們主要看fd.init邏輯。

最終是調用的pollDesc的init函數(shù)。這個函數(shù)有重要的兩步。
runtime_pollServerInit:主要會根據(jù)不同的平臺去調用對應平臺的netpollInit函數(shù)來創(chuàng)建I/O多路復用的實例。比如linux下創(chuàng)建epoll。
runtime_pollOpen(uintptr(fd.Sysfd)):主要將已經創(chuàng)建完的Listener fd注冊到上述實例當中,比如將fd注冊到epoll中,底層通過syscall調用epoll_ctl。
更具體的流程,
首先serviceInit.Do 保證當中的runtime_pollServerInit只會初始化一次。這很好理解,類似epoll實例全局初始化一次即可。
接著我們看下runtime_pollServerInit函數(shù),

這是咋回事,和我們平??催^的函數(shù)長的不太一樣,執(zhí)行體呢?
其實這個函數(shù)是通過 go:linkname連接到具體實現(xiàn)的函數(shù)poll_runtime_pollServerInit。找起來也很簡單,

看到poll_runtime_pollServerInit()上面的 //go:linkname xxx 了嗎?不了解的可以看看Go官方文檔`go:linkname。
所以最終runtime_pollServerInit調用的是,

通過調用poll_runtime_pollServerInit->netpollGenericInit,netpollGenericInit里調用netpollinit函數(shù)完成初始化。
注意。這里的netpollinit,是基于當前系統(tǒng)來調用對應系統(tǒng)的netpollinit函數(shù)的。
什么意思?
文章開始有提到Go底層網(wǎng)絡模型是基于I/O多路復用。
不同平臺對I/O多路復用有不同的實現(xiàn)方式。比如Linux的epoll,MacOS的kqueue,而Windows的icop。
所以對應,如果你當前是Linux,那么最終調用的是src/runtime/netpoll_epoll.go下的 netpollinit函數(shù),然后會創(chuàng)建一個epoll實例,并把值賦給epfd,作為整個runtime中唯一的event-loop使用。

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

以及Windows下的icop。

我們回到pollDesc.init 操作,

完成第一步初始化操作后,第二步就是調用runtime_pollOpen。
老套路通過//go:linkname找到對應的實現(xiàn),實際上是調用的poll_runtime_pollOpen函數(shù)。
這個函數(shù)里面再調用netpollopen函數(shù),netpollopen函數(shù)和上面的netpollinit函數(shù)一樣,不同平臺都有它的實現(xiàn)。linux平臺下,

netpollopen函數(shù),首先會通過指針把pollDesc保存到epollevent的一個字節(jié)數(shù)組data里。
然后會把傳遞進來的fd(剛才初始化完成的那個Listener監(jiān)聽器)注冊到epoll當中,且通過指定 _EPOLLET將epoll設置為邊緣觸發(fā)(Edge Triggered)模式。
如果讓我用一句話來說明epoll水平觸發(fā)和邊緣觸發(fā)的區(qū)別,那就是,
水平觸發(fā)下epoll_wait在文件描述符沒有讀寫完會一直觸發(fā),而邊緣觸發(fā)只在是在變成可讀寫時觸發(fā)一次。
到這里整個Listen 動作也就結束了,然后層層返回。最終到業(yè)務返回的是一個 Listener,按照本篇的例子,本質上還是一個TCPListener。
Accept解析
接著當我們調用listen.Accept的時候,

最終netFD的accept函數(shù)。netFD中通過調用fd.pfd(實際上是FD)的Accept函數(shù)獲取到socket fd,通過這個fd創(chuàng)建新的netFD表示這是一個新連接的fd。
并且會和Listen時一樣調用netFD.init做初始化,因為當前epoll已經初始化一次了,所以這次只是把這個新連接的fd也加入到epoll事件隊列當中,用于監(jiān)聽conn fd的讀寫I/O事件。
具體我們看FD.Accept是咋么執(zhí)行的。

首先是一個死循環(huán)for,死循環(huán)里調用了accept函數(shù),本質上通過systcall調用系統(tǒng)accept接收新連接。當有新連接時,最終返回一個文件描述符fd。
當accept獲取到一個fd,會調用systcall.SetNonblock把這個fd設置成非阻塞的I/O。然后返回這個連接fd。
因為我們在Listen的時候已經把對應的Listener fd設置成非阻塞I/O了。
所以調用accept這一步是不會阻塞的。只是下面會進行判斷,根據(jù)判斷 err ==syscall.EAGAIN 來調用fd.pd.waitRead阻塞住用戶程序。
直到I/O事件ready,被阻塞在fd.pd.waitRead的代碼會繼續(xù)執(zhí)行continue,重新一輪的accept, 此時對應fd上的 I/O已然ready,最終就返回一個conn類型的fd。
我剛才說的調用fd.pd.waitRead會被阻塞,直到對應I/O事件ready。我們來看它具體邏輯,

最終到runtime_pollWait函數(shù),老套路了,我們找到具體的實現(xiàn)函數(shù)。

poll_runtime_pollWait 里的for循環(huán)就是為了等待對應的I/O ready才會返回,否則的話一直調用netpollblock函數(shù)。
pollDesc結構我們之前提到,它就是底層事件驅動的封裝。
其中有兩個重要字段: rg和wg,都是指針類型,實際這兩個字段存儲的就是Go底層的g,更具體點是等待i/O ready的g。
比如當創(chuàng)建完一個Listener,調用Accept開始接收客戶端連接。如果沒有對應的請求,那么最終會把g放入到pollDesc的rg。
如果是一個conn類型的fd等待可寫I/O,那么會把g放入到pollDesc的wg中。
具體就是根據(jù)mode來判斷當前是什么類型的等待事件。
netpollblock里也有一個for循環(huán),如果已經ready了,那么直接返回給上一層就行了。否則的話,設置gpp為等待狀態(tài)pdWait。
這里還有一點atomic.Loaduintptr(gpp),這是為了防止異常情況下出現(xiàn)死循環(huán)問題。比如如果gpp的值不是pdReady也不是0,那么意味著值是pdWait,那就成了double wait,必然導致死循環(huán)。
如果gpp未ready且成功設置成pdWait,正常情況下,最終會調用gopark,會掛起g且把對應的g放入到pollDesc 的wg|rg 當中。
進入gopark。

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

在park_m函數(shù)中,首先會通過CAS并發(fā)安全地修改g的狀態(tài)。
然后調用dropg解綁g和m的關系,也就是m把當前運行的g置空,g把當前綁定的m置空。
后面的代碼是根據(jù)當前場景來解釋的。我們知道此時m的waitunlockf 其實就是netpollblockcommit。

netpollblockcommit會把當前已經是_Gwaiting狀態(tài)下的g賦值給gpp。如果賦值成功,netpollWaiters會加1。
這個全局變量表示當前等待I/O事件ready的g數(shù)量,調度器再進行調度的時候可以根據(jù)此變量判斷是否存在等待I/O事件的g。
如果此時當前gpp下的fd的I/O已經ready。那么gpp的狀態(tài)必然已不是pdWait,賦值失敗。返回false。
回到park_m,
如果netpollblockcommit返回true,那么直接觸發(fā)新一輪的調度。
如果netpollblockcommit返回false,意味著當前g已經不需要被掛起了,所以需要把狀態(tài)調整為_Grunnable,然后安排g還是在當前m上執(zhí)行。
當I/O事件ready,會一層層返回,獲取到新的socket fd,創(chuàng)建conn類型的netFD,初始化netFD(其實就是把這個conn類型的fd也加入epoll事件隊列,用于監(jiān)聽),最終最上游會獲取到一個Conn類型的網(wǎng)絡連接,就可以基于這個連接做Read、Write等操作了。

Read/Write 解析
后續(xù)的Conn.Read 和 Conn.Write 原理和Accept 類似。

上圖給出了Write操作,可以看出核心部分和accept操作時一樣的。對于Read操作,就不再重復了。
從上面的分析中我們已經知道,Go的netpoller底層通過對epoll|kqueue|iocp的封裝,使用同步的編程手法達到異步執(zhí)行的效果,無論是一個Listener還是一個Conn,它的核心都是netFD。
netFD又和底層的PollDesc結構綁定,當讀寫出現(xiàn)EAGAIN錯誤時,會通過調用gopark把當前g給park住,同時會將當前的g存儲到對應netFD的PollDesc的wg|rg當中。
直到這個netFD再次發(fā)生對應的讀寫事件,才會重新把當前g放入到調度系統(tǒng)進行調度。
還有最后一個問題,我們咋么知道哪些FD發(fā)生讀寫事件了?
I/O已就緒
答案就是netpoll()函數(shù)。

此函數(shù)會調用epollwait函數(shù),本質上就是Linux中epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout)。
在之前調用epoll_ctl,注冊fd對應的I/O事件到epoll實例當中。
這里的epoll_wait實際上會阻塞監(jiān)聽epoll實例上所有fd的I/O事件,通過傳入的第二個參數(shù)(用戶內存地址events)。
當有對應的I/O事件到來時,內核就會把發(fā)生事件對應的fd復制到這塊用戶內存地址(events),解除阻塞。
然后我們遍歷這個events,去獲取到對應的事件類型、pollDesc,再通過調用netpollready函數(shù)獲取到pollDesc對應被gopark的g,最終把這些g加入到一個鏈表當中,返回。
也就是說只要調用這個函數(shù),我們就能獲取到之前因為I/O未ready而被gopark掛起,現(xiàn)在I/O已ready的g鏈表了。
我們可以找到四個調用處,如下,
startTheWorldWithSema
findrunnable
pollWork
sysmon
這和go的調度有關,當然這不是本章的內容。
當這四種方法調用netpoll函數(shù)得到一個可運行的g鏈表時,都會調用同一個函數(shù)injectglist。
這個函數(shù)本質上就是把鏈表中所有g的狀態(tài)從Gwaiting->Grunnable。然后按照策略,把這些g推送到本地處理器p或者全家運行隊列中等待被調度器執(zhí)行。

到這里,整個流程就已經剖析完畢。不能再寫了。
總結
Go netpoller通過在底層對epoll/kqueue/iocp這些不同平臺下對I/O多路復用實現(xiàn)的封裝,加上自帶的goroutine(上文我一直用g表達),從而實現(xiàn)了使用同步編程模式達到異步執(zhí)行的效果。
代碼很長,涉及到的模塊也很多,整體看完代碼還是非常爽的。
另外早有人提出,由于一個連接對應一個goroutine,瞬時并發(fā)場景下,大量的goroutine會被不斷創(chuàng)建。
原生netpoller無法提供足夠的性能和控制力,如無法感知連接狀態(tài)、連接數(shù)量多導致利用率低、無法控制協(xié)程數(shù)量等。針對這些問題,可以參考下gnet以及 KiteX 這兩個項目的網(wǎng)絡模型。
參考資料
http://man7.org/linux/man-pages/man7/epoll.7.html
> https://github.com/panjf2000/gnet
https://strikefreedom.top/go-netpoll-io-multiplexing-reactor
https://ninokop.github.io/2018/02/18/go-net/
https://github.com/cloudwego/kitex
推薦閱讀
