Go netpoll I/O 多路複用構建原生網路模型之原始碼深度解析

panjf2000發表於2019-11-10

原文

Go netpoll I/O 多路複用構建原生網路模型之原始碼深度解析

導言

Go 基於 I/O multiplexing 和 goroutine 構建了一個簡潔而高效能的原生網路模型 (基於 Go 的 I/O 多路複用 netpoll),提供了 goroutine-per-connection 這樣簡單的網路程式設計模式。在這種模式下,開發者使用的是同步的模式去編寫非同步的邏輯,極大地降低了開發者編寫網路應用時的心智負擔,且藉助於 Go runtime scheduler 對 goroutines 的高效排程,這個原生網路模型不論從適用性還是效能上都足以滿足絕大部分的應用場景。

然而,在工程性上能做到如此高的普適性和相容性,最終暴露給開發者提供介面/模式如此簡潔,其底層必然是基於非常複雜的封裝,做了很多取捨,也有可能放棄了一些『極致』的設計和理念。事實上netpoll底層就是基於 epoll/kqueue/iocp 這些系統呼叫來做封裝的,最終暴露出 goroutine-per-connection 這樣的極簡的開發模式給使用者。

Go netpoll 在不同的作業系統,其底層使用的 I/O 多路複用技術也不一樣,可以從 Go 原始碼目錄結構和對應程式碼檔案瞭解 Go 在不同平臺下的網路 I/O 模式的實現。比如,在 Linux 系統下基於 epoll,freeBSD 系統下基於 kqueue,以及 Windows 系統下基於 iocp。

本文將基於 linux 平臺來解析 Go netpoll 之 I/O 多路複用的底層是如何基於 epoll 封裝實現的,從原始碼層層推進,全面而深度地解析 Go netpoll 的設計理念和實現原理,以及 Go 是如何利用netpoll來構建它的原生網路模型的。主要涉及到的一些概念:I/O 模式、使用者/核心空間、epoll、linux 原始碼、goroutine scheduler 等等,我會盡量簡單地講解,如果有對相關概念不熟悉的同學,還是希望能提前熟悉一下。

使用者空間與核心空間

現在作業系統都是採用虛擬儲存器,那麼對 32 位作業系統而言,它的定址空間(虛擬儲存空間)為 4G(2 的 32 次方)。作業系統的核心是核心,獨立於普通的應用程式,可以訪問受保護的記憶體空間,也有訪問底層硬體裝置的所有許可權。為了保證使用者程式不能直接操作核心(kernel),保證核心的安全,操心繫統將虛擬空間劃分為兩部分,一部分為核心空間,一部分為使用者空間。針對 linux 作業系統而言,將最高的 1G 位元組(從虛擬地址 0xC0000000 到 0xFFFFFFFF),供核心使用,稱為核心空間,而將較低的 3G 位元組(從虛擬地址 0x00000000 到 0xBFFFFFFF),供各個程式使用,稱為使用者空間。

I/O 多路複用

在神作《UNIX 網路程式設計》裡,總結歸納了 5 種 I/O 模型,包括同步和非同步 I/O:

  • 阻塞 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)

作業系統上的 I/O 是使用者空間和核心空間的資料互動,因此 I/O 操作通常包含以下兩個步驟:

  1. 等待網路資料到達網路卡 (讀就緒)/等待網路卡可寫 (寫就緒) –> 讀取/寫入到核心緩衝區
  2. 從核心緩衝區複製資料 –> 使用者空間 (讀)/從使用者空間複製資料 -> 核心緩衝區 (寫)

而判定一個 I/O 模型是同步還是非同步,主要看第二步:資料在使用者和核心空間之間複製的時候是不是會阻塞當前程式,如果會,則是同步 I/O,否則,就是非同步 I/O。基於這個原則,這 5 種 I/O 模型中只有一種非同步 I/O 模型:Asynchronous I/O,其餘都是同步 I/O 模型。

這 5 種 I/O 模型的對比如下:

所謂 I/O 多路複用指的就是 select/poll/epoll 這一系列的多路選擇器:支援單一執行緒同時監聽多個檔案描述符(I/O 事件),阻塞等待,並在其中某個檔案描述符可讀寫時收到通知。 I/O 複用其實複用的不是 I/O 連線,而是複用執行緒,讓一個 thread of control 能夠處理多個連線(I/O 事件)。

select & poll

#include <sys/select.h>

/* According to earlier standards */ #include <sys/time.h> #include <sys/types.h> #include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

// 和 select 緊密結合的四個巨集: void FD_CLR(int fd, fd_set set); int FD_ISSET(int fd, fd_set *set); void FD_SET(int fd, fd_set *set); void FD_ZERO(fd_set *set);

select 是 epoll 之前 linux 使用的 I/O 事件驅動技術。

理解 select 的關鍵在於理解 fd_set,為說明方便,取 fd_set 長度為 1 位元組,fd_set 中的每一 bit 可以對應一個檔案描述符 fd,則 1 位元組長的 fd_set 最大可以對應 8 個 fd。select 的呼叫過程如下:

  1. 執行 FD_ZERO(&set), 則 set 用位表示是 0000,0000
  2. 若 fd=5, 執行 FD_SET(fd, &set); 後 set 變為 0001,0000(第 5 位置為 1)
  3. 再加入 fd=2, fd=1,則 set 變為 0001,0011
  4. 執行 select(6, &set, 0, 0, 0) 阻塞等待
  5. 若 fd=1, fd=2 上都發生可讀事件,則 select 返回,此時 set 變為 0000,0011 (注意:沒有事件發生的 fd=5 被清空)

基於上面的呼叫過程,可以得出 select 的特點:

  • 可監控的檔案描述符個數取決於 sizeof(fd_set) 的值。假設伺服器上 sizeof(fd_set)=512,每 bit 表示一個檔案描述符,則伺服器上支援的最大檔案描述符是 512*8=4096。fd_set 的大小調整可參考 【原創】技術系列之 網路模型(二) 中的模型 2,可以有效突破 select 可監控的檔案描述符上限
  • 將 fd 加入 select 監控集的同時,還要再使用一個資料結構 array 儲存放到 select 監控集中的 fd,一是用於在 select 返回後,array 作為源資料和 fd_set 進行 FD_ISSET 判斷。二是 select 返回後會把以前加入的但並無事件發生的 fd 清空,則每次開始 select 前都要重新從 array 取得 fd 逐一加入(FD_ZERO 最先),掃描 array 的同時取得 fd 最大值 maxfd,用於 select 的第一個引數
  • 可見 select 模型必須在 select 前迴圈 array(加 fd,取 maxfd),select 返回後迴圈 array(FD_ISSET 判斷是否有事件發生)

所以,select 有如下的缺點:

  1. 最大併發數限制:使用 32 個整數的 32 位,即 32*32=1024 來標識 fd,雖然可修改,但是有以下第 2, 3 點的瓶頸
  2. 每次呼叫 select,都需要把 fd 集合從使用者態拷貝到核心態,這個開銷在 fd 很多時會很大
  3. 效能衰減嚴重:每次 kernel 都需要線性掃描整個 fd_set,所以隨著監控的描述符 fd 數量增長,其 I/O 效能會線性下降

poll 的實現和 select 非常相似,只是描述 fd 集合的方式不同,poll 使用 pollfd 結構而不是 select 的 fd_set 結構,poll 解決了最大檔案描述符數量限制的問題,但是同樣需要從使用者態拷貝所有的 fd 到核心態,也需要線性遍歷所有的 fd 集合,所以它和 select 只是實現細節上的區分,並沒有本質上的區別。

epoll

epoll 是 linux kernel 2.6 之後引入的新 I/O 事件驅動技術,I/O 多路複用的核心設計是 1 個執行緒處理所有連線的等待訊息準備好I/O 事件,這一點上 epoll 和 select&poll 是大同小異的。但 select&poll 預估錯誤了一件事,當數十萬併發連線存在時,可能每一毫秒只有數百個活躍的連線,同時其餘數十萬連線在這一毫秒是非活躍的。select&poll 的使用方法是這樣的:返回的活躍連線 == select(全部待監控的連線)

什麼時候會呼叫 select&poll 呢?在你認為需要找出有報文到達的活躍連線時,就應該呼叫。所以,select&poll 在高併發時是會被頻繁呼叫的。這樣,這個頻繁呼叫的方法就很有必要看看它是否有效率,因為,它的輕微效率損失都會被高頻二字所放大。它有效率損失嗎?顯而易見,全部待監控連線是數以十萬計的,返回的只是數百個活躍連線,這本身就是無效率的表現。被放大後就會發現,處理併發上萬個連線時,select&poll 就完全力不從心了。這個時候就該 epoll 上場了,epoll 通過一些新的設計和優化,基本上解決了 select&poll 的問題。

epoll 的 API 非常簡潔,涉及到的只有 3 個系統呼叫:

#include <sys/epoll.h>
int epoll_create(int size); // int epoll_create1(int flags); 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);

其中,epoll_create 建立一個 epoll 例項並返回 epollfd;epoll_ctl 註冊 file descriptor 等待的 I/O 事件 (比如 EPOLLIN、EPOLLOUT 等) 到 epoll 例項上;epoll_wait 則是阻塞監聽 epoll 例項上所有的 file descriptor 的 I/O 事件,它接收一個使用者空間上的一塊記憶體地址 (events 陣列),kernel 會在有 I/O 事件發生的時候把檔案描述符列表複製到這塊記憶體地址上,然後 epoll_wait 解除阻塞並返回,最後使用者空間上的程式就可以對相應的 fd 進行讀寫了:

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

epoll 的工作原理如下:

與 select&poll 相比,epoll 分清了高頻呼叫和低頻呼叫。例如,epoll_ctl 相對來說就是不太頻繁被呼叫的,而 epoll_wait 則是非常頻繁被呼叫的。所以 epoll 利用 epoll_ctl 來插入或者刪除一個 fd,實現使用者態到核心態的資料拷貝,這確保了每一個 fd 在其生命週期只需要被拷貝一次,而不是每次呼叫 epoll_wait 的時候都拷貝一次。 epoll_wait 則被設計成幾乎沒有入參的呼叫,相比 select&poll 需要把全部監聽的 fd 集合從使用者態拷貝至核心態的做法,epoll 的效率就高出了一大截。

在實現上 epoll 採用紅黑樹來儲存所有監聽的 fd,而紅黑樹本身插入和刪除效能比較穩定,時間複雜度 O(logN)。通過 epoll_ctl 函式新增進來的 fd 都會被放在紅黑樹的某個節點內,所以,重複新增是沒有用的。當把 fd 新增進來的時候時候會完成關鍵的一步:該 fd 都會與相應的裝置(網路卡)驅動程式建立回撥關係,也就是在核心中斷處理程式為它註冊一個回撥函式,在 fd 相應的事件觸發(中斷)之後(裝置就緒了),核心就會呼叫這個回撥函式,該回撥函式在核心中被稱為:ep_poll_callback這個回撥函式其實就是把這個 fd 新增到 rdllist 這個雙向連結串列(就緒連結串列)中。epoll_wait 實際上就是去檢查 rdlist 雙向連結串列中是否有就緒的 fd,當 rdlist 為空(無就緒 fd)時掛起當前程式,直到 rdlist 非空時程式才被喚醒並返回。

相比於 select&poll 呼叫時會將全部監聽的 fd 從使用者態空間拷貝至核心態空間併線性掃描一遍找出就緒的 fd 再返回到使用者態,epoll_wait 則是直接返回已就緒 fd,因此 epoll 的 I/O 效能不會像 select&poll 那樣隨著監聽的 fd 數量增加而出現線性衰減,是一個非常高效的 I/O 事件驅動技術。

由於使用 epoll 的 I/O 多路複用需要使用者程式自己負責 I/O 讀寫,從使用者程式的角度看,讀寫過程是阻塞的,所以 select&poll&epoll 本質上都是同步 I/O 模型,而像 Windows 的 IOCP 這一類的非同步 I/O,只需要在呼叫 WSARecv 或 WSASend 方法讀寫資料的時候把使用者空間的記憶體 buffer 提交給 kernel,kernel 負責資料在使用者空間和核心空間拷貝,完成之後就會通知使用者程式,整個過程不需要使用者程式參與,所以是真正的非同步 I/O。

延伸

另外,我看到有些文章說 epoll 之所以效能高是因為利用了 linux 的 mmap 記憶體對映讓核心和使用者程式共享了一片實體記憶體,用來存放就緒 fd 列表和它們的資料 buffer,所以使用者程式在 epoll_wait返回之後使用者程式就可以直接從共享記憶體那裡讀取/寫入資料了,這讓我很疑惑,因為首先看epoll_wait的函式宣告:

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

第二個引數:就緒事件列表,是需要在使用者空間分配記憶體然後再傳給epoll_wait的,如果核心會用 mmap 設定共享記憶體,直接傳遞一個指標進去就行了,根本不需要在使用者態分配記憶體,多此一舉。其次,核心和使用者程式通過 mmap 共享記憶體是一件極度危險的事情,核心無法確定這塊共享記憶體什麼時候會被回收,而且這樣也會賦予使用者程式直接操作核心資料的許可權和入口,非常容易出現大的系統漏洞,因此一般極少會這麼做。所以我很懷疑 epoll 是不是真的在 linux kernel 裡用了 mmap,我就去看了下最新版本(5.3.9)的 linux kernel 原始碼:

/

  • Implement the event wait interface for the eventpoll file. It is the kernel
  • part of the user space epoll_wait(2). */

static int do_epoll_wait(int epfd, struct epoll_event __user *events, int maxevents, int timeout) { // ...

/* Time to fish for events ... */ error = ep_poll(ep, events, maxevents, timeout); }

// 如果 epoll_wait 入參時設定 timeout == 0, 那麼直接通過 ep_events_available 判斷當前是否有使用者感興趣的事件發生,如果有則通過 ep_send_events 進行處理 // 如果設定 timeout > 0,並且當前沒有使用者關注的事件發生,則進行休眠,並新增到 ep->wq 等待佇列的頭部;對等待事件描述符設定 WQ_FLAG_EXCLUSIVE 標誌 // ep_poll 被事件喚醒後會重新檢查是否有關注事件,如果對應的事件已經被搶走,那麼 ep_poll 會繼續休眠等待 static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events, int maxevents, long timeout) { // ...

send_events: /* * Try to transfer events to user space. In case we get 0 events and * there's still timeout left over, we go trying again in search of * more luck. */

// 如果一切正常, 有 event 發生, 就開始準備資料 copy 給使用者空間了 // 如果有就緒的事件發生,那麼就呼叫 ep_send_events 將就緒的事件 copy 到使用者態記憶體中, // 然後返回到使用者態,否則判斷是否超時,如果沒有超時就繼續等待就緒事件發生,如果超時就返回使用者態。 // 從 ep_poll 函式的實現可以看到,如果有就緒事件發生,則呼叫 ep_send_events 函式做進一步處理 if (!res && eavail && !(res = ep_send_events(ep, events, maxevents)) && !timed_out) goto fetch_events;

// ... }

// ep_send_events 函式是用來向使用者空間拷貝就緒 fd 列表的,它將使用者傳入的就緒 fd 列表記憶體簡單封裝到 // ep_send_events_data 結構中,然後呼叫 ep_scan_ready_list 將就緒佇列中的事件寫入使用者空間的記憶體; // 使用者程式就可以訪問到這些資料進行處理 static int ep_send_events(struct eventpoll *ep, struct epoll_event __user *events, int maxevents) { struct ep_send_events_data esed;

esed.maxevents = maxevents; esed.events = events; // 呼叫 ep_scan_ready_list 函式檢查 epoll 例項 eventpoll 中的 rdllist 就緒連結串列, // 並註冊一個回撥函式 ep_send_events_proc,如果有就緒 fd,則呼叫 ep_send_events_proc 進行處理 ep_scan_ready_list(ep, ep_send_events_proc, &esed, 0, false); return esed.res; }

// 呼叫 ep_scan_ready_list 的時候會傳遞指向 ep_send_events_proc 函式的函式指標作為回撥函式, // 一旦有就緒 fd,就會呼叫 ep_send_events_proc 函式 static __poll_t ep_send_events_proc(struct eventpoll *ep, struct list_head *head, void *priv) { // ...

/* * If the event mask intersect the caller-requested one, * deliver the event to userspace. Again, ep_scan_ready_list() * is holding ep->mtx, so no operations coming from userspace * can change the item. */ revents = ep_item_poll(epi, &pt, 1); // 如果 revents 為 0,說明沒有就緒的事件,跳過,否則就將就緒事件拷貝到使用者態記憶體中 if (!revents) continue; // 將當前就緒的事件和使用者程式傳入的資料都通過 put_user 拷貝回使用者空間, // 也就是呼叫 epoll_wait 之時使用者程式傳入的 fd 列表的記憶體 if (put_user(revents, &uevent->events) || __put_user(epi->event.data, &uevent->data)) { list_add(&epi->rdllink, head); ep_pm_stay_awake(epi); if (!esed->res) esed->res = -EFAULT; return 0; }

// ... }

do_epoll_wait開始層層跳轉,我們可以很清楚地看到最後核心是通過put_user函式把就緒 fd 列表和事件返回到使用者空間,而put_user正是核心用來拷貝資料到使用者空間的標準函式。此外,我並沒有在 linux kernel 的原始碼中和 epoll 相關的程式碼裡找到 mmap 系統呼叫做記憶體對映的邏輯,所以基本可以得出結論:epoll 在 linux kernel 裡並沒有使用 mmap 來做使用者空間和核心空間的記憶體共享,所以那些說 epoll 使用了 mmap 的文章都是誤解。

Non-blocking I/O

什麼叫非阻塞 I/O,顧名思義就是:所有 I/O 操作都是立刻返回而不會阻塞當前使用者程式。I/O 多路複用通常情況下需要和非阻塞 I/O 搭配使用,否則可能會產生意想不到的問題。比如,epoll 的 ET(邊緣觸發) 模式下,如果不使用非阻塞 I/O,有極大的概率會導致阻塞 event-loop 執行緒,從而降低吞吐量,甚至導致 bug。

Linux 下,我們可以通過 fcntl 系統呼叫來設定 O_NONBLOCK標誌位,從而把 socket 設定成 non-blocking。當對一個 non-blocking socket 執行讀操作時,流程是這個樣子:

當使用者程式發出 read 操作時,如果 kernel 中的資料還沒有準備好,那麼它並不會 block 使用者程式,而是立刻返回一個 EAGAIN error。從使用者程式角度講 ,它發起一個 read 操作後,並不需要等待,而是馬上就得到了一個結果。使用者程式判斷結果是一個 error 時,它就知道資料還沒有準備好,於是它可以再次傳送 read 操作。一旦 kernel 中的資料準備好了,並且又再次收到了使用者程式的 system call,那麼它馬上就將資料拷貝到了使用者記憶體,然後返回。

所以,non-blocking I/O 的特點是使用者程式需要不斷的主動詢問 kernel 資料好了沒有。

Go netpoll

一個典型的 Go TCP server:

package main

import ( "fmt" "net" )

func main() { listen, err := net.Listen("tcp", ":8888") if err != nil { fmt.Println("listen error: ", err) return }

for { conn, err := listen.Accept() if err != nil { fmt.Println("accept error: ", err) break }

// start a new goroutine to handle the new connection go HandleConn(conn) } } func HandleConn(conn net.Conn) { defer conn.Close() packet := make([]byte, 1024) for { // 如果沒有可讀資料,也就是讀 buffer 為空,則阻塞 _, _ = conn.Read(packet) // 同理,不可寫則阻塞 _, _ = conn.Write(packet) } }

上面是一個基於 Go 原生網路模型(基於 netpoll)編寫的一個 TCP server,模式是 goroutine-per-connection,在這種模式下,開發者使用的是同步的模式去編寫非同步的邏輯而且對於開發者來說 I/O 是否阻塞是無感知的,也就是說開發者無需考慮 goroutines 甚至更底層的執行緒、程式的排程和上下文切換。而 Go netpoll 最底層的事件驅動技術肯定是基於 epoll/kqueue/iocp 這一類的 I/O 事件驅動技術,只不過是把這些排程和上下文切換的工作轉移到了 runtime 的 Go scheduler,讓它來負責排程 goroutines,從而極大地降低了程式設計師的心智負擔!

Go netpoll 核心

Go netpoll 通過在底層對 epoll/kqueue/iocp 的封裝,從而實現了使用同步程式設計模式達到非同步執行的效果。總結來說,所有的網路操作都以網路描述符 netFD 為中心實現。netFD 與底層 PollDesc 結構繫結,當在一個 netFD 上讀寫遇到 EAGAIN 錯誤時,就將當前 goroutine 儲存到這個 netFD 對應的 PollDesc 中,同時呼叫 gopark 把當前 goroutine 給 park 住,直到這個 netFD 上再次發生讀寫事件,才將此 goroutine 給 ready 啟用重新執行。顯然,在底層通知 goroutine 再次發生讀寫等事件的方式就是 epoll/kqueue/iocp 等事件驅動機制。

接下來我們通過分析最新的 Go 原始碼(v1.13.4),解讀一下整個 netpoll 的執行流程。

上面的示例程式碼中相關的在原始碼裡的幾個資料結構和方法:

// TCPListener is a TCP network listener. Clients should typically
// use variables of type Listener instead of assuming TCP.
type TCPListener struct {
    fd *netFD
    lc ListenConfig
}

// Accept implements the Accept method in the Listener interface; it // waits for the next call and returns a generic Conn. func (l *TCPListener) Accept() (Conn, error) { if !l.ok() { return nil, syscall.EINVAL } c, err := l.accept() if err != nil { return nil, &OpError{Op: "accept", Net: l.fd.net, Source: nil, Addr: l.fd.laddr, Err: err} } return c, nil }

func (ln *TCPListener) accept() (*TCPConn, error) { fd, err := ln.fd.accept() if err != nil { return nil, err } tc := newTCPConn(fd) if ln.lc.KeepAlive >= 0 { setKeepAlive(fd, true) ka := ln.lc.KeepAlive if ln.lc.KeepAlive == 0 { ka = defaultTCPKeepAlive } setKeepAlivePeriod(fd, ka) } return tc, nil }

// TCPConn is an implementation of the Conn interface for TCP network // connections. type TCPConn struct { conn }

// Conn type conn struct { fd *netFD }

type conn struct { fd *netFD }

func (c *conn) ok() bool { return c != nil && c.fd != nil }

// Implementation of the Conn interface.

// Read implements the Conn Read method. func (c *conn) Read(b []byte) (int, error) { if !c.ok() { return 0, syscall.EINVAL } n, err := c.fd.Read(b) if err != nil && err != io.EOF { err = &OpError{Op: "read", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err} } return n, err }

// Write implements the Conn Write method. func (c *conn) Write(b []byte) (int, error) { if !c.ok() { return 0, syscall.EINVAL } n, err := c.fd.Write(b) if err != nil { err = &OpError{Op: "write", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err} } return n, err }

netFD

net.Listen("tcp", ":8888") 方法返回了一個 TCPListener,它是一個實現了 net.Listener 介面的 struct,而通過 listen.Accept() 接收的新連線 TCPConn 則是一個實現了 net.Conn 介面的 struct,它內嵌了 net.conn struct。仔細閱讀上面的原始碼可以發現,不管是 Listener 的 Accept 還是 Conn 的 Read/Write 方法,都是基於一個 netFD 的資料結構的操作,netFD 是一個網路描述符,類似於 Linux 的檔案描述符的概念,netFD 中包含一個 poll.FD 資料結構,而 poll.FD 中包含兩個重要的資料結構 Sysfd 和 pollDesc,前者是真正的系統檔案描述符,後者對是底層事件驅動的封裝,所有的讀寫超時等操作都是通過呼叫後者的對應方法實現的。

netFDpoll.FD的原始碼:

// Network file descriptor.
type netFD struct {
    pfd poll.FD

// immutable until Close family int sotype int isConnected bool // handshake completed or use of association with peer net string laddr Addr raddr Addr }

// FD is a file descriptor. The net and os packages use this type as a // field of a larger type representing a network connection or OS file. 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

// Writev cache. iovecs *[]syscall.Iovec

// Semaphore signaled when file is closed. csema uint32

// Non-zero if this file has been set to blocking mode. isBlocking uint32

// Whether this is a streaming descriptor, as opposed to a // packet-based descriptor like a UDP socket. Immutable. IsStream bool

// Whether a zero byte read indicates EOF. This is false for a // message based socket connection. ZeroReadIsEOF bool

// Whether this is a file rather than a network socket. isFile bool }

pollDesc

前面提到了 pollDesc 是底層事件驅動的封裝,netFD 通過它來完成各種 I/O 相關的操作,它的定義如下:

type pollDesc struct {
    runtimeCtx uintptr
}

這裡的 struct 只包含了一個指標,而通過 pollDesc 的 init 方法,我們可以找到它具體的定義是在runtime.pollDesc這裡:

func (pd *pollDesc) init(fd *FD) error {
    serverInit.Do(runtime_pollServerInit)
    ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd))
    if errno != 0 {
        if ctx != 0 {
            runtime_pollUnblock(ctx)
            runtime_pollClose(ctx)
        }
        return syscall.Errno(errno)
    }
    pd.runtimeCtx = ctx
    return nil
}

// Network poller descriptor. // // No heap pointers. // //go:notinheap type pollDesc struct { link *pollDesc // in pollcache, protected by pollcache.lock

// The lock protects pollOpen, pollSetDeadline, pollUnblock and deadlineimpl operations. // This fully covers seq, rt and wt variables. fd is constant throughout the PollDesc lifetime. // pollReset, pollWait, pollWaitCanceled and runtime·netpollready (IO readiness notification) // proceed w/o taking the lock. So closing, everr, rg, rd, wg and wd are manipulated // in a lock-free way by all operations. // NOTE(dvyukov): the following code uses uintptr to store *g (rg/wg), // that will blow up when GC starts moving objects. lock mutex // protects the following fields fd uintptr closing bool everr bool // marks event scanning error happened user uint32 // user settable cookie rseq uintptr // protects from stale read timers rg uintptr // pdReady, pdWait, G waiting for read or nil rt timer // read deadline timer (set if rt.f != nil) rd int64 // read deadline wseq uintptr // protects from stale write timers wg uintptr // pdReady, pdWait, G waiting for write or nil wt timer // write deadline timer wd int64 // write deadline }

runtime.pollDesc包含自身型別的一個指標,用來儲存下一個runtime.pollDesc的地址,以此來實現連結串列,可以減少資料結構的大小,所有的runtime.pollDesc儲存在runtime.pollCache結構中,定義如下:

type pollCache struct {
   lock  mutex
   first *pollDesc
   // PollDesc objects must be type-stable,
   // because we can get ready notification from epoll/kqueue
   // after the descriptor is closed/reused.
   // Stale notifications are detected using seq variable,
   // seq is incremented when deadlines are changed or descriptor is reused.
}

net.Listen

呼叫 net.Listen之後,底層會通過 Linux 的系統呼叫socket 方法建立一個 fd 分配給 listener,並用以來初始化 listener 的 netFD,接著呼叫 netFD 的listenStream方法完成對 socket 的 bind&listen 操作以及對 netFD 的初始化(主要是對 netFD 裡的 pollDesc 的初始化),相關原始碼如下:

// 呼叫 linux 系統呼叫 socket 建立 listener fd 並設定為為阻塞 I/O    
s, err := socketFunc(family, sotype|syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC, proto)
// On Linux the SOCK_NONBLOCK and SOCK_CLOEXEC flags were
// introduced in 2.6.27 kernel and on FreeBSD both flags were
// introduced in 10 kernel. If we get an EINVAL error on Linux
// or EPROTONOSUPPORT error on FreeBSD, fall back to using
// socket without them.

socketFunc func(int, int, int) (int, error) = syscall.Socket

// 用上面建立的 listener fd 初始化 listener netFD if fd, err = newFD(s, family, sotype, net); err != nil { poll.CloseFunc(s) return nil, err }

// 對 listener fd 進行 bind&listen 操作,並且呼叫 init 方法完成初始化 func (fd *netFD) listenStream(laddr sockaddr, backlog int, ctrlFn func(string, string, syscall.RawConn) error) error { // ...

// 完成繫結操作 if err = syscall.Bind(fd.pfd.Sysfd, lsa); err != nil { return os.NewSyscallError("bind", err) }

// 完成監聽操作 if err = listenFunc(fd.pfd.Sysfd, backlog); err != nil { return os.NewSyscallError("listen", err) }

// 呼叫 init,內部會呼叫 poll.FD.Init,最後呼叫 pollDesc.init if err = fd.init(); err != nil { return err } lsa, _ = syscall.Getsockname(fd.pfd.Sysfd) fd.setAddr(fd.addrFunc()(lsa), nil) return nil }

// 使用 sync.Once 來確保一個 listener 只持有一個 epoll 例項 var serverInit sync.Once

// netFD.init 會呼叫 poll.FD.Init 並最終呼叫到 pollDesc.init, // 它會建立 epoll 例項並把 listener fd 加入監聽佇列 func (pd *pollDesc) init(fd *FD) error { // runtime_pollServerInit 內部呼叫了 netpollinit 來建立 epoll 例項 serverInit.Do(runtime_pollServerInit)

// runtime_pollOpen 內部呼叫了 netpollopen 來將 listener fd 註冊到 // epoll 例項中,另外,它會初始化一個 pollDesc 並返回 ctx, errno := runtime_pollOpen(uintptr(fd.Sysfd)) if errno != 0 { if ctx != 0 { runtime_pollUnblock(ctx) runtime_pollClose(ctx) } return syscall.Errno(errno) } // 把真正初始化完成的 pollDesc 例項賦值給當前的 pollDesc 代表自身的指標, // 後續使用直接通過該指標操作 pd.runtimeCtx = ctx return nil }

// netpollopen 會被 runtime_pollOpen,註冊 fd 到 epoll 例項, // 同時會利用萬能指標把 pollDesc 儲存到 epollevent 的一個 8 位的位元組陣列 data 裡 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) }

我們前面提到的 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

netFD 就是通過這三個封裝來對 epoll 進行建立例項、註冊 fd 和等待事件操作的。

Listener.Accept()

netpoll accept socket 的工作流程如下:

  1. 服務端的 netFD 在listen時會建立 epoll 的例項,並將 listenerFD 加入 epoll 的事件佇列
  2. netFD 在accept時將返回的 connFD 也加入 epoll 的事件佇列
  3. netFD 在讀寫時出現syscall.EAGAIN錯誤,通過 pollDesc 的 waitRead 方法將當前的 goroutine park 住,直到 ready,從 pollDesc 的waitRead中返回

Listener.Accept()接收來自客戶端的新連線,具體還是呼叫netFD.accept方法來完成這個功能:

// Accept implements the Accept method in the Listener interface; it
// waits for the next call and returns a generic Conn.
func (l *TCPListener) Accept() (Conn, error) {
    if !l.ok() {
        return nil, syscall.EINVAL
    }
    c, err := l.accept()
    if err != nil {
        return nil, &OpError{Op: "accept", Net: l.fd.net, Source: nil, Addr: l.fd.laddr, Err: err}
    }
    return c, nil
}

func (ln *TCPListener) accept() (*TCPConn, error) { fd, err := ln.fd.accept() if err != nil { return nil, err } tc := newTCPConn(fd) if ln.lc.KeepAlive >= 0 { setKeepAlive(fd, true) ka := ln.lc.KeepAlive if ln.lc.KeepAlive == 0 { ka = defaultTCPKeepAlive } setKeepAlivePeriod(fd, ka) } return tc, nil }

netFD.accept方法裡再呼叫poll.FD.Accept,最後會使用 linux 的系統呼叫accept來完成新連線的接收,並且會把 accept 的 socket 設定成非阻塞 I/O 模式:

// Accept wraps the accept network call.
func (fd *FD) Accept() (int, syscall.Sockaddr, string, error) {
    if err := fd.readLock(); err != nil {
        return -1, nil, "", err
    }
    defer fd.readUnlock()

if err := fd.pd.prepareRead(fd.isFile); err != nil { return -1, nil, "", err } for { // 使用 linux 系統呼叫 accept 接收新連線,建立對應的 socket s, rsa, errcall, err := accept(fd.Sysfd) // 因為 listener fd 在建立的時候已經設定成非阻塞的了, // 所以 accept 方法會直接返回,不管有沒有新連線到來;如果 err == nil 則表示正常建立新連線,直接返回 if err == nil { return s, rsa, "", err } // 如果 err != nil,則判斷 err == syscall.EAGAIN,符合條件則進入 pollDesc.waitRead 方法 switch err { case syscall.EAGAIN: if fd.pd.pollable() { // 如果當前沒有發生期待的 I/O 事件,那麼 waitRead 會通過 park goroutine 讓邏輯 block 在這裡 if err = fd.pd.waitRead(fd.isFile); err == nil { continue } } case syscall.ECONNABORTED: // This means that a socket on the listen // queue was closed before we Accept()ed it; // it's a silly error, so try again. continue } return -1, nil, errcall, err } }

// 使用 linux 的 accept 系統呼叫接收新連線並把這個 socket fd 設定成非阻塞 I/O ns, sa, err := Accept4Func(s, syscall.SOCK_NONBLOCK|syscall.SOCK_CLOEXEC) // On Linux the accept4 system call was introduced in 2.6.28 // kernel and on FreeBSD it was introduced in 10 kernel. If we // get an ENOSYS error on both Linux and FreeBSD, or EINVAL // error on Linux, fall back to using accept.

// Accept4Func is used to hook the accept4 call. var Accept4Func func(int, int) (int, syscall.Sockaddr, error) = syscall.Accept4

pollDesc.waitRead方法主要負責檢測當前這個 pollDesc 的上層 netFD 對應的 fd 是否有『期待的』I/O 事件發生,如果有就直接返回,否則就 park 住當前的 goroutine 並持續等待直至對應的 fd 上發生可讀/可寫或者其他『期待的』I/O 事件為止,然後它就會返回到外層的 for 迴圈,讓 goroutine 繼續執行邏輯。

Conn.Read/Conn.Write

我們先來看看Conn.Read方法是如何實現的,原理其實和 Listener.Accept 是一樣的,具體呼叫鏈還是首先呼叫 conn 的netFD.Read,然後內部再呼叫 poll.FD.Read,最後使用 linux 的系統呼叫 read: syscall.Read完成資料讀取:

// Implementation of the Conn interface.

// Read implements the Conn Read method. func (c *conn) Read(b []byte) (int, error) { if !c.ok() { return 0, syscall.EINVAL } n, err := c.fd.Read(b) if err != nil && err != io.EOF { err = &OpError{Op: "read", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err} } return n, err }

func (fd *netFD) Read(p []byte) (n int, err error) { n, err = fd.pfd.Read(p) runtime.KeepAlive(fd) return n, wrapSyscallError("read", err) }

// Read implements io.Reader. func (fd *FD) Read(p []byte) (int, error) { if err := fd.readLock(); err != nil { return 0, err } defer fd.readUnlock() if len(p) == 0 { // If the caller wanted a zero byte read, return immediately // without trying (but after acquiring the readLock). // Otherwise syscall.Read returns 0, nil which looks like // io.EOF. // TODO(bradfitz): make it wait for readability? (Issue 15735) return 0, nil } if err := fd.pd.prepareRead(fd.isFile); err != nil { return 0, err } if fd.IsStream && len(p) > maxRW { p = p[:maxRW] } for { // 嘗試從該 socket 讀取資料,因為 socket 在被 listener accept 的時候設定成 // 了非阻塞 I/O,所以這裡同樣也是直接返回,不管有沒有可讀的資料 n, err := syscall.Read(fd.Sysfd, p) if err != nil { n = 0 // err == syscall.EAGAIN 表示當前沒有期待的 I/O 事件發生,也就是 socket 不可讀 if err == syscall.EAGAIN && fd.pd.pollable() { // 如果當前沒有發生期待的 I/O 事件,那麼 waitRead // 會通過 park goroutine 讓邏輯 block 在這裡 if err = fd.pd.waitRead(fd.isFile); err == nil { continue } }

// On MacOS we can see EINTR here if the user // pressed ^Z. See issue #22838. if runtime.GOOS == "darwin" && err == syscall.EINTR { continue } } err = fd.eofError(n, err) return n, err } }

conn.Writeconn.Read的原理是一致的,它也是通過類似 pollDesc.waitReadpollDesc.waitWrite來 park 住 goroutine 直至期待的 I/O 事件發生才返回,而 pollDesc.waitWrite的內部實現原理和pollDesc.waitRead是一樣的,都是基於runtime_pollWait,這裡就不再贅述。

pollDesc.waitRead

pollDesc.waitRead內部呼叫了 runtime_pollWait來達成無 I/O 事件時 park 住 goroutine 的目的:

//go:linkname poll_runtime_pollWait internal/poll.runtime_pollWait
func poll_runtime_pollWait(pd *pollDesc, mode int) int {
    err := netpollcheckerr(pd, int32(mode))
    if err != 0 {
        return err
    }
    // As for now only Solaris, illumos, and AIX use level-triggered IO.
    if GOOS == "solaris" || GOOS == "illumos" || GOOS == "aix" {
        netpollarm(pd, mode)
    }
    // 進入 netpollblock 並且判斷是否有期待的 I/O 事件發生,
    // 這裡的 for 迴圈是為了一直等到 io ready
    for !netpollblock(pd, int32(mode), false) {
        err = netpollcheckerr(pd, int32(mode))
        if err != 0 {
            return err
        }
        // Can happen if timeout has fired and unblocked us,
        // but before we had a chance to run, timeout has been reset.
        // Pretend it has not happened and retry.
    }
    return 0
}

// returns true if IO is ready, or false if timedout or closed // waitio - wait only for completed IO, ignore errors func netpollblock(pd *pollDesc, mode int32, waitio bool) bool { // gpp 儲存的是 goroutine 的資料結構 g,這裡會根據 mode 的值決定是 rg 還是 wg // 後面呼叫 gopark 之後,會把當前的 goroutine 的抽象資料結構 g 存入 gpp 這個指標 gpp := &pd.rg if mode == 'w' { gpp = &pd.wg }

// set the gpp semaphore to WAIT // 這個 for 迴圈是為了等待 io ready 或者 io wait for { old := *gpp // gpp == pdReady 表示此時已有期待的 I/O 事件發生, // 可以直接返回 unblock 當前 goroutine 並執行響應的 I/O 操作 if old == pdReady { *gpp = 0 return true } if old != 0 { throw("runtime: double wait") } // 如果沒有期待的 I/O 事件發生,則通過原子操作把 gpp 的值置為 pdWait 並退出 for 迴圈 if atomic.Casuintptr(gpp, 0, pdWait) { break } }

// need to recheck error states after setting gpp to WAIT // this is necessary because runtime_pollUnblock/runtime_pollSetDeadline/deadlineimpl // do the opposite: store to closing/rd/wd, membarrier, load of rg/wg

// waitio 此時是 false,netpollcheckerr 方法會檢查當前 pollDesc 對應的 fd 是否是正常的, // 通常來說 netpollcheckerr(pd, mode) == 0 是成立的,所以這裡會執行 gopark // 把當前 goroutine 給 park 住,直至對應的 fd 上發生可讀/可寫或者其他『期待的』I/O 事件為止, // 然後 unpark 返回,在 gopark 內部會把當前 goroutine 的抽象資料結構 g 存入 // gpp(pollDesc.rg/pollDesc.wg) 指標裡,以便在後面的 netpoll 函式取出 pollDesc 之後, // 把 g 新增到連結串列裡返回,然後重新排程執行該 goroutine if waitio || netpollcheckerr(pd, mode) == 0 { // 註冊 netpollblockcommit 回撥給 gopark,在 gopark 內部會執行它,儲存當前 goroutine 到 gpp gopark(netpollblockcommit, unsafe.Pointer(gpp), waitReasonIOWait, traceEvGoBlockNet, 5) } // be careful to not lose concurrent READY notification old := atomic.Xchguintptr(gpp, 0) if old > pdWait { throw("runtime: corrupted polldesc") } return old == pdReady }

// gopark 會停住當前的 goroutine 並且呼叫傳遞進來的回撥函式 unlockf,從上面的原始碼我們可以知道這個函式是 // netpollblockcommit func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) { if reason != waitReasonSleep { checkTimeouts() // timeouts may expire while two goroutines keep the scheduler busy } mp := acquirem() gp := mp.curg status := readgstatus(gp) if status != _Grunning && status != _Gscanrunning { throw("gopark: bad g status") } mp.waitlock = lock mp.waitunlockf = unlockf gp.waitreason = reason mp.waittraceev = traceEv mp.waittraceskip = traceskip releasem(mp) // can't do anything that might move the G between Ms here. // gopark 最終會呼叫 park_m,在這個函式內部會呼叫 unlockf,也就是 netpollblockcommit, // 然後會把當前的 goroutine,也就是 g 資料結構儲存到 pollDesc 的 rg 或者 wg 指標裡 mcall(park_m) }

// park continuation on g0. func park_m(gp *g) { g := getg()

if trace.enabled { traceGoPark(g.m.waittraceev, g.m.waittraceskip) }

casgstatus(gp, _Grunning, _Gwaiting) dropg()

if fn := g.m.waitunlockf; fn != nil { // 呼叫 netpollblockcommit,把當前的 goroutine, // 也就是 g 資料結構儲存到 pollDesc 的 rg 或者 wg 指標裡 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() }

// netpollblockcommit 在 gopark 函式裡被呼叫 func netpollblockcommit(gp g, gpp unsafe.Pointer) bool { // 通過原子操作把當前 goroutine 抽象的資料結構 g,也就是這裡的引數 gp 存入 gpp 指標, // 此時 gpp 的值是 pollDesc 的 rg 或者 wg 指標 r := atomic.Casuintptr((*uintptr)(gpp), pdWait, uintptr(unsafe.Pointer(gp))) if r { // Bump the count of goroutines waiting for the poller. // The scheduler uses this to decide whether to block // waiting for the poller if there is nothing else to do. atomic.Xadd(&netpollWaiters, 1) } return r }

netpoll

前面已經從原始碼的角度分析完了 netpoll 是如何通過 park goroutine 從而達到阻塞 Accept/Read/Write 的效果,而通過呼叫 gopark,goroutine 會被放置在某個等待佇列中 (如 channel 的 waitq ,此時 G 的狀態由_Grunning_Gwaitting),因此 G 必須被手動喚醒 (通過 goready ),否則會丟失任務,應用層阻塞通常使用這種方式。

所以,最後還有一個非常關鍵的問題是:當 I/O 事件發生之後,netpoll 是通過什麼方式喚醒那些在 I/O wait 的 goroutine 的?答案是通過 epoll_wait,在 Go 原始碼中的 src/runtime/netpoll_epoll.go檔案中有一個 func netpoll(block bool) gList 方法,它會內部呼叫epoll_wait獲取就緒的 fd 列表,並將每個 fd 對應的 goroutine 新增到連結串列返回

// polls for ready network connections
// returns list of goroutines that become runnable
func netpoll(block bool) gList {
    if epfd == -1 {
        return gList{}
    }
    waitms := int32(-1)
    // 是否以阻塞模式呼叫 epoll_wait
    if !block {
        waitms = 0
    }
    var events [128]epollevent
retry:
    // 獲取就緒的 fd 列表
    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
    }
    // toRun 是一個 g 的連結串列,儲存要恢復的 goroutines,最後返回給呼叫方
    var toRun gList
    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 {
            // 取出儲存在 epollevent 裡的 pollDesc
            pd := *(*pollDesc)(unsafe.Pointer(&ev.data))
            pd.everr = false
            if ev.events == _EPOLLERR {
                pd.everr = true
            }
            // 呼叫 netpollready,傳入就緒 fd 的 pollDesc,把 fd 對應的 goroutine 新增到連結串列 toRun 中
            netpollready(&toRun, pd, mode)
        }
    }
    if block && toRun.empty() {
        goto retry
    }
    return toRun
}

// netpollready 呼叫 netpollunblock 返回就緒 fd 對應的 goroutine 的抽象資料結構 g 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) } }

// netpollunblock 會依據傳入的 mode 決定從 pollDesc 的 rg 或者 wg 取出當時 gopark 之時存入的 // goroutine 抽象資料結構 g 並返回 func netpollunblock(pd *pollDesc, mode int32, ioready bool) *g { // mode == 'r' 代表當時 gopark 是為了等待讀事件,而 mode == 'w' 則代表是等待寫事件 gpp := &pd.rg if mode == 'w' { gpp = &pd.wg }

for { // 取出 gpp 儲存的 g old := *gpp if old == pdReady { return nil } if old == 0 && !ioready { // Only set READY for ioready. runtime_pollWait // will check for timeout/cancel before waiting. return nil } var new uintptr if ioready { new = pdReady } // 重置 pollDesc 的 rg 或者 wg if atomic.Casuintpt

更多原創文章乾貨分享,請關注公眾號
  • Go netpoll I/O 多路複用構建原生網路模型之原始碼深度解析
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章