批量讀寫網絡包- 只爲更高性能

雖然網絡協議棧提供了豐富的功能,讓我們可以方便的實現網絡的數據交流,但是有時候我們對協議棧的性能還是不是那麼滿意的,前幾篇文章我也介紹了通過 XDP 等技術高效處理網絡數據的方式,但是畢竟 XDP 還沒有那麼廣泛的應用,使用起來也還不是那麼簡單,如果我們通過編程語言提供的標準庫來實現數據的讀寫,還怎麼能提高性能呢?今天就介紹一種方式,批量的讀寫數據包。

直觀上看,我們也能理解批收發網絡包比單個的收發網絡包更有效,因爲在普通的單個收發包的邏輯中,每次收發一個包都至少需要一次系統調用 (Send/SendTo, Read/RecvFrom),而批處理方式下,一次系統調用就可以處理多個網絡包,所以從理論分析來看,批處理方式更有效。不僅僅網絡處理,很多消息隊列、數據存儲,也都會通過批處理方式獲得更好的性能。

在這篇文章中,我並沒有對批量處理網絡包和普通處理單個網絡包的性能做一個測試 (或許 5.1 假期的時候我能補上),有人做了簡單的測試 [1], 並沒有發現批處理帶來的好處,當然我猜測他的測試可能過於單一或者簡單,cloudflare 也做過百萬 pps 的測試 [2],批處理方式性能還是很好的。我想你在評估這項技術的時候也根據你的場景做一下性能測試,這樣才能確保這項技術是否適合採用。

我所說的批量處理收發包的技術是通過系統調用sendmmsgrecvmmsg來實現的,目前只支持 Linux 系統。正如 man 手冊上介紹的,它們是在 socket 上收發多個數據包的系統調用 [3]:

這兩個系統調用開始加入到 Linux 的版本是 3.0, glibc 自 2.14 版本開始加入。OpenBSD 7.2 也加入了這個系統調用。

注意recvmmsg是一個blocking系統調用,直到收到 vlen 個消息或者超時纔會返回。這兩個系統調用是對sendmsgrecvmsg的擴展。如果你以前研究過,你可能直到對於網絡數據的讀寫有多個系統調用sendsendtosendmsgsendmmsgreadrecv、``、

man send[4] 和 man recv[5] 分別對它們做了詳細的介紹。

同樣的,接收用的系統調用主要有下面的幾個:

對應到標準庫的 conn 的方法上,以 UDPConn 爲例:

遺憾的是, Go 標準庫中並沒有提供對系統調用sendmmsgrecvmmsg的包裝,這個系統調用在syscall甚至沒有定義,所以像net.UDPConn還沒有對應的批處理方法。2021 年,在 go issue#45886[6] 中 bradfitz 提出爲*UDPConn增加批讀寫消息的提案,這個提案在 2022 年 11 月 10 日被 Russ Cox 批准爲接受狀態,但是目前這個提案處理依然還沒有人領取去實現。

但是,go 的擴展庫 golang.org/x/net[7] 提供了 ReadBatchWriteBatch的方法,可以提供批量讀寫消息的方法,它實際是對系統調用readmmsgsendmmsg的封裝。

當然你也可以類似的實現對系統調用的封裝:

func recvmmsg(s uintptr, hs []mmsghdr, flags int) (int, error) {
 n, _, errno := syscall.Syscall6(sysRECVMMSG, s, uintptr(unsafe.Pointer(&hs[0])), uintptr(len(hs)), uintptr(flags), 0, 0)
 return int(n), errnoErr(errno)
}

func sendmmsg(s uintptr, hs []mmsghdr, flags int) (int, error) {
 n, _, errno := syscall.Syscall6(sysSENDMMSG, s, uintptr(unsafe.Pointer(&hs[0])), uintptr(len(hs)), uintptr(flags), 0, 0)
 return int(n), errnoErr(errno)
}

不過目前它們都有一個問題,類似ReadBatch是 blocking 的,如果沒有足夠的消息被接收到,當前線程會被阻塞住。線程數很少的情況下沒有問題,但是如果線程數很多,就會有資源不足和性能的問題。bradfitz 在提案中期望能和標準庫的 net poller 集成,這樣就避免線程被阻塞中,所以你可以期待這個功能早日能實現。

那麼,我們就看看目前最好的實現批量讀寫 UDP 消息的方式吧,也就是利用 ipv4 包的方式。

使用 ipv4.PacketConn

我們可以利用ipv4.PacketConn的方式,它提供了ReadBatchWriteBatch方法:

接下來,我們以一個 UDP 客戶端和服務端的例子,演示批量讀寫的能力。

下面是客戶端的代碼。它首先使用標準庫創建的了一個*net.UDPConn的實例,並使用ipv4.NewPacketConn轉換成*ipv4.PacketConn。接下來準備了 10 條消息,需要準備成ipv4.Message類型。如果你在產品中使用,最好使用 Pool 池化這些對象,這個例子比較簡單,就沒有考慮性能的問題,值演示批量讀寫的功能。準備好數據後就調用WriteBatch批量寫入,這裏也每考慮如果有的每寫成功怎麼處理,假定都寫入成功了。

接下來讀取回包,假定服務器都要把每一條消息返回,所以這裏如果批量讀沒有讀取完,還會接着讀,直到把所有的消息都讀取回來。

package main

import (
 "fmt"
 "net"

 "golang.org/x/net/ipv4"
)
func main() {
 remote, err := net.ResolveUDPAddr("udp""localhost:9999")
 if err != nil {
  panic(err)
 }

 conn, err := net.Dial("udp""localhost:9999")
 if err != nil {
  panic(err)
 }
 defer conn.Close()

 pconn := ipv4.NewPacketConn(conn.(*net.UDPConn))

 // write with a batch of 10 messages
 batch := 10
 msgs := make([]ipv4.Message, batch)
 for i := 0; i < batch; i++ {
  msgs[i] = ipv4.Message{
   Buffers: [][]byte{[]byte(fmt.Sprintf("hello batch %d", i))},
   Addr:    remote,
  }
 }
 n, err := pconn.WriteBatch(msgs, 0)
 if err != nil {
  panic(err)
 }
 fmt.Printf("sent %d messages\n", n)

 // read 10 messages with batch
 count := 0
 for count < batch {
  n, err := pconn.ReadBatch(msgs, 0)
  if err != nil {
   panic(err)
  }
  count += n

  for i := 0; i < n; i++ {
   fmt.Println(string(msgs[i].Buffers[0]))
  }
 }
}

服務端的代碼類似,先批量接收消息,再把消息原樣批量寫回:

package main

import (
 "fmt"
 "net"

 "golang.org/x/net/ipv4"
)

func main() {
 addr, err := net.ResolveUDPAddr("udp"":9999")
 if err != nil {
  panic(err)
 }
 conn, err := net.ListenUDP("udp", addr)
 if err != nil {
  panic(err)
 }
 defer conn.Close()

 pconn := ipv4.NewPacketConn(conn)
 fmt.Println("server listening on", addr)

 batch := 10
 msgs := make([]ipv4.Message, batch)
 for i := 0; i < batch; i++ {
  msgs[i] = ipv4.Message{
   Buffers: [][]byte{make([]byte, 1024)},
  }
 }

 for {
  n, err := pconn.ReadBatch(msgs, 0)
  if err != nil {
   panic(err)
  }

  tn := 0
  for tn < n {
   nn, err := pconn.WriteBatch(msgs[tn:n], 0)
   if err != nil {
    panic(err)
   }
   tn += nn
  }
 }
}

運行 server 和 client, 可以在 client 看到 10 條消息都收到了:

ubuntu@lab:~/network-programming/ch04/mmsg/sendmmsg/client$ ./client
sent 10 messages
hello batch 0
hello batch 1
hello batch 2
hello batch 3
hello batch 4
hello batch 5
hello batch 6
hello batch 7
hello batch 8
hello batch 9

使用 ipv4.Conn

其實使用 ipv4.Conn 也能實現批量讀寫,底層是一樣的。這個類型底層是golang.org/x/net/internal/socket.Conn, 它包含下面的批量讀寫消息的方法:

使用方式幾乎和上面的一樣,只不過方法名不同。下面是客戶端的代碼:

package main

import (
 "fmt"
 "net"

 "golang.org/x/net/ipv4"
)

func main() {
 remote, err := net.ResolveUDPAddr("udp""localhost:9999")
 if err != nil {
  panic(err)
 }

 conn, err := net.Dial("udp""localhost:9999")
 if err != nil {
  panic(err)
 }
 defer conn.Close()

 pconn := ipv4.NewConn(conn)

 // write with a batch of 10 messages
 batch := 10
 msgs := make([]ipv4.Message, batch)
 for i := 0; i < batch; i++ {
  msgs[i] = ipv4.Message{
   Buffers: [][]byte{[]byte(fmt.Sprintf("hello batch %d", i))},
   Addr:    remote,
  }
 }
 n, err := pconn.SendMsgs(msgs, 0)
 if err != nil {
  panic(err)
 }
 fmt.Printf("sent %d messages\n", n)

 // read 10 messages with batch
 count := 0
 for count < batch {
  n, err := pconn.RecvMsgs(msgs, 0)
  if err != nil {
   panic(err)
  }
  count += n

  for i := 0; i < n; i++ {
   fmt.Println(string(msgs[i].Buffers[0]))
  }
 }
}

下面是服務端的代碼:

package main

import (
 "fmt"
 "net"

 "golang.org/x/net/ipv4"
)

func main() {
 addr, err := net.ResolveUDPAddr("udp"":9999")
 if err != nil {
  panic(err)
 }
 conn, err := net.ListenUDP("udp", addr)
 if err != nil {
  panic(err)
 }
 defer conn.Close()

 pconn := ipv4.NewConn(conn)
 fmt.Println("server listening on", addr)

 batch := 10
 msgs := make([]ipv4.Message, batch)
 for i := 0; i < batch; i++ {
  msgs[i] = ipv4.Message{
   Buffers: [][]byte{make([]byte, 1024)},
  }
 }

 for {
  n, err := pconn.RecvMsgs(msgs, 0)
  if err != nil {
   panic(err)
  }

  tn := 0
  for tn < n {
   nn, err := pconn.SendMsgs(msgs[tn:n], 0)
   if err != nil {
    panic(err)
   }
   tn += nn
  }
 }
}

參考資料

[1]

測試: https://hpnpl.net/posts/batchconn-mmsg-go/

[2]

測試: https://blog.cloudflare.com/how-to-receive-a-million-packets/

[3]

系統調用: https://man7.org/linux/man-pages/man2/sendmmsg.2.html

[4]

man send: https://manpages.ubuntu.com/manpages/bionic/man2/send.2.html

[5]

man recv: https://manpages.ubuntu.com/manpages/bionic/man2/recv.2.html

[6]

go issue#45886: https://github.com/golang/go/issues/45886

[7]

golang.org/x/net: https://pkg.go.dev/golang.org/x/net/ipv4#PacketConn.ReadBatch

[8]

使用 AF_XDP Socket 更高效的網絡傳輸: https://colobu.com/2023/04/17/use-af-xdp-socket/

[9]

使用底層的 syscall.Socket 實現網絡編程: https://colobu.com/2023/04/09/use-syscall-Socket-in-go/

[10]

每秒 1 百萬的包傳輸,幾乎不耗 CPU 的那種: https://colobu.com/2023/04/02/support-1m-pps-with-zero-cpu-usage/

[11]

像閃電一樣快速掃描全網的 IP 地址: https://colobu.com/2023/03/19/scan-all-IP-addresses-of-mainland-fastly-like-lightning/

[12]

零拷貝技術第二篇:Go 語言中的應用: https://colobu.com/2022/11/21/zero-copy-and-how-to-use-it-in-go-2/

[13]

零拷貝技術第一篇:綜述: https://colobu.com/2022/11/19/zero-copy-and-how-to-use-it-in-go/

[14]

使用 BPF, 將 Go 網絡程序的吞吐提升 8 倍: https://colobu.com/2022/06/05/use-bpf-to-make-the-go-network-program-8x-faster/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/8h2QSHznd3K1MMclhdJv3w