深入理解 Go 高效能網路框架 nbio

俞凡發表於2024-12-05
本文深入探討了高效能網路框架 nbio 在 Golang 中的應用,包括其架構、配置、事件處理機制、核心元件等,並與 Evio 做了比較。原文: Analyzing High-Performance Network Framework nbio in Go

前言

nbio 專案還包括建立在 nbio 基礎上的 nbhttp,但這不在我們的討論範圍之內。

與 evio 一樣,nbio 也採用經典的 Reactor 模式。事實上,Go 中的許多非同步網路框架都是基於這種模式設計的。

我們先看看如何執行 nbio 程式碼。

伺服器:

package main

import (
   "fmt"
   "github.com/lesismal/nbio"
)

func main() {
   g := nbio.NewGopher(nbio.Config{
       Network:            "tcp",
       Addrs:              []string{":8888"},
       MaxWriteBufferSize: 6 * 1024 * 1024,
   })

   g.OnData(func(c *nbio.Conn, data []byte) {
       c.Write(append([]byte{}, data...))
   })

   err := g.Start()
   if err != nil {
       fmt.Printf("nbio.Start failed: %v\n", err)
       return
   }

   defer g.Stop()
   g.Wait()
}

我們用 nbio.NewGopher() 函式建立新的引擎例項,透過 nbio.Config 結構來配置引擎例項,包括:

  • Network(網路):使用的網路型別,本例中為 "TCP"。
  • Addrs(地址):伺服器應該監聽的地址和埠,這裡是":8888"(監聽本地計算機的 8888 埠)。
  • MaxWriteBufferSize(最大寫緩衝區大小):寫緩衝區的最大大小,此處設定為 6MB。

我們還可以進一步探索其他配置。然後,我們透過引擎例項 g.OnData() 註冊資料接收回撥函式,該回撥函式會在收到資料時呼叫。回撥函式需要兩個引數:連線物件 c 和接收到的資料 data。在回撥函式中,透過 c.Write() 方法將接收到的資料寫回客戶端。

客戶端:

package main

import (
   "bytes"
   "context"
   "fmt"
   "math/rand"
   "time"
   "github.com/lesismal/nbio"
   "github.com/lesismal/nbio/logging"
)

func main() {
   var (
       ret  []byte
       buf  = make([]byte, 1024*1024*4)
       addr = "localhost:8888"
       ctx, _ = context.WithTimeout(context.Background(), 60*time.Second)
   )

   logging.SetLevel(logging.LevelInfo)
   rand.Read(buf)

   g := nbio.NewGopher(nbio.Config{})
   done := make(chan int)

   g.OnData(func(c *nbio.Conn, data []byte) {
       ret = append(ret, data...)
       if len(ret) == len(buf) {
           if bytes.Equal(buf, ret) {
               close(done)
           }
       }
   })

   err := g.Start()
   if err != nil {
       fmt.Printf("Start failed: %v\n", err)
   }

   defer g.Stop()

   c, err := nbio.Dial("tcp", addr)
   if err != nil {
       fmt.Printf("Dial failed: %v\n", err)
   }

   g.AddConn(c)
   c.Write(buf)

   select {
   case <-ctx.Done():
       logging.Error("timeout")
   case <-done:
       logging.Info("success")
   }
}

乍一看似乎有點繁瑣,實際上伺服器和客戶端共享同一套結構。

客戶端透過 nbio.Dial 與伺服器連線,連線成功後封裝到 nbio.Conn 中。這裡 nbio.Conn 實現了標準庫中的 net.Conn 介面,最後透過 g.AddConn(c) 新增此連線,並向伺服器寫入資料。伺服器收到資料後,其處理邏輯是將資料原封不動傳送回客戶端,客戶端收到資料後,會觸發 OnData 回撥,該回撥會檢查收到的資料長度是否與傳送的資料長度一致,如果一致,則關閉連線。

下面深入探討幾個關鍵結構。

type Engine struct {
   //...
   sync.WaitGroup
   //...
   mux                        sync.Mutex
   wgConn                     sync.WaitGroup
   network                    string
   addrs                      []string
   //...
   connsStd                   map[*Conn]struct{}
   connsUnix                  []*Conn
   listeners                  []*poller
   pollers                    []*poller
   onOpen                     func(c *Conn)
   onClose                    func(c *Conn, err error)
   onRead                     func(c *Conn)
   onData                     func(c *Conn, data []byte)
   onReadBufferAlloc          func(c *Conn) []byte
   onReadBufferFree           func(c *Conn, buffer []byte)
   //...
}

Engine 本質上是核心管理器,負責管理所有監聽器、輪詢器和工作輪詢器。

這兩種輪詢器有什麼區別?

區別在於責任不同。

監聽輪詢器只負責接受新連線。當一個新的客戶端 conn 到達時,它會從 pollers 中選擇一個工作輪詢器,並將 conn 新增到相應的工作輪詢器中。隨後,工作輪詢器負責處理該連線的讀/寫事件。

因此當我們啟動程式時,如果只監聽一個地址,程式中的輪詢次數等於 1(監聽器輪詢器)+ pollerNum

透過上述欄位,可以自定義配置和回撥。例如,可以在新連線到達時設定 onOpen 回撥函式,或在資料到達時設定 onData 回撥函式等。

type Conn struct {
   mux                   sync.Mutex
   p                     *poller
   fd                    int
   //...
   writeBuffer           []byte
   //...
   DataHandler           func(c *Conn, data []byte)
}

Conn 結構代表網路連線,每個 Conn 只屬於一個輪詢器。當資料一次寫不完時,剩餘資料會先儲存在 writeBuffer 中,等待下一個可寫事件繼續寫入。

type poller struct {
   g             *Engine
   epfd          int
   evtfd         int
   index         int
   shutdown      bool
   listener      net.Listener
   isListener    bool
   unixSockAddr  string
   ReadBuffer    []byte
   pollType      string
}

至於 poller 結構,這是一個抽象概念,用於管理底層多路複用 I/O 操作(如 Linux 的 epoll、Darwin 的 kqueue 等)。

注意 pollType,nbio 預設使用電平觸發(LT)模式的 epoll,但使用者也可以將其設定為邊緣觸發(ET)模式。

介紹完基本結構後,我們來看看程式碼流程。

當啟動伺服器程式碼時,呼叫 Start

func (g *Engine) Start() error {
   //...
   switch g.network {
   // 第一部分: 初始化 listener
   case "unix", "tcp", "tcp4", "tcp6":
       for i := range g.addrs {
           ln, err := newPoller(g, true, i)
           if err != nil {
               for j := 0; j < i; j++ {
                   g.listeners[j].stop()
               }
               return err
           }
           g.addrs[i] = ln.listener.Addr().String()
           g.listeners = append(g.listeners, ln)
       }
   //...
   // 第二部分: 初始化一定數量的輪詢器
   for i := 0; i < g.pollerNum; i++ {
       p, err := newPoller(g, false, i)
       if err != nil {
           for j := 0; j < len(g.listeners); j++ {
               g.listeners[j].stop()
           }
           for j := 0; j < i; j++ {
               g.pollers[j].stop()
           }
           return err
       }
       g.pollers[i] = p
   }
   //...
   // 第三部分: 啟動所有工作輪詢器
   for i := 0; i < g.pollerNum; i++ {
       g.pollers[i].ReadBuffer = make([]byte, g.readBufferSize)
       g.Add(1)
       go g.pollers[i].start()
   }
   // 第四部分: 啟動所有監聽器
   for _, l := range g.listeners {
       g.Add(1)
       go l.start()
   }
   //... (忽略 UDP)
   //...
}

程式碼比較容易理解,分為四個部分:

第一部分:初始化監聽器

根據 g.network 值(如 "unix"、"tcp"、"tcp4"、"tcp6"),為每個要監聽的地址建立一個新的輪詢器。該輪詢器主要管理監聽套接字上的事件。如果在建立過程中發生錯誤,則停止所有先前建立的監聽器並返回錯誤資訊。

第二部分:初始化一定數量的輪詢器

建立指定數量(pollerNum)的輪詢器,用於處理已連線套接字上的讀/寫事件。如果在建立過程中發生錯誤,將停止所有監聽器和之前建立的工作輪詢器,然後返回錯誤資訊。

第三部分:啟動所有工作輪詢器投票站

為每個輪詢器分配讀緩衝區並啟動。

第四部分:啟動所有監聽器

啟動之前建立的所有監聽器,並開始監聽各自地址上的連線請求。

關於輪詢器的啟動:

func (p *poller) start() {
   defer p.g.Done()
   //...
   if p.isListener {
       p.acceptorLoop()
   } else {
       defer func() {
           syscall.Close(p.epfd)
           syscall.Close(p.evtfd)
       }()
       p.readWriteLoop()
   }
}

分為兩種情況。如果是監聽輪詢器:

func (p *poller) acceptorLoop() {
   // 如果不希望將當前 goroutine 排程到其他操作執行緒。
   if p.g.lockListener {
       runtime.LockOSThread()
       defer runtime.UnlockOSThread()
   }
   p.shutdown = false
   for !p.shutdown {
       conn, err := p.listener.Accept()
       if err == nil {
           var c *Conn
           c, err = NBConn(conn)
           if err != nil {
               conn.Close()
               continue
           }
           // p.g.pollers[c.Hash()%len(p.g.pollers)].addConn(c)
       } else {
           var ne net.Error
           if ok := errors.As(err, &ne); ok && ne.Timeout() {
               logging.Error("NBIO[%v][%v_%v] Accept failed: temporary error, retrying...", p.g.Name, p.pollType, p.index)
               time.Sleep(time.Second / 20)
           } else {
               if !p.shutdown {
                   logging.Error("NBIO[%v][%v_%v] Accept failed: %v, exit...", p.g.Name, p.pollType, p.index, err)
               }
               break
           }
       }
   }
}

監聽輪詢器等待新連線的到來,並在接受後將其封裝到 nbio.Conn 中,並將 Conn 新增到相應的工作輪詢器中。

func (p *poller) addConn(c *Conn) {
   c.p = p
   if c.typ != ConnTypeUDPServer {
       p.g.onOpen(c)
   }
   fd := c.fd
   p.g.connsUnix[fd] = c
   err := p.addRead(fd)
   if err != nil {
       p.g.connsUnix[fd] = nil
       c.closeWithError(err)
       logging.Error("[%v] add read event failed: %v", c.fd, err)
   }
}

這裡一個有趣的設計是對 conn 的管理。該結構是個切片,直接使用 connfd 作為索引。這樣做的好處是:

  • 在連線數較多的情況下,垃圾回收時的負擔要比使用 map 小。
  • 可以防止序列號問題。

最後,透過呼叫 addRead 將相應的 conn fd 新增到 epoll 中。

func (p *poller) addRead(fd int) error {
   switch p.g.epollMod {
   case EPOLLET:
       return syscall.EpollCtl(p.epfd, syscall.EPOLL_CTL_ADD, fd, &syscall.EpollEvent{Fd: int32(fd), Events: syscall.EPOLLERR | syscall.EPOLLHUP | syscall.EPOLLRDHUP | syscall.EPOLLPRI | syscall.EPOLLIN | syscall.EPOLLET})
   default:
       return syscall.EpollCtl(p.epfd, syscall.EPOLL_CTL_ADD, fd, &syscall.E

pollEvent{Fd: int32(fd), Events: syscall.EPOLLERR | syscall.EPOLLHUP | syscall.EPOLLRDHUP | syscall.EPOLLPRI | syscall.EPOLLIN})
   }
}

這裡不註冊寫事件是合理的,因為新連線上沒有資料要傳送。這種方法避免了一些不必要的系統呼叫,從而提高了程式效能。

如果啟動的是工作輪詢器,它的工作就是等待新增 conn 事件,並進行相應處理。

func (p *poller) readWriteLoop() {
   //...
   msec := -1
   events := make([]syscall.EpollEvent, 1024)
   //...
   for !p.shutdown {
       n, err := syscall.EpollWait(p.epfd, events, msec)
       if err != nil && !errors.Is(err, syscall.EINTR) {
           return
       }
       if n <= 0 {
           msec = -1
           continue
       }
       msec = 20
       // 遍歷事件
       for _, ev := range events[:n] {
           fd := int(ev.Fd)
           switch fd {
           case p.evtfd:
           default:
               c := p.getConn(fd)
               if c != nil {
                   if ev.Events&epollEventsError != 0 {
                       c.closeWithError(io.EOF)
                       continue
                   }
                   // 如果可寫,則重新整理資料
                   if ev.Events&epollEventsWrite != 0 {
                       c.flush()
                   }
                   // 讀取事件
                   if ev.Events&epollEventsRead != 0 {
                       if p.g.onRead == nil {
                           for i := 0; i < p.g.maxConnReadTimesPerEventLoop; i++ {
                               buffer := p.g.borrow(c)
                               rc, n, err := c.ReadAndGetConn(buffer)
                               if n > 0 {
                                   p.g.onData(rc, buffer[:n])
                               }
                               p.g.payback(c, buffer)
                               //...
                               if n < len(buffer) {
                                   break
                               }
                           }
                       } else {
                           p.g.onRead(c)
                       }
                   }
               } else {
                   syscall.Close(fd)
               }
           }
       }
   }
}

這段程式碼也很簡單,等待事件到來,遍歷事件列表,並相應處理每個事件。

func EpollWait(epfd int, events []EpollEvent, msec int) (n int, err error)

EpollWait 中,只有 msec 是使用者可修改的。通常,我們設定 msec = -1 使函式阻塞,直到至少有一個事件發生;否則,函式將無限期阻塞。當事件較少時,這種方法非常有用,能最大限度減少 CPU 佔用。

如果想盡快響應事件,可以設定 msec = 0,這樣 EpollWait 就能立即返回,無需等待任何事件。在這種情況下,程式可能會更頻繁呼叫 EpollWait,可以在事件發生後立即處理事件,從而提高 CPU 使用率。

如果程式可以容忍一定延遲,並且希望降低 CPU 佔用率,可以將 msec 設定為正數。這樣,EpollWait 就會在指定時間內等待事件發生。如果在這段時間內沒有事件發生,函式將返回,可以選擇稍後再次呼叫 EpollWait。這種方法可以降低 CPU 佔用率,但可能導致響應時間延長。

nbio 會根據事件計數調整 msec 值。如果計數大於 0,則 msec 設定為 20。

位元組跳動的 netpoll 程式碼與此類似;如果事件計數大於 0 ,則將 msec 設定為 0;如果事件計數小於或等於 0,則將 msec 設定為-1,然後呼叫 Gosched() 以主動退出當前 goroutine。

var msec = -1
for {
   n, err = syscall.EpollWait(epfd, events, msec)
   if n <= 0 {
       msec = -1
       runtime.Gosched()
       continue
   }
   msec = 0
   ...
}

不過,nbio 中的自願切換程式碼已被註釋掉。根據作者的解釋,最初他參考了位元組跳動的方法,並新增了自願切換功能。

不過,在對 nbio 進行效能測試時發現,新增或不新增自願切換功能對效能並無明顯影響,因此最終決定將其刪除。

事件處理部分

如果是可讀事件,則可以透過內建或自定義記憶體分配器獲取相應的緩衝區,然後呼叫 ReadAndGetConn 讀取資料,無需每次都分配緩衝區。

如果是可寫事件,則會呼叫 flush 傳送緩衝區中未傳送的資料。

func (c *Conn) flush() error {
   //.....
   old := c.writeBuffer
   n, err := c.doWrite(old)
   if err != nil && !errors.Is(err, syscall.EINTR) && !errors.Is(err, syscall.EAGAIN) {
     //.....
   }

   if n < 0 {
     n = 0
   }
   left := len(old) - n
   // 描述尚未完成,因此將其餘部分儲存在writeBuffer中以備下次寫入。
   if left > 0 {
     if n > 0 {
       c.writeBuffer = mempool.Malloc(left)
       copy(c.writeBuffer, old[n:])
       mempool.Free(old)
     }
     // c.modWrite()
   } else {
     mempool.Free(old)
     c.writeBuffer = nil
     if c.wTimer != nil {
       c.wTimer.Stop()
       c.wTimer = nil
     }
     // 解釋完成後,首先將conn重置為僅讀取事件。
     c.resetRead()
     //...
   }

   c.mux.Unlock()
   return nil
}

邏輯也很簡單,有多少就寫多少,如果寫不完,就把剩餘資料放回 writeBuffer,然後在 epollWait 觸發時再次寫入。

如果寫入完成,則不再有資料要寫入,將此連線的事件重置為讀取事件。

主邏輯基本上就是這樣。

等等,最初提到有新連線進入時,只註冊了連線的讀事件,並沒有註冊寫事件。寫事件是什麼時候註冊的?

當然是在呼叫 conn.Write 時註冊的。

g := nbio.NewGopher(nbio.Config{
   Network:            "tcp",
   Addrs:              []string{":8888"},
   MaxWriteBufferSize: 6 * 1024 * 1024,
 })

g.OnData(func(c *nbio.Conn, data []byte) {
   c.Write(append([]byte{}, data...))
})

當 Conn 資料到達時,底層會在讀取資料後回撥 OnData 函式,此時可以呼叫 Write 向另一端傳送資料。

g := nbio.NewGopher(nbio.Config{
     Network:            "tcp",
     Addrs:              []string{":8888"},
     MaxWriteBufferSize: 6 * 1024 * 1024,
   })

g.OnData(func(c *nbio.Conn, data []byte) {
   c.Write(append([]byte{}, data...))
})

// 當資料到達conn時,底層將讀取資料並回撥OnData函式。此時,您可以呼叫Write來向另一端傳送資料。
func (c *Conn) Write(b []byte) (int, error) {
   //....
   n, err := c.write(b)
   if err != nil && !errors.Is(err, syscall.EINTR) && !errors.Is(err, syscall.EAGAIN) {
     //.....
     return n, err
   }

   if len(c.writeBuffer) == 0 {
     if c.wTimer != nil {
       c.wTimer.Stop()
       c.wTimer = nil
     }
   } else {
     //仍然有資料未寫入,新增寫事件。
     c.modWrite()
   }
   //.....
   return n, err
}
 
func (c *Conn) write(b []byte) (int, error) {
   //...
   if len(c.writeBuffer) == 0 {
     n, err := c.doWrite(b)
     if err != nil && !errors.Is(err, syscall.EINTR) && !errors.Is(err, syscall.EAGAIN) {
       return n, err
     }
     //.....
     
     left := len(b) - n
     // 未完成,將剩餘資料寫入writeBuffer。
     if left > 0 && c.typ == ConnTypeTCP {
       c.writeBuffer = mempool.Malloc(left)
       copy(c.writeBuffer, b[n:])
       c.modWrite()
     }
     return len(b), nil
   }
   // 如果writeBuffer中仍有未寫入的資料,則還將追加新資料。
   c.writeBuffer = mempool.Append(c.writeBuffer, b...)

   return len(b), nil
}

當資料未完全寫入時,剩餘資料將被放入 writeBuffer,觸發執行 modWrite,並將 conn 的寫入事件註冊到 epoll。

總結

與 evio 相比,nbio 沒有蜂群效應。

Evio 透過不斷喚醒無效的 epoll 來實現邏輯正確性。Nbio 儘量減少系統呼叫,減少不必要的開銷。

在可用性方面,nbio 實現了標準庫 net.Conn,許多設定都是可配置的,允許使用者進行高度靈活的定製。

預分配緩衝區用於讀寫操作,以提高應用程式效能。

總之,nbio 是個不錯的高效能無阻塞網路框架。


你好,我是俞凡,在Motorola做過研發,現在在Mavenir做技術工作,對通訊、網路、後端架構、雲原生、DevOps、CICD、區塊鏈、AI等技術始終保持著濃厚的興趣,平時喜歡閱讀、思考,相信持續學習、終身成長,歡迎一起交流學習。為了方便大家以後能第一時間看到文章,請朋友們關注公眾號"DeepNoMind",並設個星標吧,如果能一鍵三連(轉發、點贊、在看),則能給我帶來更多的支援和動力,激勵我持續寫下去,和大家共同成長進步!

本文由mdnice多平臺釋出

相關文章