Go BIO/NIO探討(6):IO多路復用之select
tcp connection 或已連接套接字(Established socket),可以理解為一個邏輯上的雙向通道,分別支持讀寫。不過在讀通道上,數(shù)據(jù)包的讀操作一般都是串行的;寫通道上,數(shù)據(jù)包的寫入也是串行的。對于net/http庫實現(xiàn)的tcp server而言,每次有一個新的客戶端connect,server端都會獲取一個已連接套接字(等價于一個有效的tcp conn,后面對這兩個概念不做區(qū)分),為其分配一個獨立的goroutine,串行地讀取request、處理request并寫入response。
goroutine在已連接的tcp conn上讀取請求時,在阻塞模式下,會等待有數(shù)據(jù)時真正開始讀套接字緩沖區(qū);非阻塞模式下,需要通過polling機制休眠當前goroutine,直到數(shù)據(jù)到來后被喚醒,然后開始讀套接字緩沖區(qū)。
Server端套接字的監(jiān)聽運行在一個獨立的goroutine里,如果同時有100個tcp conn,那么就會創(chuàng)建100個goroutine分別去處理conn上的請求。
那么問題來了:如果有1000個tcp conn,就需要1000個goroutine。那么問題來了,用1個行不行?
答案是可以,I/O多路復用的功能就是支持同時檢查N個tcp conn,并在任何一個有數(shù)據(jù)可讀時返回。
為了充分說明其區(qū)別,首先回顧下5種I/O模型工作方式的圖:

這里復用上篇文章提到的概念,講數(shù)據(jù)讀取流程分為兩個階段:
-
第一階段:read數(shù)據(jù)可用/write緩沖區(qū)可用之前,等待的過程
-
第二階段:read數(shù)據(jù)可用/write緩沖區(qū)可用之后,數(shù)據(jù)拷貝的過程
在阻塞式I/O和非阻塞式I/O中,一個tcp conn上的兩個階段是由一個goroutine來處理;
I/O多路復用模型下,一個goroutine就可以支持批量等待多個tcp conn上數(shù)據(jù)可讀的信號。簡單的處理方式是,在這個goroutine里遍歷所有可用的tcp conn,逐個從socket讀取數(shù)據(jù)、解析數(shù)據(jù)成request、邏輯處理生成response、向socket寫response。
Linux下提供了select、poll、epoll這三類系統(tǒng)調用支持批量等待數(shù)據(jù)可讀信號。他們通用的邏輯有:
-
需要定義一組要監(jiān)聽的套接字和要監(jiān)聽的事件;
-
定義polling的timeout值:0值表示不等待,大于0表示最長等待timeout時間;小于零或空指針表示永久等待;
-
返回值是有事件發(fā)生的socket數(shù);
-
錯誤碼被重置(同時返回值是-1);
-
函數(shù)返回后,接收參數(shù)均會被修改;
不同的地方在于:對于要監(jiān)聽的套接字和事件的定義方式不一樣,對參數(shù)的改動方式也不一樣。
select 系統(tǒng)調用
select函數(shù)的聲明如下:
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *errorfds,
struct timeval *timeout);
其中 nfds 指定了監(jiān)聽的套接字數(shù),readfds/writefds/errorfds分別指定了要監(jiān)聽的讀/寫/異常的套接字集,timeout指定了最長等待時間。
這里值得重點關注的是 struct fd_set,邏輯上它是一個長度為1024的bit數(shù)組,在實現(xiàn)過程中可以用長度為32的int32數(shù)組表示,也可以用長度為16的int64數(shù)組表示。考慮到big endian和little endian的影響,每個操作系統(tǒng)里在不同的硬件架構下采用不同的表示方式。linux下的一個實現(xiàn)是:
typedef struct {
uint32_t fd32[(FD_SETSIZE + 31) / 32];
} fd_set;
FD_SETSIZE 通常是1024。由于已連接套接字的編號從0開始,依次遞增;斷開連接后,id會被釋放出來。所以fd_set 可以支持監(jiān)聽1024個已連接套接字。
可以看到,select最多監(jiān)聽1024個套接字,而且每次調用都必須把三個fd_set(用戶態(tài))都傳過去,拷貝到內核態(tài)進行處理,之后將更新結果再同步到用戶態(tài)的fd_set。調用完成后,需要遍歷fd_set,才能知道哪些套接字發(fā)生了改變。
poll 系統(tǒng)調用
poll函數(shù)的聲明如下:
int poll(
struct pollfd *fds,
int nfds,
int timeout);
struct pollfd {
int fd;
short int events;
short int revents;
};
同樣的,nfds 指定了監(jiān)聽的套接字數(shù),但具體哪些套接字上的哪些事件被監(jiān)聽沒有按照信號拆分,而是按照套接字去拆分,表現(xiàn)為一個長度為nfds的pollfd數(shù)組,收到的事件也通過一個新字段revents來判斷,而不是修改傳入的字段。
這種表現(xiàn)方式的好處是,能監(jiān)聽的套接字不再受限于1024個,能定義的事件也不止read/write/error三個。poll支持很多類型的事件,并且支持了消息的優(yōu)先級。每次進行polling時,仍然需要把要監(jiān)聽的所有套接字和事件信息(用戶態(tài))都傳過去,拷貝到內核態(tài)處理,內核將更新結果再同步到用戶態(tài)的pollfd數(shù)組。調用完成后,需要便利pollfd數(shù)組,才能知道哪些套接字發(fā)生了變化。
epoll 系統(tǒng)調用
epoll針對select和poll的問題進行了優(yōu)化,主要在于每次polling時,只需要傳入一個epoll fd,而不是要監(jiān)聽的套接字集合。實現(xiàn)上包含三個系統(tǒng)調用:
// 創(chuàng)建一個epoll fd
int epoll_create1(int flags);
// 增加/刪除/更新監(jiān)聽的套接字
int epoll_ctl(
int epfd, // epoll fd
int op, // 操作:add/del/update
int fd, // 監(jiān)聽的套接字
struct epoll_event *event); // 監(jiān)聽哪些事件
int epoll_wait(
int epfd, // epoll fd
struct epoll_event *events, // 有事件發(fā)生的fd,需要提前分配好內存
int maxevents, // events的長度
int timeout); // 超時事件,-1表示一直block
相對于select和poll,epoll模式下內核承擔了維護套接字狀態(tài)的任務,使用紅黑樹去實現(xiàn)O(logN)復雜度的查找、插入、刪除和更新。用戶態(tài)層面上,epoll拆分了三個系統(tǒng)調用,通過這種拆分,大大減少了epoll_wait時用戶態(tài)和內核態(tài)之間的數(shù)據(jù)拷貝。
后面的部分,我們用select去實現(xiàn)echo server。
Go語言對select的封裝
Go語言中的系統(tǒng)調用代碼是通過命令生成的,對于 linux amd64 的代碼存放在文件 zsyscall_linux_amd64.go 下,生成命令為:
// mksyscall.pl -tags linux,amd64 syscall_linux.go syscall_linux_amd64.go
之所以能這樣做,是因為所有的指令本質上都是向linux系統(tǒng)發(fā)送的信號,不同的指令用不同的編號表示,通過函數(shù)Syscall或Syscall6向操作系統(tǒng)發(fā)送這些信號。以 Select 為例,內部調用是通過Syscall6發(fā)送 SYS_SELECT 信號。
// 位置: syscall/zsyscall_linux_amd64.go
func Select(nfd int, r *FdSet, w *FdSet, e *FdSet, timeout *Timeval) (n int, err error) {
r0, _, e1 := Syscall6(SYS_SELECT, uintptr(nfd), uintptr(unsafe.Pointer(r)), uintptr(unsafe.Pointer(w)), uintptr(unsafe.Pointer(e)), uintptr(unsafe.Pointer(timeout)), 0)
n = int(r0)
if e1 != 0 {
err = errnoErr(e1)
}
return
}
// 位置: runtime/internal/syscall/syscall_linux.go,具體實現(xiàn)在匯編里
// Syscall6 calls system call number 'num' with arguments a1-6.
func Syscall6(num, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, errno uintptr)
// 位置: runtime/internal/syscall/asm_linux_amd64.s
// func Syscall6(num, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2, errno uintptr)
//
// Syscall # in AX, args in DI SI DX R10 R8 R9, return in AX DX.
//
// Note that this differs from "standard" ABI convention, which would pass 4th
// arg in CX, not R10.
TEXT ·Syscall6(SB),NOSPLIT,$0-80
MOVQ num+0(FP), AX // syscall entry
MOVQ a1+8(FP), DI
MOVQ a2+16(FP), SI
MOVQ a3+24(FP), DX
MOVQ a4+32(FP), R10
MOVQ a5+40(FP), R8
MOVQ a6+48(FP), R9
SYSCALL
// 省略部分代碼
可以發(fā)現(xiàn),除了指令ID,額外的6個參數(shù)類型都是uintptr,也就是說 *FdSet 和 *Timeval 被強轉成C語言的指針,這要求這兩個結構體和C語言里struct fd_set 和 struct timeval的內存布局也是一致的。
用select改造echo server
之前我們用BIO的模式實現(xiàn)了一個echo server,現(xiàn)在增加Select對這個服務進行改造。
第一步是net.Listener的創(chuàng)建流程是一樣的,都是socket/bind/listen組合(省略錯誤處理邏輯):
var (
family = syscall.AF_INET
sotype = syscall.SOCK_STREAM
_ = "tcp"
listenBacklog = syscall.SOMAXCONN
serverip = net.IPv4(0, 0, 0, 0)
serverport = 8080
)
sockfd, err := syscall.Socket(family, sotype, 0)
syscall.CloseOnExec(sockfd)
addr, err := ipToSockaddrInet4(serverip, serverport)
err := syscall.Bind(sockfd, &addr)
err := syscall.Listen(sockfd, listenBacklog)
其次是監(jiān)聽新的tcp conn,并處理tcp conn上的請求。
BIO模式下是for循環(huán)+Accept實現(xiàn),然后創(chuàng)建一個新的goroutine處理新的tcp conn;
使用Select以后,使用for循環(huán)+Select+Accept/Read實現(xiàn)。監(jiān)聽套接字(Server端)和已連接套接字(新的tcp conn)都被存放到 readfds *syscall.FdSet,在Select看來沒有本質區(qū)別。
值得注意的是,Select并不會Accept或Read套接字上的數(shù)據(jù),只是監(jiān)聽信號。Select函數(shù)返回以后,對于監(jiān)聽套接字,我們通過syscall.Accept獲取新的已連接套接字;對于已連接套接字,通過syscall.Read讀取數(shù)據(jù)。下面是一個簡單的代碼實現(xiàn):
var nfds = sockfd // sockfd是監(jiān)聽套接字
var fdSet syscall.FdSet
// 講監(jiān)聽套接字加入read fdSet
fdsetutil.SetFdBit(sockfd, &fdSet)
// 已建立套接字存儲在一個map里
clientFdMap := make(map[int]struct{}, 1024)
for {
// select會修改這個值,所以拷貝一份fdSet
r := fdSet
// timeout = nil, Select 會被阻塞直到有一個 fd 可用
nReady, err := syscall.Select(nfds+1, &r, nil, nil, nil)
if err != nil {
panic("select error")
}
// 處理監(jiān)聽套接字
if fdsetutil.IsSetFdBit(sockfd, &r) {
clientSockfd, clientSockAddr, err := syscall.Accept(sockfd)
if err != nil {
log.Printf("accept sockfd %d error=%v\n", sockfd, err)
continue
}
clientSockAddrInet4 := clientSockAddr.(*syscall.SockaddrInet4)
log.Printf("Connected with new client, sock addr = %v:%d\n", clientSockAddrInet4.Addr, clientSockAddrInet4.Port)
clientFdMap[clientSockfd] = struct{}{}
fdsetutil.SetFdBit(clientSockfd, &fdSet)
if clientSockfd > nfds {
nfds = clientSockfd
}
}
// 處理已連接套接字
for clientSockFd := range clientFdMap {
if fdsetutil.IsSetFdBit(clientSockFd, &r) {
var buf [32 * 1024]byte
nRead, err := syscall.Read(clientSockFd, buf[:])
if err != nil {
log.Printf("fails to read data from sockfd %d, err=%v\n", clientSockFd, err)
_ = syscall.Close(clientSockFd)
fdsetutil.ClearFdBit(clientSockFd, &fdSet)
delete(clientFdMap, clientSockFd)
} else if nRead == 0 {
// Client closed
log.Printf("client sock %d closed\n", clientSockFd)
_ = syscall.Close(clientSockFd)
fdsetutil.ClearFdBit(clientSockFd, &fdSet)
delete(clientFdMap, clientSockFd)
} else {
log.Printf("read %d bytes from sock %d\n", nRead, clientSockFd)
if _, err := syscall.Write(clientSockFd, buf[:nRead]); err != nil {
log.Printf("fails to write data %s into sockfd %d, err=%v\n", buf[:nRead], sockfd, err)
}
}
}
}
}
在Go語言里,Linux amd64下syscall.FdSet的定義是:
// 位置: syscall/ztypes_linux_amd64.go
type FdSet struct {
Bits [16]int64
}
我們實現(xiàn)fdsetutil庫實現(xiàn)FdSet的讀寫,對應C語言里的宏定義 FD_CLR, FD_COPY, FD_ISSET, FD_SET, FD_ZERO。
點擊左下角“查看原文”閱讀這段代碼完整的版本(代碼在gist上,如果網絡不好需要多試幾次)。
通過Select改造以后,不再對每個新的tcp conn創(chuàng)建goroutine。結果是polling的效率提高了,不過從套接字讀取數(shù)據(jù)、數(shù)據(jù)處理、向套接字寫數(shù)據(jù)這三個計算過程都落到了一個goroutine上。如果連接數(shù)過多或處理邏輯比較耗時,并不能發(fā)揮多核的優(yōu)勢。比如下面這兩種常見的情形:
-
處理邏輯包含大量的rpc調用時,當前的goroutine可能會被休眠而不能去處理其他請求;
-
計算邏輯比較耗時,單個M(GMP里的M)一直在忙沒空處理其他tcp conn上的請求;
對于第一種情況,在網絡IO場景下,runtime對對goroutine的調度優(yōu)化完全無法發(fā)揮出來;
對于第二種情況,多核CPU的計算優(yōu)勢發(fā)揮不出來;
從性能層面上看,Select改造似乎并沒有多少優(yōu)勢,針對網絡IO密集型的服務,性能可能還不如Go語言采用的BIO模式。
但從歷史上來看,網絡IO復用是Blocking IO的迭代,性能上肯定會有所提升。那么問題出在哪里呢?
我們不妨跳出Go語言,回到Java/Python等更早期的語言。其中一個差別是在多線程的支持上,Go語言有Goroutine,依仗runtime的GMP模型進行調度;Java依賴操作系統(tǒng)的線程;Python是偽多線程。
在Java里,tcp conn上的數(shù)據(jù)處理可以交給線程池,Go里面對應的是Goroutine池。Goroutine池化以后的性能相對于線程池,優(yōu)勢可能沒那么明顯,這就回到了GMP經典面試題:操作系統(tǒng)線程和Goroutine有什么區(qū)別?Goroutine是如何實現(xiàn)的?是否有必要池化?
幾乎每個人都能答出來:Goroutine更輕量級;Goroutine運行在用戶態(tài),線程同時存在用戶態(tài)和內核態(tài)(Linux下);每個P都有一個本地Goroutine隊列,所有P共享一個全局Goroutine隊列;M數(shù)量受限于CPU核數(shù),Goroutine數(shù)量卻不受此限制;Goroutine處理網絡IO時,被休眠和喚醒的成本比較低,poll_runtime_pollWait依賴epoll對所有套接字統(tǒng)一進行polling等等。聯(lián)想有點遠了,后面專門聊聊這個話題。
簡單來說,Go runtime和net庫已經考慮這些問題,Goroutine的調度優(yōu)勢+基于epoll的netpoller帶來的性能優(yōu)勢,既能避免網絡IO只占用少量的CPU資源,又能保證其他CPU資源被充分利用,比常規(guī)的IO多路復用性能更佳(如果有大量的性能優(yōu)化,結論可能會有所不同)。
小結一下
這篇文章聊聊了IO多路復用的基本概念,并使用Select對echo server進行簡單的改造。下一篇文章我們繼續(xù)聊一聊IO多路復用,重點放到epoll上。
推薦閱讀
我為大家整理了一份 從入門到進階的Go學習資料禮包 ,包含學習建議:入門看什么,進階看什么。 關注公眾號 「polarisxu」,回復? ebook ?獲取;還可以回復「進群」,和數(shù)萬 Gopher 交流學習。
