在 Go 的网络编程中,UDP 是一种无连接、轻量的传输协议,常被用于实时音视频、游戏同步和内部服务探测。Go 标准库通过 net 包提供了 ListenUDP 函数,用来在指定地址上监听 UDP 数据报。与 TCP 不同,UDP 的 socket 本身并不绑定单一对端,这意味着一个 UDP 连接对象可以同时与多个远端通信,也可以在不建立连接的情况下收发数据。

ListenUDP 的基本用法与返回对象
net.ListenUDP 的函数签名是 func ListenUDP(network string, laddr *UDPAddr) (*UDPConn, error)。其中 network 一般传 udp、udp4 或 udp6,laddr 是本地监听地址。调用成功后返回的是一个 *UDPConn,它内嵌了 *conn 并实现了 PacketConn 与 Conn 接口。也就是说,它既有 ReadFromUDP、WriteToUDP 这类面向数据报的方法,也有 Read、Write 这类面向已绑定对端连接的方法。
下面是一段最简单的监听代码,它在本地 9000 端口接收数据并报出来源:
package main
import (
"fmt"
"net"
)
func main() {
addr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:9000")
conn, err := net.ListenUDP("udp", addr)
if err != nil {
fmt.Println("监听失败:", err)
return
}
defer conn.Close()
buf := make([]byte, 1024)
for {
n, remoteAddr, err := conn.ReadFromUDP(buf)
if err != nil {
fmt.Println("读取错误:", err)
continue
}
fmt.Printf("收到来自 %v 的数据: %sn", remoteAddr, string(buf[:n]))
}
}
这段代码展示了 ListenUDP 最基础的接收能力。ReadFromUDP 每次返回数据长度、远端地址和错误。由于 UDP 无连接,同一个 conn 可以不断接收来自不同 remoteAddr 的包,这也是它和 TCP Accept 模型的最大区别。
双向通信的两种模式
使用 ListenUDP 实现双向通信,主要有两种写法。第一种是每次发送都显式指定对端地址,即调用 WriteToUDP(data, remoteAddr)。这种方式适合一对多的应答模型,比如服务端收到 A 的包就回给 A,收到 B 的包就回给 B,连接本身不绑定固定远端。
示例代码如下,在收到消息后原样回给发送方:
package main
import (
"fmt"
"net"
)
func main() {
laddr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:9000")
conn, err := net.ListenUDP("udp", laddr)
if err != nil {
panic(err)
}
defer conn.Close()
buf := make([]byte, 1024)
for {
n, raddr, err := conn.ReadFromUDP(buf)
if err != nil {
continue
}
// 双向:将数据回写到来源地址
_, err = conn.WriteToUDP(buf[:n], raddr)
if err != nil {
fmt.Println("回写失败:", err)
}
}
}
第二种模式是先通过 conn.ReadFromUDP 拿到对端地址,然后调用 conn.Write 或 conn.Read,但前提是先用 conn.WriteToUDP 或 Dial 绑定了远端。实际上,如果对端固定,可以在 ListenUDP 之后调用 conn.Dial(remoteAddr.String()),将 UDP socket 连接到特定对端,此后就能像 TCP 一样用 Read 和 Write 收发,但这会限制只能与该对端通信。
下面的代码演示了绑定远端后的双向写法:
package main
import (
"fmt"
"net"
"time"
)
func main() {
laddr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:9000")
conn, err := net.ListenUDP("udp", laddr)
if err != nil {
panic(err)
}
defer conn.Close()
// 绑定到固定对端
raddr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:9001")
if err := conn.Dial(raddr.String()); err != nil {
panic(err)
}
go func() {
for {
// 向已绑定对端发送
conn.Write([]byte("hello"))
time.Sleep(time.Second)
}
}()
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
fmt.Println("读错误:", err)
continue
}
fmt.Println("收到:", string(buf[:n]))
}
}
两种模式各有适用场景。若服务需要回应多个客户端,用 WriteToUDP 最灵活;若只是两个固定节点的 P2P 通信,Dial 后使用 Read/Write 代码更简洁。但注意 Dial 后,从其他地址来的包会被内核丢弃,这是常见踩坑点。
超时控制与资源释放
UDP 读取默认是阻塞的,如果远端静默,goroutine 会一直卡在 ReadFromUDP。生产环境必须设置超时,否则无法优雅退出或探测对端存活。可以使用 conn.SetReadDeadline 来控制。
以下示例加入了超时处理,避免永久阻塞:
package main
import (
"fmt"
"net"
"time"
)
func main() {
laddr, _ := net.ResolveUDPAddr("udp", "127.0.0.1:9000")
conn, err := net.ListenUDP("udp", laddr)
if err != nil {
panic(err)
}
defer conn.Close()
buf := make([]byte, 1024)
for {
// 设置 3 秒读超时
conn.SetReadDeadline(time.Now().Add(3 * time.Second))
n, raddr, err := conn.ReadFromUDP(buf)
if err != nil {
if e, ok := err.(net.Error); ok && e.Timeout() {
fmt.Println("读取超时,继续监听")
continue
}
fmt.Println("其他错误:", err)
return
}
conn.WriteToUDP(buf[:n], raddr)
}
}
除了读超时,WriteToUDP 在不可达网络下一般不会立即报错,因为 UDP 本身不保证送达。因此双向通信中,应用层通常需要自己设计确认机制,比如收到回包才认为对端在线。资源方面,defer conn.Close() 是必须的,否则端口会处于 TIME_WAIT 类似的占用状态,频繁重启可能导致地址占用错误。
常见误区与正确实践
一个典型误区是:开发者在 ListenUDP 之后,又新建了 net.DialUDP 去发消息,认为这样才是双向。实际上这创建了两个 socket,既浪费文件描述符,又可能让回包走到不同端口,客户端难以对应。正确做法就是复用同一个 *UDPConn,通过 WriteToUDP 回包。
另一个误区是混淆了 <code>Conn</code> 和 <code>PacketConn</code> 的语义。在 UDP 中,如果不 Dial,调用 Write 会返回错误;而 WriteToUDP 永远可用。代码里如果写了 conn.Write 却忘了 Dial,就会在运行时 panic 或返回写错误。
总结来说,ListenUDP 的正确用法核心在于理解 UDP 的无连接本质:它返回的 *UDPConn 是一个可复用的双向通道。用 ReadFromUDP 收、WriteToUDP 发,就能轻松实现一对多双向通信;用 Dial 绑定后,则退化为点对点模型。配合超时与关闭机制,即可写出健壮的 Go UDP 程序。