<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          Go netpoll大解析

          共 8776字,需瀏覽 18分鐘

           ·

          2022-04-26 13:42

          圖片拍攝于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模型的文章,看著看著有可能就跑偏了,所以我還是從 <> 中總結的5中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://mp.weixin.qq.com/s/wSaJYg-HqnYY4SdLA2Zzaw

          • https://ninokop.github.io/2018/02/18/go-net/

          • https://github.com/cloudwego/kitex



          推薦閱讀


          福利

          我為大家整理了一份從入門到進階的Go學習資料禮包,包含學習建議:入門看什么,進階看什么。關注公眾號 「polarisxu」,回復?ebook?獲取;還可以回復「進群」,和數(shù)萬 Gopher 交流學習。

          瀏覽 25
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  av久草青青 | 国产视频性高潮 | 成人福利视频在线 | 小视频+福利 | 亚洲无码婷婷国产 |