Go udp 的高性能優化

前段時間(已經是 2 年前了😛)優化了 golang udp client 和 server 的性能問題,我在這裏簡單描述下 udp 服務的優化過程。

當然,udp 性能本就很高,就算不優化,也輕易可以到幾十萬的 qps,但我們想更好的優化 go udp server 和 client。

UDP 存在粘包半包問題?

我們知道應用程序之間的網絡傳輸會存在粘包半包的問題。該問題的由來我這裏就不描述了,大家去搜吧。使用 tcp 會存在該問題,而 udp 是不存在該問題的。

爲啥? tcp 是無邊界的,tcp 是基於流傳輸的,tcp 報頭沒有長度這個變量,而 udp 是有邊界的,基於消息的,是可以解決粘包問題的。udp 協議裏有 16 位來描述包的大小,16 位決定他的數字最大數字是 65536,除去 udp 頭和 ip 頭的大小,最大的包差不多是 65507 byte。

但根據我們的測試,udp 並沒有完美的解決應用層粘包半包的問題。如果你的 go udp server 的讀緩衝是 1024,那麼 client 發送的數據不能超過 server read buf 定義的 1024 byte,不然還是要處理半包了。如果發送的數據小於 1024 byte,倒是不會出現粘包的問題。

// xiaorui.cc
buf := make([]byte, 1024)
for {
    n, _ := ServerConn.Read(buf[0:])
    if string(buf[0:n]) != s {
        panic(...)
...

在 Linux 下 藉助 strace 發現 syscall read fd 的時候,最大隻獲取 1024 個字節。這個 1024 就是上面配置的讀緩衝大小。

// xiaorui.cc

[pid 25939] futex(0x56db90, FUTEX_WAKE, 1) = 1
[pid 25939] read(3, "Introduction... 隱藏... overview of IPython'", 1024) = 1024
[pid 25939] epoll_ctl(4, EPOLL_CTL_DEL, 3, {0, {u32=0, u64=0}}) = 0
[pid 25939] close([pid 25940] <... restart_syscall resumed> ) = 0
[pid 25939] <... close resumed> )       = 0
[pid 25940] clock_gettime(CLOCK_MONOTONIC, {19280781, 509925143}) = 0
[pid 25939] pselect6(0, NULL, NULL, NULL, {0, 1000000}, 0 
[pid 25940] pselect6(0, NULL, NULL, NULL, {0, 20000}, 0) = 0 (Timeout)
[pid 25940] clock_gettime(CLOCK_MONOTONIC, {19280781, 510266460}) = 0
[pid 25940] futex(0x56db90, FUTEX_WAIT, 0, {60, 0}

下面是 golang 裏 socket fd read 的源碼,可以看到你傳入多大的 byte 數組,他就 syscall read 多大的數據。

// xiaorui.cc
func read(fd int, p []byte) (n int, err error) {
    var _p0 unsafe.Pointer
    if len(p) > 0 {
        _p0 = unsafe.Pointer(&p[0])
    } else {
        _p0 = unsafe.Pointer(&_zero)
    }
    r0, _, e1 := Syscall(SYS_READ, uintptr(fd), uintptr(_p0), uintptr(len(p)))
    n = int(r0)
    if e1 != 0 {
        err = errnoErr(e1)
    }
    return
}

http2 爲毛比 http1 的協議解析更快,是因爲 http2 實現了 header 的 hpack 編碼協議。thrift 爲啥比 grpc 快?單單對比協議結構體來說,thrift 和 protobuf 的性能半斤八兩,但對比網絡應用層協議來說,thrift 要更快。因爲 grpc 是在 http2 上跑的,grpc server  不僅要解析 http2 header,還要解析 http2 body,這個 body 就是 protobuf 數據。

所以說,高效的應用層協議也是高性能服務的重要的一個標準。我們先前使用的是自定義的 TLV 編碼,t 是類型,l 是 length,v 是數據。一般解決網絡協議上的數據完整性差不多是這個思路。當然,我也是這麼搞得。

如何優化 udp 應用協議上的開銷?

上面已經說了,udp 在合理的 size 情況下是不需要依賴應用層協議解析包問題。那麼我們只需要在 client 端控制 send 包的大小,server 端控制接收大小,就可以節省應用層協議帶來的性能高效。😁別小看應用層協議的 cpu 消耗!

解決 golang udp 的鎖競爭問題

在 udp 壓力測試的時候,發現 client 和 server 都跑不滿 cpu 的情況。開始以爲是 golang udp server 的問題,去掉所有相關的業務邏輯,只是單純的做 atomic 計數,還是跑不滿 cpu。通過 go tool pprof 的函數調用圖以及火焰圖,看不出問題所在。嘗試使用 iperf 進行 udp 壓測,golang udp server 的壓力直接幹到了滿負載。可以說是壓力源不足。

那麼 udp 性能上不去的問題看似明顯了,應該是 golang udp client 的問題了。我嘗試在 go udp client 裏增加了多協程寫入,10 個 goroutine,100 個 goroutine,500 個 goroutine,都沒有好的明顯的提升效果,而且性能抖動很明顯。😅

進一步排查問題,通過 lsof 分析 client 進程的描述符列表,client 連接 udp server 只有一個連接。也就是說,500 個協程共用一個連接。接着使用 strace 做 syscall 系統調用統計,發現 futex 和 pselect6 系統調用特別多,這一看就是存在過大的鎖競爭。翻看 golang net 源代碼,果然發現 golang 在往 socket fd 寫入時,存在寫鎖競爭。

TODO 圖片

// xiaorui.cc

// Write implements io.Writer.
func (fd *FD) Write([]byte) (int, error) {
    if err := fd.writeLock(); err != nil {
        return 0, err
    }
    defer fd.writeUnlock()
    if err := fd.pd.prepareWrite(fd.isFile); err != nil {
        return 0, err
    }
}

怎麼優化鎖競爭?

實例化多個 udp 連接到一個數組池子裏,在客戶端代碼裏隨機使用 udp 連接。這樣就能減少鎖的競爭了。

總結

udp 性能調優的過程就是這樣子了。簡單說就兩個點:一個是消除應用層協議帶來的性能消耗,再一個是 golang socket 寫鎖帶來的競爭。

當我們一些性能問題時,多使用 perf、strace 功能,再配合 golang pprof 分析火焰圖來分析問題。實在不行,直接幹 golang 源碼。👌

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