Go netpoller 網絡模型
使用 Go 進行網絡編程是十分的高效和便捷的,在 goroutine-per-connection 這樣的編程模式下開發者可以使用同步的模式來編寫業務邏輯而無需關心網絡的阻塞、異步等問題極大的降低了心智負擔。同時 Go 基於 I/O multiplexing 和 goroutine scheduler 使得這個網絡模型在開發模式和接口層面保持簡潔的同時也具備較高的性能可以滿足絕大多數的場景。
I/O 模型
在網絡編程的世界裏存在 5 種 IO 模型:
-
阻塞 I/O (Blocking I/O)
-
非阻塞 I/O (Nonblocking I/O)
-
I/O 多路複用 (I/O multiplexing)
-
信號驅動 I/O (Signal driven I/O)
-
異步 I/O (Asynchronous I/O)
當應用程序對一個 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 主要存在以下幾個缺點:
-
每次調用 select 都需要使用 copy_from_user 把 fd 集合從用戶態拷貝到內核態,當 fd 很多時這個開銷會很大
-
每次調用 select 都需要在內核遍歷傳遞進來的所有 fd 挨個檢查 fd 的狀態,隨着 fd 數量的增長 I/O 性能會線性下降
-
最大文件描述符數量限制,默認一次只能傳入 1024 個 fd,意味着一次只能監聽 1024 個 socket poll 在本質上與 select 沒有區別只是擴大了可傳入的 fd 集合的大小
epoll
相比於 select,用戶可以通過 epoll_ctl 調用將 fd 註冊到 epoll 實例上,而 epoll_wait 則會阻塞監聽所有的 epoll 實例上所有的 fd 的事件。他解決了 select 未解決的兩個問題:
-
通過 epoll_ctl 註冊 fd,一個 fd 只完成一次從用戶態到內核態的拷貝而不需要每次調用時都拷貝一次,並且 epoll 使用紅黑樹存儲所有的 fd 因此重複註冊是沒用的
-
當某個 fd 註冊完成後會與對應的設備建立回調關係,當設備就緒觸發中斷後內核通過該回調函數將該 fd 添加到 rdllist 雙向就緒鏈表中。epoll_wait 就是去檢查 rdllist 中是否有就緒的 fd,當 rdllist 爲空時就會阻塞掛起當前調用 epoll_wait 進程,直到 rdllist 非空時進程才被喚醒並返回。此處 epoll 解決了 select 的第二個問題:不需要每次調用都遍歷傳進來的 fd 列表,從而不會因爲隨着 fd 數量的增多而性能下降。
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
用來初始化listener
的netFD
(go 層面封裝的網絡文件描述符),同時會調用epoll_create
創建一個 epoll
實例 作爲整個 runtime
的唯一 event-loop
使用。
當一個 client 連接 server 時,listener 通過accept
接受新的連接,該連接的 fd 被設置爲非阻塞模式同時會起一個新的 goroutine 來處理新連接並將新連接對應的 fd 註冊到 epoll 中。當 goroutine 調用conn.Read
,conn.Write
對連接進行讀寫操作遇到EAGAI
N 錯誤時會被 gopark 給 park 住進行休眠,讓 P 去執行本地調度隊列裏的下一個可執行的 G。這些被 park 住的 goroutine 會在 goroutine 的調度中調用runtime.netpoll
被喚醒,然後調用 injectglist
把喚醒的 G 放入當前 P 本地調度隊列或者全局調度隊列去執行。
runtime.netpoll 的核心邏輯是:根據入參 delay
設置調用 epoll_wait
的 timeout
值,調用 epoll_wait
從 epoll
的 eventpoll.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 進行了優化,其中一個優化點是將timer
與netpoll
結合。在 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