大部分的服務都是 I/O 密集型的,應用程序會花費大量時間等待 I/O 操作的完成。網絡輪詢器(netpoller)是 Go 語言運行時用來處理 I/O 操作的關鍵組件,它使用了操作系統提供的 I/O 多路復用機制增強程序的并發處理能力。本文會詳細介紹I/O模型相關知識,并深入分析 Go 語言網絡輪詢器的設計與實現原理。
I/O 相關基礎概念
文件
在Linux世界中文件是一個很簡單的概念,作為程序員我們只需要將其理解為一個N byte的序列就可以了。
實際上所有的I/O設備都被抽象為了文件這個概念,一切皆文件,Everything is File,磁盤、網絡數據、終端,甚至進程間通信工具管道pipe等都被當做文件對待。
所有的I/O操作也都可以通過文件讀寫來實現,這一非常優雅的抽象可以讓程序員使用一套接口就能對所有外設I/O操作。
常用的I/O操作接口一般有以下幾類:
? 打開文件,open
? 改變讀寫位置,seek
? 文件讀寫,read、write
? 關閉文件,close
程序員通過這幾個接口幾乎可以實現所有I/O操作,這就是文件這個概念的強大之處。
文件描述符
要想進行I/O讀操作,像磁盤數據,我們需要指定一個buff用來裝入數據,一般都是這樣寫的
read(buff)
雖然我們指定了往哪里寫數據,但是我們該從哪里讀數據呢?我們無法確定哪個文件是我們需要去讀取的。Linux為了高效管理已被打開的文件,于是引入了索引:文件描述符(file descriptor)。fd
用于指代被打開的文件,對文件所有 I/O 操作相關的系統調用都需要通過fd
。
有了文件描述符,進程可以對文件一無所知,比如文件在磁盤的什么位置、加載到內存中又是怎樣管理的等等,這些信息統統交由操作系統打理,進程無需關心,操作系統只需要給進程一個文件描述符就足夠了。
因此我們來完善上述程序:
int fd = open(file_name); // 獲取文件描述符read(fd, buff);
read(fd, buff);
I/O模型
目前Linux系統中提供了以下5種IO處理模型,不同的 I/O 模型會使用不同的方式操作文件描述符:
- 阻塞I/O
- 非阻塞I/O
- I/O多路復用
- 信號驅動I/O
- 異步I/O
阻塞I/O(Blocking I/O)
阻塞 I/O 是最常見的 I/O 模型,在默認情況下,當我們通過 read 或者 write 等系統調用讀寫文件或者網絡時,應用程序會被阻塞。
如下圖所示,當我們執行 recvfrom 系統調用時,應用程序會從用戶態陷入內核態,內核會檢查文件描述符是否就緒;當文件描述符中存在數據時,操作系統內核會將準備好的數據拷貝給應用程序并交回控制權。線程會阻塞在這里,然后掛起(掛起的時候,cpu回去處理其他任務),等待隊列不為空。
非阻塞I/O(NoneBlocking I/O)
當進程把一個文件描述符設置成非阻塞時,執行 read 和 write 等 I/O 操作會立刻返回。在 C 語言中,我們可以使用如下所示的代碼片段將一個文件描述符設置成非阻塞的:
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
在上述代碼中,最關鍵的就是系統調用 fcntl
和參數 O_NONBLOCK
,fcntl
為我們提供了操作文件描述符的能力,我們可以通過它修改文件描述符的特性。當我們將文件描述符修改成非阻塞后,讀寫文件會經歷以下流程:
第一次從文件描述符中讀取數據會觸發系統調用并返回 EAGAIN
錯誤,EAGAIN
意味著該文件描述符還在等待緩沖區中的數據;隨后,應用程序會不斷輪詢調用 read
直到它的返回值大于 0,這時應用程序就可以對讀取操作系統緩沖區中的數據并進行操作。進程使用非阻塞的 I/O 操作時,可以在等待過程中執行其他任務,提高 CPU 的利用率。
I/O多路復用(I/O multiplexing)
原本是一個 I/O對應一個進程,這樣的話如果有1000個i/o 就需要啟動1000個進程,這對于一個16核,8核的cpu來說,需要大量性能損耗在進程間的切換上。所以優化了一種方案是N個i/o只對應一個進程來處理。這個就是I/O 多路復用。
I/O 多路復用機制,就是說通過一種機制,可以監視多個描述符;一旦某個描述符就緒(一般是讀就緒或寫就緒),能夠通知程序進行相應的讀寫操作;沒有文件句柄就緒就會阻塞應用程序,交出CPU。這種機制的使用需要 select
、 poll
、 epoll
來配合。
select
在select這種I/O多路復用機制下,我們需要把想監控的fd
集合通過函數參數的形式告訴select,然后select會將這些文件描述符集合拷貝到內核中。
select的缺點:
- 能監控的文件描述符太少,通過 FD_SETSIZE 設置,默認1024個
- 每次調用 select,都需要把 fd 集合從用戶態拷貝到內核態,這個開銷在 fd 很多時會很大(需要維護一個用來存放大量fd的數據結構,這樣會使得用戶空間和內核空間在傳遞該結構時復制開銷大)
- 每次調用select,都需要在內核,遍歷fd集合進行無差別輪詢,性能開銷大(如果能給套接字注冊某個回調函數,當他們活躍時,自動完成相關操作,那就避免了輪詢,這正是epoll與kqueue做的)
poll
poll本質上和select沒有區別,它將用戶傳入的數組拷貝到內核空間,然后查詢每個fd對應的設備狀態, 但是它沒有最大連接數的限制,原因是它是基于鏈表來存儲的。
poll的缺點:
- 每次調用 poll ,都需要把 fd 集合從用戶態拷貝到內核態,這個開銷在 fd 很多時會很大;
- 對 fd集合 掃描是線性掃描,采用輪詢的方法,效率較低(高并發時)
epoll
epoll可以理解為event poll,不同于忙輪詢和無差別輪詢,epoll會把哪個流發生了怎樣的I/O事件通知我們。所以我們說epoll實際上是事件驅動(每個事件關聯上fd)的,此時我們對這些流的操作都是有意義的。(復雜度降低到了O(1))
epoll函數接口:
#include <sys/epoll.h>
// 數據結構
// 每一個epoll對象都有一個獨立的eventpoll結構體
// 用于存放通過epoll_ctl方法向epoll對象中添加進來的事件
// epoll_wait檢查是否有事件發生時,只需要檢查eventpoll對象中的rdlist雙鏈表中是否有epitem元素即可
struct eventpoll {
/*紅黑樹的根節點,這顆樹中存儲著所有添加到epoll中的需要監控的事件*/
struct rb_root rbr;
/*雙鏈表中則存放著將要通過epoll_wait返回給用戶的滿足條件的事件*/
struct list_head rdlist;
};
// API
int epoll_create(int size); // 內核中間加一個 ep 對象,把所有需要監聽的 fd 都放到 ep 對象中
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // epoll_ctl 負責把 fd 增加、刪除到內核紅黑樹
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);// epoll_wait 負責檢測可讀隊列,沒有可讀 fd 則阻塞
每一個epoll對象都有一個獨立的eventpoll結構體,用于存放通過epoll_ctl方法向epoll對象中添加進來的事件。這些事件都會掛載在紅黑樹中,如此,重復添加的事件就可以通過紅黑樹而高效的識別出來(紅黑樹的插入時間效率是logn,其中n為紅黑樹元素個數)。
而所有添加到epoll中的事件都會與設備(網卡)驅動程序建立回調關系,也就是說,當相應的事件發生時會調用這個回調方法。這個回調方法在內核中叫ep_poll_callback,它會將發生的事件添加到rdlist雙鏈表中。
在epoll中,對于每一個事件,都會建立一個epitem結構體,如下所示:
struct epitem{
struct rb_node rbn;//紅黑樹節點
struct list_head rdllink;//雙向鏈表節點
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所屬的eventpoll對象
struct epoll_event event; //期待發生的事件類型
}
當調用epoll_wait檢查是否有事件發生時,只需要檢查eventpoll對象中的rdlist雙鏈表中是否有epitem元素即可。如果rdlist不為空,則把發生的事件復制到用戶態,同時將事件數量返回給用戶。
從上面的講解可知:通過于紅黑樹和雙鏈表數據結構,并結合回調機制,造就了epoll的高效。
講解完了Epoll的機理,我們便能很容易掌握epoll的用法了。一句話描述就是:三步曲。
第一步:epoll_create()系統調用。此調用返回一個句柄,之后所有的使用都依靠這個句柄來標識。創建一個epoll句柄,實際上是在內核空間,建立一個root根節點,這個根節點的關系與epfd相對應。
第二步:epoll_ctl()系統調用。通過此調用向epoll對象中添加、刪除、修改感興趣的事件,返回0標識成功,返回-1表示失敗。創建的該用戶態事件,綁定到某個fd上,然后添加到內核中的epoll紅黑樹中。
第三步:epoll_wait()系統調用。通過此調用收集在epoll監控中已經發生的事件。如果內核檢測到IO的讀寫響應,會拋給上層的epoll_wait, 返回給用戶態一個已經觸發的事件隊列,同時阻塞返回。開發者可以從隊列中取出事件來處理,其中事件里就有綁定的對應fd是哪個(之前添加epoll事件的時候已經綁定)。
- epoll的優點
- 沒有最大并發連接的限制,能打開的FD的上限遠大于1024(1G的內存上能監聽約10萬個端口)
- 效率提升,不是輪詢的方式,不會隨著FD數目的增加效率下降。只有活躍可用的FD才會調用callback函數;即Epoll最大的優點就在于它只管你“活躍”的連接,而跟連接總數無關,因此在實際的網絡環境中,Epoll的效率就會遠遠高于select和poll
- epoll的缺點
- epoll只能工作在 linux 下
-
epoll LT 與 ET 模式的區別
epoll 有 EPOLLLT 和 EPOLLET 兩種觸發模式,LT 是默認的模式,ET 是 “高速” 模式。
- LT 模式下,只要這個 fd 還有數據可讀,每次 epoll_wait 都會返回它的事件,提醒用戶程序去操作;
- ET 模式下,它只會提示一次,直到下次再有數據流入之前都不會再提示了,無論 fd 中是否還有數據可讀。所以在 ET 模式下,read 一個 fd 的時候一定要把它的 buffer 讀完,或者遇到 EAGIN 錯誤。
epoll使用“事件”的就緒通知方式,通過epoll_ctl注冊fd,一旦該fd就緒,內核就會采用類似callback的回調機制來激活該fd,epoll_wait便可以收到通知。
select/poll/epoll之間的區別
select,poll,epoll都是IO多路復用的機制。I/O多路復用就通過一種機制,可以監視多個描述符,一旦某個描述符就緒(一般是讀就緒或者寫就緒),能夠通知程序進行相應的讀寫操作。但select,poll,epoll本質上都是同步I/O,因為他們都需要在讀寫事件就緒后自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步I/O則無需自己負責進行讀寫,異步I/O的實現會負責把數據從內核拷貝到用戶空間。
epoll跟select都能提供多路I/O復用的解決方案。在現在的Linux內核里有都能夠支持,其中epoll是Linux所特有,而select則應該是POSIX所規定,一般操作系統均有實現。
select | poll | epoll | |
---|---|---|---|
獲取可用的fd | 遍歷 | 遍歷 | 回調 |
存儲fd的數據結構 | bitmap | 數組 | 紅黑樹 |
最大連接數 | 1024(x86)或 2048(x64) | 無上限 | 無上限 |
最大支持fd數量 | 一般有最大值限制 | 65535 | 65535 |
fd拷貝 | 每次調用select,都需要把fd集合從用戶態拷貝到內核態 | 每次調用poll,都需要把fd集合從用戶態拷貝到內核態 | fd首次調用epoll_ctl拷貝,每次調用epoll_wait不拷貝 |
工作模式 | LT | LT | 支持lT默認模式及ET高效模式 |
工作效率 | 每次調用都進行線性遍歷,時間復雜度為O(n) | 每次調用都進行線性遍歷,時間復雜度為O(n) | 事件通知方式,每當fd就緒,系統注冊的回調函數就會被調用,將就緒fd放到readyList里面,時間復雜度O(1) |
epoll是Linux目前大規模網絡并發程序開發的首選模型。在絕大多數情況下性能遠超select和poll。目前流行的高性能web服務器Nginx正式依賴于epoll提供的高效網絡套接字輪詢服務。但是,在并發連接不高的情況下,多線程+阻塞I/O方式可能性能更好。
select
,poll
實現需要自己不斷輪詢所有fd集合,直到設備就緒,期間可能要睡眠和喚醒多次交替。而epoll
其實也需要調用epoll_wait
不斷輪詢就緒鏈表,期間也可能多次睡眠和喚醒交替,但是它是設備就緒時,調用回調函數,把就緒fd放入就緒鏈表中,并喚醒在epoll_wait中進入睡眠的進程。雖然都要睡眠和交替,但是select和poll在“醒著”的時候要遍歷整個fd集合,而epoll在“醒著”的時候只要判斷一下就緒鏈表是否為空就行了,這節省了大量的CPU時間。這就是回調機制帶來的性能提升。
select
,poll
每次調用都要把fd
集合從用戶態往內核態拷貝一次,并且要把current往設備等待隊列中掛一次。而epoll只要一次拷貝,而且把current往等待隊列上掛也只掛一次*(在epoll_wait的開始,注意這里的等待隊列并不是設備等待隊列,只是一個epoll內部定義的等待隊列)。這也能節省不少的開銷。
信號驅動I/O
在信號驅動IO模型中,當用戶線程發起一個IO請求操作,會給對應的socket注冊一個信號函數,然后用戶線程會繼續執行,當內核數據就緒時會發送一個信號給用戶線程,用戶線程接收到信號后,便在信號函數中調用IO讀寫操作來進行實際的IO請求操作。這個一般用于UDP中,對TCP套接字幾乎沒用,原因是該信號產生得過于頻繁,并且該信號的出現并沒有告訴我們發生了什么請求。
用戶進程可以使用信號方式,當系統內核描述符就緒時將會發送SIGNO給到用戶空間,這個時候再發起recvfrom的系統調用等待返回成功提示,流程如下:
- 先開啟套接字的信號IO啟動功能,并通過一個內置安裝信號處理函數的signaction系統調用,當發起調用之后會直接返回;
- 其次,等待內核從網絡中接收數據報之后,向用戶空間發送當前數據可達的信號給信號處理函數;
- 信號處理函數接收到信息就發起recvfrom系統調用等待內核數據復制數據報到用戶空間的緩沖區;
- 接收到復制完成的返回成功提示之后,應用進程就可以開始從網絡中讀取數據。
異步I/O
前面四種IO模型實際上都屬于同步IO,只有最后一種是真正的異步IO,因為無論是多路復用IO還是信號驅動模型,IO操作的第2個階段都會引起用戶線程阻塞,也就是內核進行數據拷貝的過程都會讓用戶線程阻塞。
- 由POSIX規范定義,告知系統內核啟動某個操作,并讓內核在整個操作包含數據等待以及數據復制過程的完成之后通知用戶進程數據已經準備完成,可以進行讀取數據;
- 與上述的信號IO模型區分在于異步是通知我們何時IO操作完成,而信號IO是通知我們何時可以啟動一個IO操作
同步IO/異步IO/阻塞IO/非阻塞IO(基于POSIX規范)
- 同步IO: 表示應用進程發起真實的IO操作請求(recvfrom)導致進程一直處于等待狀態,這時候進程被阻塞,直到IO操作完成返回成功提示
- 異步IO: 表示應用進程發起真實的IO操作請求(recvfrom)導致進程將直接返回一個錯誤信息,“相當于告訴進程還沒有處理好,好了會通知你”
- 阻塞IO: 主要是體現發起IO操作請求通知內核并且內核接收到信號之后如果讓進程等待,那么就是阻塞
- 非阻塞IO: 發起IO操作請求的時候不論結果直接告訴進程“不用等待,晚點再來”,那就是非阻塞
IO模型對比
除了真正的異步I/O模型以外,其他幾種模型,最后一階段的處理都是相同的——阻塞于recvfrom調用,將數據從內核拷貝到應用緩沖區。
同步與異步針對通信機制,阻塞與非阻塞針對程序調用等待結果的狀態
netpoller
netpoller基本原理
在Go的實現中,所有IO都是阻塞調用的,Go的設計思想是程序員使用阻塞式的接口來編寫程序,然后通過goroutine+channel來處理并發。因此所有的IO邏輯都是直來直去的,先xx,再xx, 你不再需要回調,不再需要future,要的僅僅是step by step。這對于代碼的可讀性是很有幫助的。
netpoller的工作就是成為同步(阻塞)IO調用和異步(非阻塞)IO調用之間的橋梁。
Go netpoller 通過在底層對 epoll/kqueue/iocp 的封裝,從而實現了使用同步編程模式達到異步執行的效果。總結來說,所有的網絡操作都以網絡描述符 netFD 為中心實現。netFD 與底層 PollDesc 結構綁定,當在一個 netFD 上讀寫遇到 EAGAIN 錯誤時,就將當前 goroutine 存儲到這個 netFD 對應的 PollDesc 中,同時調用 gopark 把當前 goroutine 給 park 住,直到這個 netFD 上再次發生讀寫事件,才將此 goroutine 給 ready 激活重新運行。顯然,在底層通知 goroutine 再次發生讀寫等事件的方式就是 epoll/kqueue/iocp 等事件驅動機制。
Go 是一門跨平臺的編程語言,而不同平臺針對特定的功能有不用的實現,這當然也包括了 I/O 多路復用技術,比如 Linux 里的 I/O 多路復用有 select、poll 和 epoll,而 freeBSD 或者 MacOS 里則是 kqueue,而 Windows 里則是基于異步 I/O 實現的 iocp,等等;因此,Go 為了實現底層 I/O 多路復用的跨平臺,分別基于上述的這些不同平臺的系統調用實現了多版本的 netpollers,具體的源碼路徑如下:
- src/runtime/netpoll_epoll.go
- src/runtime/netpoll_kqueue.go
- src/runtime/netpoll_solaris.go
- src/runtime/netpoll_windows.go
- src/runtime/netpoll_aix.go
- src/runtime/netpoll_fake.go
本文的解析基于 epoll 版本,如果讀者對其他平臺的 netpoller 底層實現感興趣,可以在閱讀完本文后自行翻閱其他 netpoller 源碼,所有實現版本的機制和原理基本類似。
netpoller代碼結構概覽
實際的實現(epoll/kqueue)必須定義以下函數:
func netpollinit() // 初始化輪詢器
func netpollopen(fd uintptr, pd *pollDesc) int32 // 為fd和pd啟動邊緣觸發通知
當一個goroutine進行io阻塞時,會去被放到等待隊列。這里面就關鍵的就是建立起文件描述符和goroutine之間的關聯。 pollDesc結構體就是完成這個任務的。代碼參見src/runtime/netpoll.go
type pollDesc struct { // Poller對象
link *pollDesc // 鏈表
lock mutex // 保護下面字段
fd uintptr // fd是底層網絡io文件描述符,整個生命期內,不能改變值
closing bool
seq uintptr // protect from stale(過時) timers and ready notifications
rg uintptr // reader goroutine addr
rt timer
rd int64
wg uintptr // writer goroutine addr
wt timer
wd int64
user int32 // user-set cookie用戶自定義數據
}
type pollCache struct { // 全局Poller鏈表
lock mutex // 保護Poller鏈表
first *pollDesc
}
// 調用netpollinit()
func poll_runtime_pollServerInit() {}
// 調用netpollopen()
func poll_runtime_pollOpen() {}
// 調用netpollclose()
func poll_runtime_pollClose() {}
// 先check(netpollcheckerr(pd, mode))是否有err發生,沒有的話重置pd對應字段
func poll_runtime_pollReset(pd, mode) {}
// 先chekerr,再調用netpollblock(pd, mode, false) {}
func poll_runtime_pollWait(pd, mode) {}
// windows下專用
func poll_runtime_pollWaitCanceled(pd, mode) {}
func poll_runtime_pollSetDeadline(pd, deadline, mode) {}
//1. 重置定時器,并seq++
//2. 設置超時函數netpollDeadline(或者netpollReadDeadline、netpollWriteDeadline)
//3. 如果已經過期,調用netpollunblock和netpollgoready
// netpollUnblock、netpollgoready
func poll_runtime_pollUnblock(pd) {}
/*------------------部分實現------------------*/
// 檢查是否超時或正在關閉
func netpollcheckerr(pd, mode) {}
func netpollblockcommit(gp *g, gpp unsafe.Pointer) {}
// 調用netpollunblock,更新g的
func netpollready(gpp *guintptr, pd, mode) schedlink {}
// 更新統計數據,調用goready --- 通知調度器協程g從parked變為ready
func netpollgoready(gp *g, traceskip) {}
// Set rg/wg = pdWait,調用gopark掛起pd對應的g。
func netpollblock(pd, mode, waitio) {}
func netpollunblock(pd, mode, ioready) {}
func netpoll(Write/Read)Deadline(arg, seq) {}
pollCache
是pollDesc鏈表入口,加鎖保護鏈表安全。
pollDesc
中,rg、wg有些特殊,它可能有如下3種狀態:
pdReady == 1
: 網絡io就緒通知,goroutine消費完后應置為nil-
pdWait == 2
: goroutine等待被掛起,后續可能有3種情況:- goroutine被調度器掛起,置為goroutine地址
- 收到io通知,置為pdReady
- 超時或者被關閉,置為nil
Goroutine地址: 被掛起的goroutine的地址,當io就緒時、或者超時、被關閉時,此goroutine將被喚醒,同時將狀態改為pdReady或者nil。
另外,由于wg、rg是goroutine的地址,因此當GC發生后,如果goroutine被回收(在heap區),代碼就崩潰了(指針無效)。所以,進行網絡IO的goroutine不能在heap區分配內存。
lock鎖對象保護了
pollOpen
,pollSetDeadline
,pollUnblock
和deadlineimpl
操作。而這些操作又完全包含了對seq, rt, tw變量。fd在PollDesc
整個生命過程中都是一個常量。處理pollReset
,pollWait
,pollWaitCanceled
和runtime.netpollready
(IO就緒通知)不需要用到鎖,所以closing, rg, rd, wg和wd的所有操作都是一個無鎖的操作。
netpoller多路復用三部曲
初始化PollServer
初始化在下面注冊fd監聽時順便處理了,調用runtime_pollServerInit()
,并使用sync.Once()
機制保證只會被初始化一次。全局使用同一個EpollServer
(同一個Epfd
)。
func poll_runtime_pollServerInit() {
netpollGenericInit()
}
func netpollGenericInit() {
if atomic.Load(&netpollInited) == 0 {
lockInit(&netpollInitLock, lockRankNetpollInit)
lock(&netpollInitLock)
if netpollInited == 0 {
netpollinit() // 具現化到Linux下,調用epoll_create
atomic.Store(&netpollInited, 1)
}
unlock(&netpollInitLock)
}
}
func netpollinit() {
epfd = epollcreate1(_EPOLL_CLOEXEC)
if epfd < 0 {
epfd = epollcreate(1024)
if epfd < 0 {
println("runtime: epollcreate failed with", -epfd)
throw("runtime: netpollinit failed")
}
closeonexec(epfd)
}
r, w, errno := nonblockingPipe()
if errno != 0 {
println("runtime: pipe failed with", -errno)
throw("runtime: pipe failed")
}
ev := epollevent{
events: _EPOLLIN,
}
*(**uintptr)(unsafe.Pointer(&ev.data)) = &netpollBreakRd
errno = epollctl(epfd, _EPOLL_CTL_ADD, r, &ev)
if errno != 0 {
println("runtime: epollctl failed with", -errno)
throw("runtime: epollctl failed")
}
netpollBreakRd = uintptr(r)
netpollBreakWr = uintptr(w)
}
注冊監聽fd
所有Unix文件在初始化時,如果支持Poll,都會加入到PollServer的監聽中。
/*****************internal/poll/fd_unix.go*******************/
type FD struct {
// Lock sysfd and serialize access to Read and Write methods.
fdmu fdMutex
// System file descriptor. Immutable until Close.
Sysfd int
// I/O poller.
pd pollDesc
...
}
func(fd *FD) Init(net string, pollable bool) error {
...
err := fd.pd.init(fd) // 初始化pd
...
}
...
/*****************internal/poll/fd_poll_runtime.go*****************/
type pollDesc struct {
runtimeCtx uintptr
}
func (pd *pollDesc) init(fd *FD) error {
serverInit.Do(runtime_pollServerInit) // 初始化PollServer(sync.Once)
ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
...
runtimeCtx = ctx
return nil
}
...
/*****************runtime/netpoll.go*****************/
func poll_runtime_pollOpen(fd uintptr) (*epDesc, int32) {
...
errno := netpollopen(fd, pd) // 具現化到Linux下,調用epoll_ctl
...
}
取消fd的監聽與此流程類似,最終調用epoll_ctl
.
定期Poll
結合上述實現,必然有處邏輯定期執行epoll_wait
來檢測fd
狀態。在代碼中搜索下netpoll
,即可發現是在sysmon、startTheWorldWithSema、pollWork、findrunnable
中調用的,以sysmon
為例:
// runtime/proc.go
...
lastpoll := int64(atomic.Load64(&sched.lastpoll))
now := nanotime()
// 如果10ms內沒有poll過,則poll。(1ms=1000000ns)
if lastpoll != 0 && lastpoll+10*1000*1000 < now {
atomic.Cas64(&sched.lastpoll, uint64(lastpoll), uint64(now))
gp := netpoll(false) // netpoll在Linux具現為epoll_wait
if gp != nil {
injectglist(gp) //把g放到sched中去執行,底層仍然是調用的之前在goroutine里面提到的startm函數。
}
}
...
goroutine的I/O讀取流程
當goroutine發起一個同步調用,經過一系列的調用,最后會進入gopark函數,gopark將當前正在執行的goroutine狀態保存起來,然后切換到新的堆棧上執行新的goroutine。由于當前goroutine狀態是被保存起來的,因此后面可以被恢復。這樣調用Read的goroutine以為一直同步阻塞到現在,其實內部是異步完成的。
1. 加入監聽
golang中客戶端與服務端進行通訊時,常用如下方法:
conn, err := net.Dial("tcp", "localhost:1208")
從net.Dial看進去,最終會調用net/net_posix.go中的socket函數,大致流程如下:
func socket(...) ... {
/*
1. 調用sysSocket創建原生socket
2. 調用同名包下netFd(),初始化網絡文件描述符netFd
3. 調用fd.dial(),其中最終有調用poll_runtime_pollOpen()加入監聽列表
*/
}
runtime.poll_runtime_pollOpen
重置輪詢信息 runtime.pollDesc
并調用 runtime.netpollopen
初始化輪詢事件:
func poll_runtime_pollOpen(fd uintptr) (*pollDesc, int) {
pd := pollcache.alloc()
lock(&pd.lock)
if pd.wg != 0 && pd.wg != pdReady {
throw("runtime: blocked write on free polldesc")
}
...
pd.fd = fd
pd.closing = false
pd.everr = false
...
pd.wseq++
pd.wg = 0
pd.wd = 0
unlock(&pd.lock)
var errno int32
// 初始化輪詢事件
errno = netpollopen(fd, pd)
return pd, int(errno)
}
runtime.netpollopen
的實現非常簡單,它會調用 epollctl
向全局的輪詢文件描述符 epfd
中加入新的輪詢事件監聽文件描述符的可讀和可寫狀態:
func netpollopen(fd uintptr, pd *pollDesc) int32 {
var ev epollevent
ev.events = _EPOLLIN | _EPOLLOUT | _EPOLLRDHUP | _EPOLLET
*(**pollDesc)(unsafe.Pointer(&ev.data)) = pd
return -epollctl(epfd, _EPOLL_CTL_ADD, int32(fd), &ev)
}
從全局的 epfd
中刪除待監聽的文件描述符可以使用 runtime.netpollclose
,因為該函數的實現與 runtime.netpollopen
比較相似,所以這里不展開分析了。
2. 讀等待
主要是掛起goroutine,并建立gorotine和fd之間的關聯。
當從netFd讀取數據時,調用system call,循環從fd.sysfd讀取數據:
func (fd *FD) Read(p []byte) (int, error) {
if err := fd.pd.prepareRead(fd.isFile); err != nil {
return 0, err
}
if fd.IsStream && len(p) > maxRW {
p = p[:maxRW]
}
for {
n, err := syscall.Read(fd.Sysfd, p)
if err != nil {
n = 0
if err == syscall.EAGAIN && fd.pd.pollable() {
if err = fd.pd.waitRead(fd.isFile); err == nil {
continue
}
}
}
err = fd.eofError(n, err)
return n, err
}
}
讀取的時候只處理EAGAIN
類型的錯誤,其他錯誤一律返回給調用者,因為對于非阻塞的網絡連接的文件描述符,如果錯誤是EAGAIN
,說明Socket的緩沖區為空,未讀取到任何數據,則調用fd.pd.WaitRead
:
func (pd *pollDesc) waitRead(isFile bool) error {
return pd.wait('r', isFile)
}
func (pd *pollDesc) wait(mode int, isFile bool) error {
if pd.runtimeCtx == 0 {
return errors.New("waiting for unsupported file type")
}
res := runtime_pollWait(pd.runtimeCtx, mode)
return convertErr(res, isFile)
}
res是runtime_pollWait函數返回的結果,由conevertErr函數包裝后返回:
func convertErr(res int, isFile bool) error {
switch res {
case 0:
return nil
case 1:
return errClosing(isFile)
case 2:
return ErrTimeout
}
println("unreachable: ", res)
panic("unreachable")
}
其中0表示io已經準備好了,1表示鏈接意見關閉,2表示io超時。再來看看pollWait的實現:
func poll_runtime_pollWait(pd *pollDesc, mode int) int {
err := netpollcheckerr(pd, int32(mode))
if err != 0 {
return err
}
for !netpollblock(pd, int32(mode), false) {
err = netpollcheckerr(pd, int32(mode))
if err != 0 {
return err
}
}
return 0
}
調用netpollblock來判斷IO是否準備好了:
func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
gpp := &pd.rg
if mode == 'w' {
gpp = &pd.wg
}
for {
old := *gpp
if old == pdReady {
*gpp = 0
return true
}
if old != 0 {
throw("runtime: double wait")
}
if atomic.Casuintptr(gpp, 0, pdWait) {
break
}
}
if waitio || netpollcheckerr(pd, mode) == 0 {
gopark(netpollblockcommit, unsafe.Pointer(gpp), "IO wait", traceEvGoBlockNet, 5)
}
old := atomic.Xchguintptr(gpp, 0)
if old > pdWait {
throw("runtime: corrupted polldesc")
}
return old == pdReady
}
返回true說明IO已經準備好,返回false說明IO操作已經超時或者已經關閉。否則當waitio為false, 且io不出現錯誤或者超時才會掛起當前goroutine。
最后的gopark函數,就是將當前的goroutine(調用者)設置為waiting狀態:
// Puts the current goroutine into a waiting state and calls unlockf.
// If unlockf returns false, the goroutine is resumed.
// unlockf must not access this G's stack, as it may be moved between
// the call to gopark and the call to unlockf.
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason string, traceEv byte, traceskip int) {
mp := acquirem()
gp := mp.curg
status := readgstatus(gp)
if status != _Grunning && status != _Gscanrunning {
throw("gopark: bad g status")
}
mp.waitlock = lock
mp.waitunlockf = *(*unsafe.Pointer)(unsafe.Pointer(&unlockf))
gp.waitreason = reason
mp.waittraceev = traceEv
mp.waittraceskip = traceskip
releasem(mp)
// can't do anything that might move the G between Ms here.
mcall(park_m)
}
mcall(park_m)
將會掛起當前與g綁定的m:
func park_m(gp *g) {
_g_ := getg()
if trace.enabled {
traceGoPark(_g_.m.waittraceev, _g_.m.waittraceskip)
}
casgstatus(gp, _Grunning, _Gwaiting)
dropg()
if _g_.m.waitunlockf != nil {
fn := *(*func(*g, unsafe.Pointer) bool)(unsafe.Pointer(&_g_.m.waitunlockf))
ok := fn(gp, _g_.m.waitlock)
_g_.m.waitunlockf = nil
_g_.m.waitlock = nil
if !ok {
if trace.enabled {
traceGoUnpark(gp, 2)
}
casgstatus(gp, _Gwaiting, _Grunnable)
execute(gp, true) // Schedule it back, never returns.
}
}
schedule()
}
3. 就緒喚醒
那什么時候goroutine被喚醒并調度回來呢?運行時在執行schedule()
方法時,會通過findrunnable()
,調用netpoll()
檢查文件描述符狀態。
schedule() -> findrunnable() -> netpoll()
調用netpoll()
尋找到IO就緒的socket文件描述符,并找到這些socket文件描述符對應的輪詢器中附帶的信息,根據這些信息將之前等待這些socket文件描述符就緒的goroutine狀態修改為Grunnable。執行完netpoll之后,會找到一個就緒的goroutine列表,接下來將就緒的goroutine加入到調度隊列中,等待調度運行。
下面我們看下netpoll()
的源碼實現:
// polls for ready network connections
// returns list of goroutines that become runnable
func netpoll(block bool) *g {
if epfd == -1 {
return gList{}
}
var waitms int32
// 根據傳入的 delay 計算 epoll 系統調用需要等待的時間
if delay < 0 {
waitms = -1
} else if delay == 0 {
waitms = 0
} else if delay < 1e6 {
waitms = 1
} else if delay < 1e15 {
waitms = int32(delay / 1e6)
} else {
waitms = 1e9
}
var events [128]epollevent
retry:
// 調用 epollwait 等待可讀或者可寫事件的發生
n := epollwait(epfd, &events[0], int32(len(events)), waitms)
if n < 0 {
if n != -_EINTR {
println("runtime: epollwait on fd", epfd, "failed with", -n)
throw("runtime: netpoll failed")
}
goto retry
}
var gp guintptr
// 在循環中依次處理 epollevent 事件
for i := int32(0); i < n; i++ {
ev := &events[i]
if ev.events == 0 {
continue
}
var mode int32
if ev.events&(_EPOLLIN|_EPOLLRDHUP|_EPOLLHUP|_EPOLLERR) != 0 {
mode += 'r'
}
if ev.events&(_EPOLLOUT|_EPOLLHUP|_EPOLLERR) != 0 {
mode += 'w'
}
if mode != 0 {
pd := *(**pollDesc)(unsafe.Pointer(&ev.data))
// 文件描述符的正常讀寫事件,對于這些事件,我們會交給netpollready處理
netpollready(&gp, pd, mode)
}
}
if block && gp == 0 {
goto retry
}
return gp.ptr()
}
當netpoll()
調用epollwait()
獲取到被監控的文件描述符出現了待處理的事件,就會在循環中依次調用netpollready()
處理這些事件。
func netpollready(toRun *gList, pd *pollDesc, mode int32) {
var rg, wg *g
if mode == 'r' || mode == 'r'+'w' {
rg = netpollunblock(pd, 'r', true)
}
if mode == 'w' || mode == 'r'+'w' {
wg = netpollunblock(pd, 'w', true)
}
if rg != nil {
toRun.push(rg)
}
if wg != nil {
toRun.push(wg)
}
}
runtime.netpollunblock
會在讀寫事件發生時,將 runtime.pollDesc
中的讀或者寫信號量轉換成 pdReady
并返回其中存儲的 goroutine;如果返回的 Goroutine 不會為空,那么運行時會將該 goroutine 會加入 toRun
列表,并將列表中的全部 goroutine 加入運行隊列。
當goroutine 加入運行隊列后,在某一次調度goroutine的過程中,處于就緒狀態的FD對應的goroutine就會被調度回來。
netpoller超時控制
網絡輪詢器和計時器的關系非常緊密,這不僅僅是因為網絡輪詢器負責計時器的喚醒,還因為文件和網絡 I/O 的截止日期也由網絡輪詢器負責處理。截止日期在 I/O 操作中,尤其是網絡調用中很關鍵,網絡請求存在很高的不確定因素,我們需要設置一個截止日期保證程序的正常運行,這時需要用到網絡輪詢器中的 runtime.poll_runtime_pollSetDeadline
:
func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) {
rd0, wd0 := pd.rd, pd.wd
if d > 0 {
d += nanotime()
}
pd.rd = d
...
if pd.rt.f == nil {
if pd.rd > 0 {
pd.rt.f = netpollReadDeadline
pd.rt.arg = pd
pd.rt.seq = pd.rseq
resettimer(&pd.rt, pd.rd)
}
} else if pd.rd != rd0 {
pd.rseq++
if pd.rd > 0 {
modtimer(&pd.rt, pd.rd, 0, rtf, pd, pd.rseq)
} else {
deltimer(&pd.rt)
pd.rt.f = nil
}
}
該函數會先使用截止日期計算出過期的時間點,然后根據 runtime.pollDesc
的狀態做出以下不同的處理:
- 如果結構體中的計時器沒有設置執行的函數時,該函數會設置計時器到期后執行的函數、傳入的參數并調用
runtime.resettimer
重置計時器; - 如果結構體的讀截止日期已經被改變,我們會根據新的截止日期做出不同的處理:
- 如果新的截止日期大于 0,調用
runtime.modtimer
修改計時器; - 如果新的截止日期小于 0,調用
runtime.deltimer
刪除計時器;
- 如果新的截止日期大于 0,調用
在 runtime.poll_runtime_pollSetDeadline
的最后,會重新檢查輪詢信息中存儲的截止日期:
var rg *g
if pd.rd < 0 {
if pd.rd < 0 {
rg = netpollunblock(pd, 'r', false)
}
...
}
if rg != nil {
netpollgoready(rg, 3)
}
...
}
如果截止日期小于 0,上述代碼會調用 runtime.netpollgoready
直接喚醒對應的 Goroutine。
在 runtime.poll_runtime_pollSetDeadline
中直接調用 runtime.netpollgoready
是相對比較特殊的情況。在正常情況下,運行時都會在計時器到期時調用 runtime.netpollDeadline
、runtime.netpollReadDeadline
和 runtime.netpollWriteDeadline
三個函數:
上述三個函數都會通過
runtime.netpolldeadlineimpl
調用 runtime.netpollgoready
直接喚醒相應的 Goroutine:
func netpolldeadlineimpl(pd *pollDesc, seq uintptr, read, write bool) {
currentSeq := pd.rseq
if !read {
currentSeq = pd.wseq
}
if seq != currentSeq {
return
}
var rg *g
if read {
pd.rd = -1
atomic.StorepNoWB(unsafe.Pointer(&pd.rt.f), nil)
rg = netpollunblock(pd, 'r', false)
}
...
if rg != nil {
netpollgoready(rg, 0)
}
...
}
Goroutine 在被喚醒之后會意識到當前的 I/O 操作已經超時,可以根據需要選擇重試請求或者中止調用。
總結
總的來說,netpoller的最終的效果就是用戶層阻塞,底層非阻塞。當goroutine讀或寫阻塞時會被放到等待隊列,這個goroutine失去了運行權,但并不是真正的整個系統“阻塞”于系統調用。而通過后臺的poller不停地poll,所有的文件描述符都被添加到了這個poller中的,當某個時刻一個文件描述符準備好了,poller就會喚醒之前因它而阻塞的goroutine,于是goroutine重新運行起來。
和使用Unix系統中的select或是poll方法不同地是,Golang的netpoller查詢的是能被調度的goroutine而不是那些函數指針、包含了各種狀態變量的struct等,這樣你就不用管理這些狀態,也不用重新檢查函數指針等,這些都是你在傳統Unix網絡I/O需要操心的問題。
References:
https://zhuanlan.zhihu.com/p/143847169
https://developer.aliyun.com/article/893401
https://zhuanlan.zhihu.com/p/159457916
https://www.yuque.com/aceld/golang/sdgfgu
https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-netpoller
https://strikefreedom.top/archives/go-netpoll-io-multiplexing-reactor
https://juejin.cn/post/6882984260672847879
https://mp.weixin.qq.com/s/T-hP3wt4whtvVh1H1LBU3w
https://yizhi.ren/2019/06/08/gonetpoller/
https://www.cnblogs.com/luozhiyun/p/14390824.html
https://cloud.tencent.com/developer/article/1234360
https://learnku.com/articles/59847