UDP是一种无连接的轻量级传输层协议,UDP不需要建立连接、无需确认消息送达,也没有重传机制,这使得UDP非常适合低延迟、高吞吐量的应用场景,如实时音视频传输、在线游戏等。这篇笔记我们简单介绍如何在Go语言中实现UDP服务端和客户端。
最简单的单线程UDP服务端实现非常简单,UDP是无连接的,它以UDP数据包为传输单位,我们只需要写一个循环监听收到的UDP数据包并逐个处理。下面是一个简单的单线程UDP服务端实现。
package main
import (
"log/slog"
"net"
)
func main() {
// 启动UDP服务端监听UDP端口
addr, err := net.ResolveUDPAddr("udp", "192.168.1.100:8000")
if err != nil {
slog.Error(err.Error())
return
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
slog.Error(err.Error())
return
}
defer func(conn *net.UDPConn) {
err := conn.Close()
if err != nil {
slog.Error(err.Error())
return
}
}(conn)
slog.Info("UDP server listening on 192.168.1.100:8000...")
for {
// 从UDP套接字等待并读取数据
buffer := make([]byte, 512)
n, addr, err := conn.ReadFromUDP(buffer)
if err != nil {
slog.Error(err.Error())
continue
}
data := string(buffer[:n])
slog.Info("[" + addr.String() + "] " + data)
// 向套接字写入数据
if _, err := conn.WriteToUDP([]byte("Echo: "+data), addr); err != nil {
slog.Error(err.Error())
continue
}
}
}
代码中,我们在192.168.1.100:8000
上启动UDP服务端,随后编写了一个无限循环,循环中从套接字等待数据包到达,并附加一些字符后原样返回给客户端。此外,代码中我们固定读取512字节,因为这里我们约定发送的UDP数据包最大不超过512字节,如果超过则丢弃。
为什么是512字节?我们知道UDP协议数据包的Length字段为16位,因此UDP数据包最大长度可为65535字节,但实际上以太网规定了数据链路层单个帧可以传输的最大字节数(MTU),如果UDP数据包大小超过这个MTU值将被分片,接收端必须将分片重组才能交给上层,这样UDP传输的效率不高,此外UDP是不可靠传输协议,被分片后重组失败将丢失整个数据包,因此UDP数据包太长可能出现可靠性迅速下降的状况,因此我们应该尽量保持让UDP数据包不分片直接发送。MTU最大值为1500字节,不过很多网络设备使用默认值576字节,这个值再减去IP头部和UDP头部,512字节就是一个比较保守的UDP数据包大小的估计值。
单线程的UDP服务端用于一些简单的通信场景,比如单对单的通信等,这里我们并没有启动新的协程处理数据,因此如果每个数据包的处理速度较慢或处于高并发的状态下,服务端的网络协议栈缓冲区中将可能产生积压,当缓冲区写满时将发生丢包。如果对并发性能要求较高,我们也可以在循环中每次收到数据包时,启动新协程处理。
下面代码我们仍单线程循环获取UDP数据包,但收到数据包后我们启动新协程处理数据,这样实现的UDP服务端相较于单线程服务端具有更好的性能。
package main
import (
"log/slog"
"net"
)
func handleConn(conn *net.UDPConn, addr *net.UDPAddr, bytes []byte) {
data := string(bytes)
slog.Info("[" + addr.String() + "] " + data)
if _, err := conn.WriteToUDP([]byte("Echo: "+data), addr); err != nil {
slog.Error(err.Error())
return
}
}
func main() {
// 启动UDP服务端监听UDP端口
addr, err := net.ResolveUDPAddr("udp", "192.168.1.100:8000")
if err != nil {
slog.Error(err.Error())
return
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
slog.Error(err.Error())
return
}
defer func(conn *net.UDPConn) {
err := conn.Close()
if err != nil {
slog.Error(err.Error())
return
}
}(conn)
slog.Info("UDP server listening on 192.168.1.100:8000...")
for {
// 从UDP套接字等待并读取数据
buffer := make([]byte, 512)
n, addr, err := conn.ReadFromUDP(buffer)
if err != nil {
slog.Error(err.Error())
continue
}
go handleConn(conn, addr, buffer[:n])
}
}
实际开发中,我们还可以使用更多的手段来进一步提高服务端的并发性能,例如服务端动态分配UDP端口等。
运行服务端后,我们可以使用NetCat对其进行测试。
nc -u 192.168.1.100 8000
下面代码我们实现了UDP客户端。
package main
import (
"fmt"
"log/slog"
"net"
)
func main() {
// 准备UDP套接字
addr, err := net.ResolveUDPAddr("udp", "192.168.1.100:8000")
if err != nil {
slog.Error(err.Error())
return
}
conn, err := net.DialUDP("udp", nil, addr)
if err != nil {
slog.Error(err.Error())
return
}
defer func(conn *net.UDPConn) {
err := conn.Close()
if err != nil {
slog.Error(err.Error())
return
}
}(conn)
for {
// 循环从标准输入读取内容
var input string
_, err := fmt.Scanln(&input)
if err != nil {
slog.Error(err.Error())
return
}
bytes := []byte(input)
if len(bytes) > 512 {
slog.Error("Input size too big!")
return
}
// 通过套接字发送数据给服务端
_, err = conn.Write(bytes)
if err != nil {
slog.Error(err.Error())
return
}
// 读取服务端响应
buffer := make([]byte, 512)
n, _, err := conn.ReadFromUDP(buffer)
slog.Info(string(buffer[:n]))
}
}
代码中,我们编写了一个循环并读取控制台输入,每次读取一行输入后,我们将其作为数据发送给服务端然后等待服务端响应,最后将服务端响应输出到控制台。我们可以用刚才编写的UDP服务端来测试UDP客户端。
UDP并不能保证数据包一定到达,也不能保证数据包按顺序到达,因此,在使用UDP时,我们可能需要额外对数据包的丢失、乱序等问题进行处理,这本质上是基于UDP设计一种可靠的应用层通信协议,但实现这些逻辑就相对复杂了,我们也很难保证自己写的协议实现足够健壮且没有Bug。实际开发中,我们可以选择使用KCP、QUIC等基于UDP的成熟协议来实现功能,Go语言标准库没有内置这些实现,有关这些内容将在其它章节进行介绍。