go: 如何編寫一個正確的udp服務端

udp的服務端有一個大坑,即如果收包不及時,在系統緩衝寫滿後,將大量丟包。

在網上通常的示例中,一般在for循環中執行操作邏輯。這在生產環境將是一個隱患。是的,俺就翻車了。

go強大簡易的並發能力可以用在處理udp數據上。

	PoolSizeUDP := 1472
	listener, err := net.ListenUDP("udp", &net.UDPAddr{
		IP:   net.ParseIP(listenIP),
		Port: port,
	})
	if err != nil {
		logrus.Fatalf("RunUdpServer failed to listen: %v", err)
		return nil
	}
	// 如果還不行,考慮把系統的buffer設大一點
	// listener.SetReadBuffer(1024 * 1024 * 8)
	// listener.SetWriteBuffer(1024 * 1024 * 8)
	var data = make([]byte, PoolSizeUDP)
	chLimit := make(chan int, 64) // 最多創建64個協程,避免記憶體爆炸
	for {
		select {
		case <-ctx.Done():
			return nil
		default:
		}
		n, addr, err := listener.ReadFromUDP(data)
		if err != nil {
			logrus.Errorf("RunUdpServer ReadFromUDP err: %v", err)
			continue
		}
		raw := make([]byte, n) // 重點注意,每次循環都必須創建新的raw變數,否則踩記憶體
		copy(raw, data[:n])
		chLimit <- 1
		go func(udpMsg []byte) {
			// 拿 udpMsg 做點什麼
			defer func() {
				<-chLimit
			}()
			DoSth(udpMsg)
		}(raw)
	}

注意點:

  1. data可以在循環外創建,復用即可。每次ReadFromUDP並不會受到上次數據殘留的影響。
  2. 不要在for中執行重邏輯,避免等待太久時間udp大量丟包。所以每次收到udpMsg,都交給go協程來處理。
  3. raw必須每次在循環內創建,否則在後面的go並發會踩記憶體。
  4. SetReadBuffer這個配置很有用

更新:上面的示例為了避免在後續的go中,有不可控的非同步操作引用了數據導致踩記憶體,每次收消息都分配了新的[]byte。

raw := make([]byte, n) // 重點注意,每次循環都必須創建新的raw變數,否則踩記憶體
copy(raw, data[:n])

經大佬提醒,這其實是一個不小的開銷。如果go中執行的行為可控,引入sync.Pool可以很方便的做記憶體復用。

var udpBytesPool = sync.Pool{
	New: func() any {
		return make([]byte, PoolSizeUDP)
	},
}
......
	for {
		data := udpBytesPool.Get().([]byte)
		n, addr, err := listener.ReadFromUDP(data)
		if err != nil {
			logrus.Errorf("RunUdpServer ReadFromUDP err: %v", err)
			continue
		}
		go func(){
			defer udpBytesPool.Put(data) // 注意,在協程退出執行這個操作時,一定確認 data不會再被引用了
		// do sth
		}()
	}