<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          Go BIO/NIO探討(6):IO多路復用之select

          共 8829字,需瀏覽 18分鐘

           ·

          2023-03-06 00:18

          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模型工作方式的圖:

          cd402df826369aa3a9cbbb853dbdad04.webp

          這里復用上篇文章提到的概念,講數(shù)據(jù)讀取流程分為兩個階段:

          1. 第一階段:read數(shù)據(jù)可用/write緩沖區(qū)可用之前,等待的過程

          2. 第二階段: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ù)可讀信號。他們通用的邏輯有:

          1. 需要定義一組要監(jiān)聽的套接字和要監(jiān)聽的事件;

          2. 定義polling的timeout值:0值表示不等待,大于0表示最長等待timeout時間;小于零或空指針表示永久等待;

          3. 返回值是有事件發(fā)生的socket數(shù);

          4. 錯誤碼被重置(同時返回值是-1);

          5. 函數(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)勢。比如下面這兩種常見的情形:

          1. 處理邏輯包含大量的rpc調用時,當前的goroutine可能會被休眠而不能去處理其他請求;

          2. 計算邏輯比較耗時,單個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 交流學習。

          瀏覽 136
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  亚欧无码线免费观看视频 | 国产探花丝袜 | 亚洲成人免费黄色视频 | 无码人妻aV一区二区三区色欲 | 97人妻人人揉人人躁人人 |