Go netpoller 網絡模型

使用 Go 進行網絡編程是十分的高效和便捷的,在 goroutine-per-connection 這樣的編程模式下開發者可以使用同步的模式來編寫業務邏輯而無需關心網絡的阻塞、異步等問題極大的降低了心智負擔。同時 Go 基於 I/O multiplexing 和 goroutine scheduler 使得這個網絡模型在開發模式和接口層面保持簡潔的同時也具備較高的性能可以滿足絕大多數的場景。

I/O 模型

在網絡編程的世界裏存在 5 種 IO 模型:

當應用程序對一個 socket 發起一次網絡讀寫操作時分爲兩個步驟:

同步 IO 與異步 IO 的主要區別在於執行第二步的時候應用程序是否需要同步等待。上述 5 種 IO 模型除了 Asynchronous I/O 是異步之外,其餘都爲同步模型。

至於阻塞與非阻塞主要區別在於第一步數據未到達時當前進程是否會被阻塞住。

在阻塞模式下當應用程序調用recvfrom讀取socket時,如果數據未到達應用程序會被阻塞直到數據完全準備好。

在非阻塞模式下當應用程序調用recvfrom讀取socket時,如果數據未準備好則內核會立即返回一個EWOULDBLOCK,應用程序接到一個 EAGAIN error。非阻塞模式下也可以是同步 IO:基於輪詢,當返回 EAGAIN 時應用程序繼續發起 recvfrom 調用直到數據準備好。

當數據準備好之後就可以將數據從內核空間的緩存複製到用戶空間的緩存,在複製數據這一步應用程序還是阻塞的。

I/O 多路複用

IO 多路複用是指一個線程同時監聽多個 IO 連接的事件,阻塞等待,當某個連接發生可讀寫 IO 事件時收到通知。所以複用的是線程而不是 IO 連接。常見的多路複用選擇器有:select/poll/epoll、kqueue、iocp

簡單講一下 select/poll/epoll:

select&poll

select 主要存在以下幾個缺點:

epoll

相比於 select,用戶可以通過 epoll_ctl 調用將 fd 註冊到 epoll 實例上,而 epoll_wait 則會阻塞監聽所有的 epoll 實例上所有的 fd 的事件。他解決了 select 未解決的兩個問題:

netpoller

首先對於 epoll 提供的三個調用接口 Go 對此都做了封裝

#include <sys/epoll.h>  
int epoll_create(int size);  
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
// Go 對上面三個調用的封裝
func netpollinit()
func netpollopen(fd uintptr, pd *pollDesc) int32
func netpoll(block bool) gList

當調用net.Listen之後操作系統會創建一個socket同時返回與之對應的fd(被設置爲非阻塞模式),該fd用來初始化listenernetFD(go 層面封裝的網絡文件描述符),同時會調用epoll_create創建一個 epoll 實例 作爲整個 runtime 的唯一 event-loop 使用。

當一個 client 連接 server 時,listener 通過accept接受新的連接,該連接的 fd 被設置爲非阻塞模式同時會起一個新的 goroutine 來處理新連接並將新連接對應的 fd 註冊到 epoll 中。當 goroutine 調用conn.Readconn.Write對連接進行讀寫操作遇到EAGAIN 錯誤時會被 gopark 給 park 住進行休眠,讓 P 去執行本地調度隊列裏的下一個可執行的 G。這些被 park 住的 goroutine 會在 goroutine 的調度中調用runtime.netpoll被喚醒,然後調用 injectglist 把喚醒的 G 放入當前 P 本地調度隊列或者全局調度隊列去執行。

runtime.netpoll 的核心邏輯是:根據入參 delay設置調用 epoll_waittimeout 值,調用 epoll_waitepolleventpoll.rdllist雙向列表中獲取 IO 就緒的 fd 列表,遍歷epoll_wait 返回的fd列表, 根據調用epoll_ctl註冊fd時封裝的上下文信息組裝可運行的 goroutine 並返回。(仔細看遍歷fd列表的這個操作是不是又有點像select?)

調用 runtime.netpoll 的地方有兩處:

在調度器中執行runtime.schedule(),該方法中會執行runtime.findrunable(),在runtime.findrunable()中調用了runtime.netpoll獲取待執行的 goroutine Go runtime 在程序啓動的時候會創建一個獨立的 sysmon 監控線程,sysmon 每 20us~10ms 運行一次,每次運行會檢查距離上一次執行 netpoll 是否超過 10ms,如果是則會調用一次runtime.netpoll敲黑板,劃重點:上面過程中通過 Listen 和 Accept 返回的 fd 都被設置爲非阻塞模式,原因是如果爲阻塞模式則會使對應的 G 阻塞在 system call 上,此時與 G 對應的 M 也會被阻塞從而進入內核態,一旦進入內核態之後調度的控制權就不在 go runtime 手中也就無法藉助 go scheduler 進行調度。在非阻塞模式下對應 goroutine 是被 gopark 給 park 住放入某個 wait queue 中,M 可以繼續執行下一個 G。整個過程網絡 I/O 的控制權都牢牢掌握在 Go 自己的 runtime 裏。

one more thing

在 go1.14 對 timer 進行了優化,其中一個優化點是將timernetpoll結合。在 go1.10 之前由一個獨立的timerproc通過小頂堆和futexsleep來管理定時任務,go1.10 爲了降低鎖的競爭將其擴展爲最多 64 個小頂堆和timerproc,但依然沒有從本質上解決全局鎖以及多次 G、P 和 M 間的切換導致的性能開銷問題。

在 go1.14 中把存放定時事件的四叉堆放到 P 中,這樣既很大程度上避免了全局鎖又保證了 G、P 和 M 間的一致性避免相互之前多次切換,同時取消了timerproc協程轉而使用netpoll來做就近時間的休眠等待:

如果新添加的定時任務 when 小於 netpoll 的休眠等待時間sched.pollUntil就會激活netPoll的等待。也就是在runtime.findrunable裏的最後會使用超時阻塞的方法調用epoll_wait,這樣既監控了 epoll 實例紅黑樹上的 fd,又可兼顧最近的定時任務 在每次runtime.schedule調度時在runtime.findrunable中都會通過checkTimers來查找可運行的定時任務

轉自:SingleX

singlecool.com/2020/12/13/golang-netpoll/

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