golang netpoller

xuefeng發表於2021-08-10

socket(購買電話機)

int socket(int domain, int type, int protocol);

socket函式對應於普通檔案的開啟操作。普通檔案的開啟操作返回一個檔案描述字,而socket()用於建立一個socket描述符(socket descriptor),它唯一標識一個socket。這個socket描述字跟檔案描述字一樣,後續的操作都有用到它,把它作為引數,通過它來進行一些讀寫操作。

正如可以給fopen的傳入不同引數值,以開啟不同的檔案。建立socket的時候,也可以指定不同的引數建立不同的socket描述符,socket函式的三個引數分別為:
domain:即協議域,又稱為協議族(family)。常用的協議族有,AF_INET(IPv4)、AF_INET6(IPv6)、AF_LOCAL(或稱AF_UNIX,Unix域socket)、AF_ROUTE等等。協議族決定了socket的地址型別,在通訊中必須採用對應的地址,如AF_INET決定了要用ipv4地址(32位的)與埠號(16位的)的組合、AF_UNIX決定了要用一個絕對路徑名作為地址。
type:指定socket型別。常用的socket型別有,SOCK_STREAM(流式套接字)、SOCK_DGRAM(資料包式套接字)、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等
protocol:就是指定協議。常用的協議有,IPPROTO_TCP、PPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它們分別對應TCP傳輸協議、UDP傳輸協議、STCP傳輸協議、TIPC傳輸協議。linux2.1.17版本後增加了SOCK_NONBLOCK,代表了非阻塞模式
示例:

int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP);

bind(接電話線)

bind()函式把一個地址族中的特定地址賦給socket。例如對應AF_INET、AF_INET6就是把一個ipv4或ipv6地址和埠號組合賦給socket。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

函式的三個引數分別為:
sockfd:即socket描述字,它是通過socket()函式建立了,唯一標識一個socket。bind()函式就是將給這個描述字繫結一個名字

addr:一個const struct sockaddr *指標,指向要繫結給sockfd的協議地址,例如”127.0.0.1:80”

addrlen: 對應的是地址的長度。

listen(等電話中)

int  listen(int sockfd, int backlog);

backlog: socket可以排隊的最大連線個數
當有多個客戶端一起請求的時候,服務端不可能來多少就處理多少,這樣如果併發太多,就會因為效能的因素髮生擁塞,然後造成雪崩。所有就搞了一個佇列,先將請求放在佇列裡面,一個個來。socket_listen裡面的第二個引數backlog就是設定這個佇列的長度。如果將佇列長度設定成10,那麼如果有20個請求一起過來,服務端就會先放10個請求進入這個佇列,因為長度只有10。然後其他的就直接拒絕。tcp協議這時候不會傳送rst給客戶端,這樣的話客戶端就會重新傳送SYN,以便能進入這個佇列。
如果三次握手完成了,就會將完成三次握手的請求取出來,放入另一個佇列中,這樣佇列就空出一個位置,其他重發SYN的請求就可以進入佇列中。

connect(打電話)

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

sockaddr:socket地址,”127.0.0.1:8080”
addrlen:地址長度

accept(接電話)

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

sockaddr:一個指標,接收客戶端socket地址,”127.0.0.1:36820”
addrlen:客戶端地址長度

read/write/recv/send (通話)

read函式是負責從fd中讀取內容。
write函式將buf中的nbytes位元組內容寫入檔案描述符fd。

golang netpoller

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

select

#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 的關鍵在於理解 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 加入 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

struct eventpoll{
    .... 
    /*紅黑樹的根節點,這顆樹中儲存著所有新增到epoll中的需要監控的事件*/
    struct rb_root  rbr; 
    /*雙連結串列中則存放著將要通過epoll_wait返回給使用者的滿足條件的事件*/
    struct list_head rdlist;
    ....
};
#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);

由於epoll的實現機制與select/poll機制完全不同,上面所說的 select的缺點在epoll上不復存在。

設想一下如下場景:有100萬個客戶端同時與一個伺服器程式保持著TCP連線。而每一時刻,通常只有幾百上千個TCP連線是活躍的(事實上大部分場景都是這種情況)。如何實現這樣的高併發?

在select/poll時代,伺服器程式每次都把這100萬個連線告訴作業系統(從使用者態複製控制程式碼資料結構到核心態),讓作業系統核心去查詢這些套接字上是否有事件發生,輪詢完後,再將控制程式碼資料複製到使用者態,讓伺服器應用程式輪詢處理已發生的網路事件,這一過程資源消耗較大,因此,select/poll一般只能處理幾千的併發連線。

epoll的設計和實現與select完全不同。把原先的select/poll呼叫分成了3個部分:

1)呼叫epoll_create()建立一個epoll物件(紅黑樹)

2)呼叫epoll_ctl向epoll物件中新增這100萬個連線的套接字

3)呼叫epoll_wait收集發生的事件的連線(雙向連結串列)

如此一來,要實現上面說是的場景,只需要在程式啟動時建立一個epoll物件,然後在需要的時候向這個epoll物件中新增或者刪除連線。同時,epoll_wait的效率也非常高,因為呼叫epoll_wait時,並沒有一股腦的向作業系統複製這100萬個連線的控制程式碼資料,核心也不需要去遍歷全部的連線。

與 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 實際上就是去檢查 rdllist 雙向連結串列中是否有就緒的 fd,當 rdllist 為空(無就緒 fd)時掛起當前程式,直到 rdllist 非空時程式才被喚醒並返回。

邊緣/水平觸發模式

Level_triggered(水平觸發):當被監控的檔案描述符上有可讀寫事件發生時,epoll_wait()會通知處理程式去讀寫。如果這次沒有把資料一次性全部讀寫完(如讀寫緩衝區太小),那麼下次呼叫 epoll_wait()時,它還會通知你在上沒讀寫完的檔案描述符上繼續讀寫,當然如果你一直不去讀寫,它會一直通知你!!!如果系統中有大量你不需要讀寫的就緒檔案描述符,而它們每次都會返回,這樣會大大降低處理程式檢索自己關心的就緒檔案描述符的效率

Edge_triggered(邊緣觸發):當被監控的檔案描述符上有可讀寫事件發生時,epoll_wait()會通知處理程式去讀寫。如果這次沒有把資料全部讀寫完(如讀寫緩衝區太小),那麼下次呼叫epoll_wait()時,它不會通知你,也就是它只會通知你一次,直到該檔案描述符上出現第二次可讀寫事件才會通知你!!!這種模式比水平觸發效率高,系統不會充斥大量你不關心的就緒檔案描述符

為什麼ET模式要用非阻塞socket

假設buffer有1024位元組,一次讀取的資料很大,超過1024的大小:這種情況就需要讀取多次,也就是要呼叫多次recv函式,通常我們會用while迴圈一直讀取,無論ET還是LT,這樣LT模式就顯得很雞肋,所以一般不用LT。在ET模式下,(1)、如果用阻塞IO+while迴圈,當最後一個資料讀取完後,程式是無法立刻跳出while迴圈的,因為阻塞IO會在 while(true){ int len=recv(); }這裡阻塞住,除非對方關閉連線或者recv出錯,這樣程式就無法繼續往下執行,這一次的epoll_wait沒有辦法處理其它的連線,會造成延遲、併發度下降。(2)、如果是非阻塞IO+while迴圈當讀取完資料後,recv會立即返回-1,並將errno設定為EAGAIN或EWOULDBLOCK,這就表示資料已經讀取完成,已經沒有資料了,可以退出迴圈了。這樣就不會像阻塞IO一樣卡在那裡,這就減少了不必要的等待時間,效能自然更高。

int main(){  
    struct epoll_event ev, events[MAX_EVENTS];  
    int addrlen, listenfd, conn_sock, nfds, epfd, fd, i, nread, n;  
    struct sockaddr_in local, remote;  
    char buf[BUFSIZ];  

    //建立listen socket  
    if( (listenfd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK , 0)) < 0) {  
        perror("sockfd\n");  
        exit(1);  
    }  
    bzero(&local, sizeof(local));  
    local.sin_family = AF_INET;  
    local.sin_addr.s_addr = htonl(INADDR_ANY);;  
    local.sin_port = htons(PORT);  
    if( bind(listenfd, (struct sockaddr *) &local, sizeof(local)) < 0) {  
        perror("bind\n");  
        exit(1);  
    }  
    listen(listenfd, 20);  

    epfd = epoll_create(MAX_EVENTS);  
    if (epfd == -1) {  
        perror("epoll_create");  
        exit(EXIT_FAILURE);  
    }  

    ev.events = EPOLLIN;  
    ev.data.fd = listenfd;  
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev) == -1) {  
        perror("epoll_ctl: listen_sock");  
        exit(EXIT_FAILURE);  
    }  

    for (;;) {  
        nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);  
        if (nfds == -1) {  
            perror("epoll_pwait");  
            exit(EXIT_FAILURE);  
        }  
        for (i = 0; i < nfds; ++i) {  
            fd = events[i].data.fd;  
            if (fd == listenfd) {  
                while ((conn_sock = accept(listenfd,(struct sockaddr *) &remote,   
                                (size_t *)&addrlen)) > 0) {  
                    ev.events = EPOLLIN | EPOLLET;  
                    ev.data.fd = conn_sock;  
                    if (epoll_ctl(epfd, EPOLL_CTL_ADD, conn_sock,  
                                &ev) == -1) {  
                        perror("epoll_ctl: add");  
                        exit(EXIT_FAILURE);  
                    }  
                }  
                if (conn_sock == -1) {  
                    if (errno != EAGAIN && errno != ECONNABORTED   
                            && errno != EPROTO && errno != EINTR)   
                        perror("accept");  
                }  
                continue;  
            }    
            if (events[i].events & EPOLLIN) {  
                n = 0;  
                while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) {  
                    n += nread;  
                }  
                if (nread == -1 && errno != EAGAIN) {  
                    perror("read error");  
                }  
                ev.data.fd = fd;  
                ev.events = events[i].events | EPOLLOUT;  
                if (epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev) == -1) {  
                    perror("epoll_ctl: mod");  
                }  
            }  
            if (events[i].events & EPOLLOUT) {  
                sprintf(buf, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\nHello World", 11);  
                int nwrite, data_size = strlen(buf);  
                n = data_size;  
                while (n > 0) {  
                    nwrite = write(fd, buf + data_size - n, n);  
                    if (nwrite < n) {  
                        if (nwrite == -1 && errno != EAGAIN) {  
                            perror("write error");  
                        }  
                        break;  
                    }  
                    n -= nwrite;  
                }  
                close(fd);  
            }  
        }  
    }  
    return 0;  
}  

Go netpoller 基本原理

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 多路複用有 selectpollepoll,而 freeBSD 或者 MacOS 裡則是 kqueue,而 Windows 裡則是基於非同步 I/O 實現的 iocp,等等;因此,Go 為了實現底層 I/O 多路複用的跨平臺,分別基於上述的這些不同平臺的系統呼叫實現了多版本的 netpollers

資料結構

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
}

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
}

程式碼示例(服務端)

package main

import (
   "fmt"
 "log" "net")

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

   for {
      conn, err := listen.Accept()
      if err != nil {
         log.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 {
      // block here if socket is not available for reading data.
  n, err := conn.Read(packet)
      if err != nil {
         log.Println("read socket error: ", err)
         return
  }
      fmt.Println(string(packet[:n]))
      // same as above, block here if socket is not available for writing.
  _, _ = conn.Write([]byte("hi"))
   }
}

程式碼示例(客戶端)

package main

import (
    "fmt"
    "net"
    "testing"
)


func TestClient(t *testing.T) {
    conn, err := net.Dial("tcp", "127.0.0.1:8888")
    if err != nil {
        panic(err)
    }
    defer conn.Close()
    packet := make([]byte, 1024)
    d := "hello"
    _, err = conn.Write([]byte(d))
    if err != nil {
        panic(err)
    }

    n, err := conn.Read(packet)
    if err != nil {
        panic(err)
    }
    fmt.Println(string(packet[:n]))
}

epoll對應方法

#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

呼叫 net.Listen 之後,底層會通過 Linux 的系統呼叫 socket 方法建立一個 fd 分配給 listener,並用以來初始化 listener 的 netFD ,接著呼叫 netFD 的 listenStream 方法完成對 socket 的 bind&listen 操作以及對 netFD 的初始化

  1. 呼叫 epollcreate1 (netpollinit) 建立一個 epoll 例項 epfd,作為整個 runtime 的唯一 event-loop 使用;
  2. netpollBreakRd 通知訊號量封裝成 epollevent 事件結構體註冊(netpollopen)進 epoll 例項。

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 中返回

Conn.Read/Conn.Write

read/write 的工作流程如下:

  1. accept 成功時會生成客戶端connFD,並將fd 加入 epoll 的事件佇列
  2. netFD 在read/write時出現 syscall.EAGAIN 錯誤,通過 pollDesc 的 waitRead 方法將當前的 goroutine park 住,直到 ready,從 pollDesc 的 waitRead 中返回
    核心是呼叫 syscall.Read/syscall.Write 系統呼叫

epoll_wait時機

首先,client 連線 server 的時候,listener 通過 accept 呼叫接收新 connection,每一個新 connection 都啟動一個 goroutine 處理,accept 呼叫會把該 connection 的 fd 連帶所在的 goroutine 上下文資訊封裝註冊到 epoll 的監聽列表裡去,當 goroutine 呼叫 conn.Read 或者 conn.Write 等需要阻塞等待的函式時,會被 gopark 給封存起來並使之休眠,讓 P 去執行本地排程佇列裡的下一個可執行的 goroutine,往後 Go scheduler 會在迴圈排程的 runtime.schedule() 函式以及 sysmon 監控執行緒中呼叫 runtime.netpoll 以獲取可執行的 goroutine 列表並通過呼叫 injectglist 把剩下的 g 放入全域性排程佇列或者當前 P 本地排程佇列去重新執行。

那麼當 I/O 事件發生之後,netpoller 是通過什麼方式喚醒那些在 I/O wait 的 goroutine 的?答案是通過 runtime.netpoll

runtime.netpoll 的核心邏輯是:

  1. 根據呼叫方的入參 delay,設定對應的呼叫 epollwait 的 timeout 值;
  2. 呼叫 epollwait 等待發生了可讀/可寫事件的 fd;
  3. 迴圈 epollwait 返回的事件列表,處理對應的事件型別, 組裝可執行的 goroutine 連結串列並返回。

golang netpoller

本作品採用《CC 協議》,轉載必須註明作者和本文連結