Golang 网络编程:UDP 的若干细节

基于 Golang Udp 的高性能编程总结

Posted by pandaychen on March 3, 2022

0x00 前言

内网 UDP 的好处:

  • 内网的 UDP 丢包率极小(低于万分之三)
  • 发送端和接收端约定好通信协议,为了避免分片,每个 UDP 包的最大字节数应该是 1500-20-8=1472
  • 通常使用 UDP 服务来做日志接收服务

0x01 golang-UDP 的连接性?

当然,这里的连接性指的是 Lib 层面,golang 中 UDP 分为已连接(connected UDP)和未连接(unconnected UDP)两种方式,二者在发送、接收消息行为模式上有重大区别

  • 已连接状态:通过 DialUDP 创建的 UDP 为已连接状态,其会记录远端 remote 的 ip/port 信息,相当于在两者之间建立了持续通路,发送、接收函数为 Write、Read,不需要填 remote 信息
  • 未连接状态:通过 ListenUDP 建立的 UDP 为未连接形式,发送、接收函数为 WriteToReadFrom,需要填写 remote 信息 在实际应用中,如果需要在不同的连接上完成 UDP 收发,那么使用 DialUDP 就会有问题,建议使用 ListenUDP

connected UDP

原理如下:

connected-udp 当一端的 UDP endpoint 调用 DialUDP 之后,OS 会在内部把 UDP socket 和另一个 UDP endpoint 的地址关联起来(OS 仅仅记录 UDP socket 的 peer udp endpoint 地址后就立即返回),在发起 connect 的 UDP endpoint 端建立起一个单向的连接四元组,发出的 datagram packet 只能发往此 endpoint(不管 sendto 的时候是否指定了地址)且只能接收这个 endpoint 发来的 datagram packet(其他 endpoint 发来的包会被 OS 丢弃);至于对端 UDP endpoint 是否为 connected udp 则无关紧要,所以称 udp connection 是单向的连接。若 DialUDP 的对端不存在或对端口无进程监听,则发包后对端会返回 ICMP port unreachable 错误;此外,如果一个 POSIX 系统进程发起 UDP write 时没有指定对端 UDP address,则会收到 ENOTCONN 错误而非 EDESTADDRREQ

如果一个 connected UDP 需要更换对端 endpoint address,只需要重新 DialUDP 即可

connected UDP 的性能

相较于 unconnected UDP,connected UDP 的性能更优,二者的数据流程如下:

1、connected UDP:内核只在第一次设置虚拟连接的 peer address,后面进行连续发送即可

  • Connect the socket
  • Send first datagram
  • Output second datagram

2、unconnected UDP:每发送一个包都需要进行 connect

  • Connect the socket
  • Send the first datagram
  • Unconnect the socket
  • Connect the socket
  • Send the second datagram
  • Unconnect the socket

golang 相关 API

golang API 对 connected UDP 和 unconnected UDP 读写方法进行了明确区分,参考 此文 汇总的要点:读统一使用 ReadFromUDP,写则统一使用 WriteMsgUDP

  • connected UDP:读写方法是 ReadWrite
  • unconnected UDP:读写方法是 ReadFromUDPWriteToUDP(以及 ReadFrom 和 WriteTo)
  • unconnected UDP:可以调用 Read,只是无法获取 peer addr
  • connected UDP:可以调用 ReadFromUDP(填写的地址会被忽略)
  • connected UDP:不能调用 WriteToUDP(即使是相同的目标地址也不可以),否则会得到错误 use of WriteTo with pre-connected connection
  • unconnected UDP:不能调用 Write, 会报错 error: write: destination address xxxxxx
  • connected UDP:可以调用 WriteMsgUDP,但是地址必须为 nil
  • unconnected UDP:可以调用 WriteMsgUDP,但是必须填写 peer endpoint address

0x02 Golang 中的实现细节

DialUDP 的实现

func DialUDP(network string, laddr, raddr *UDPAddr) (*UDPConn, error) {
	switch network {
	case "udp", "udp4", "udp6":
	default:
		return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: raddr.opAddr(), Err: UnknownNetworkError(network)}
	}
	if raddr == nil {
		return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: nil, Err: errMissingAddress}
	}
	sd := &sysDialer{network: network, address: raddr.String()}
	c, err := sd.dialUDP(context.Background(), laddr, raddr)
	if err != nil {
		return nil, &OpError{Op: "dial", Net: network, Source: laddr.opAddr(), Addr: raddr.opAddr(), Err: err}
	}
	return c, nil
}

//sd.dialUDP
func (sd *sysDialer) dialUDP(ctx context.Context, laddr, raddr *UDPAddr) (*UDPConn, error) {
	fd, err := internetSocket(ctx, sd.network, laddr, raddr, syscall.SOCK_DGRAM, 0, "dial", sd.Dialer.Control)
	if err != nil {
		return nil, err
	}
	return newUDPConn(fd), nil
}

ListenUDP 的实现

func ListenUDP(network string, laddr *UDPAddr) (*UDPConn, error) {
	switch network {
	case "udp", "udp4", "udp6":
	default:
		return nil, &OpError{Op: "listen", Net: network, Source: nil, Addr: laddr.opAddr(), Err: UnknownNetworkError(network)}
	}
	if laddr == nil {
		laddr = &UDPAddr{}
	}
	sl := &sysListener{network: network, address: laddr.String()}
	c, err := sl.listenUDP(context.Background(), laddr)
	if err != nil {
		return nil, &OpError{Op: "listen", Net: network, Source: nil, Addr: laddr.opAddr(), Err: err}
	}
	return c, nil
}

func (sl *sysListener) listenUDP(ctx context.Context, laddr *UDPAddr) (*UDPConn, error) {
	fd, err := internetSocket(ctx, sl.network, laddr, nil, syscall.SOCK_DGRAM, 0, "listen", sl.ListenConfig.Control)
	if err != nil {
		return nil, err
	}
	return newUDPConn(fd), nil
}

底层调用

sl.listenUDPsd.dialUDP 都会调用底层的 internetSocket,只是调用参数有去呗,listenUDP 调用时 raddrnil,而 dialUDP 会传入此值;internetSocket 内部会调用 socket 函数,如下:

func socket(ctx context.Context, net string, family, sotype, proto int, ipv6only bool, laddr, raddr sockaddr, ctrlFn func(string, string, syscall.RawConn) error) (fd *netFD, err error) {
	s, err := sysSocket(family, sotype, proto)
	if err != nil {
		return nil, err
	}
	if err = setDefaultSockopts(s, family, sotype, ipv6only); err != nil {
		poll.CloseFunc(s)
		return nil, err
	}
	if fd, err = newFD(s, family, sotype, net); err != nil {
		poll.CloseFunc(s)
		return nil, err
	}

	// This function makes a network file descriptor for the
	// following applications:
	//
	// - An endpoint holder that opens a passive stream
	//   connection, known as a stream listener
	//
	// - An endpoint holder that opens a destination-unspecific
	//   datagram connection, known as a datagram listener
	//
	// - An endpoint holder that opens an active stream or a
	//   destination-specific datagram connection, known as a
	//   dialer
	//
	// - An endpoint holder that opens the other connection, such
	//   as talking to the protocol stack inside the kernel
	//
	// For stream and datagram listeners, they will only require
	// named sockets, so we can assume that it's just a request
	// from stream or datagram listeners when laddr is not nil but
	// raddr is nil. Otherwise we assume it's just for dialers or
	// the other connection holders.

  //laddr 不为 nil,而 raddr 为 nil,说明是监听 socket(listenUDP)
	if laddr != nil && raddr == nil {
		switch sotype {
		case syscall.SOCK_STREAM, syscall.SOCK_SEQPACKET:
			if err := fd.listenStream(laddr, listenerBacklog(), ctrlFn); err != nil {
				fd.Close()
				return nil, err
			}
			return fd, nil
		case syscall.SOCK_DGRAM:
			if err := fd.listenDatagram(laddr, ctrlFn); err != nil {
				fd.Close()
				return nil, err
			}
			return fd, nil
		}
	}

  //DialUDP
	if err := fd.dial(ctx, laddr, raddr, ctrlFn); err != nil {
		fd.Close()
		return nil, err
	}
	return fd, nil
}

底层调用:dial

dial 方法解答了 ListenUDPDialUDP 的行为差异的原因:

  • laddr!=nil 时,调用 bind 方法,绑定本地 ip 及 port
  • raddr!=nil 时,调用 fd.connect 与 remote 建立连接(raddr 的区别)

根据上小节的调用参数看,ListenUDP 方法构造的 UDPConn 为未连接状态,而 DialUDP 方法构造的 UDPConn 为已连接状态,因而 DialUDP 方法构建的 UDPConn 只能从指定 remote 接收数据,而 ListenUDP 方法构建的 UDPConn 则可以从任何远端接收数据

func (fd *netFD) dial(ctx context.Context, laddr, raddr sockaddr, ctrlFn func(string, string, syscall.RawConn) error) error {
	if ctrlFn != nil {
		c, err := newRawConn(fd)
		if err != nil {
			return err
		}
		var ctrlAddr string
		if raddr != nil {
			ctrlAddr = raddr.String()
		} else if laddr != nil {
			ctrlAddr = laddr.String()
		}
		if err := ctrlFn(fd.ctrlNetwork(), ctrlAddr, c); err != nil {
			return err
		}
	}
	var err error
	var lsa syscall.Sockaddr
	if laddr != nil {
    //laddr not nil
		if lsa, err = laddr.sockaddr(fd.family); err != nil {
			return err
		} else if lsa != nil {
			if err = syscall.Bind(fd.pfd.Sysfd, lsa); err != nil {
				return os.NewSyscallError("bind", err)
			}
		}
	}
	var rsa syscall.Sockaddr  // remote address from the user
	var crsa syscall.Sockaddr // remote address we actually connected to
	if raddr != nil {
    //raddr not nil
		if rsa, err = raddr.sockaddr(fd.family); err != nil {
			return err
		}
		if crsa, err = fd.connect(ctx, lsa, rsa); err != nil {
			return err
		}
		fd.isConnected = true
	} else {
		if err := fd.init(); err != nil {
			return err
		}
	}
	// Record the local and remote addresses from the actual socket.
	// Get the local address by calling Getsockname.
	// For the remote address, use
	// 1) the one returned by the connect method, if any; or
	// 2) the one from Getpeername, if it succeeds; or
	// 3) the one passed to us as the raddr parameter.
	lsa, _ = syscall.Getsockname(fd.pfd.Sysfd)
	if crsa != nil {
		fd.setAddr(fd.addrFunc()(lsa), fd.addrFunc()(crsa))
	} else if rsa, _ = syscall.Getpeername(fd.pfd.Sysfd); rsa != nil {
		fd.setAddr(fd.addrFunc()(lsa), fd.addrFunc()(rsa))
	} else {
		fd.setAddr(fd.addrFunc()(lsa), raddr)
	}
	return nil
}

0x03 UDP 优化

服务端优化

客户端优化

  • 减少锁竞争:实例化多个 UDP 连接到一个 slice 中,在客户端代码里随机使用 slice 的 UDP 进行连接

0x04 总结

  1. 项目中如需 UDP 收发包框架,可以尝试使用 getty
  2. 关于 golang UDP 的使用及若干方法细节,可以参考 深入 Go UDP 编程

0x05 参考