Go语言实现UDP socket的ack机制和丢包重传 | 青训营

1.实验介绍和Golang的socket编程

实验介绍:

UDP是指User Datagram Protocol即用户数据报协议,属于传输层协议,UDP数据报由首部和数据两部分组成,其首部只有源端口、目的端口、消息长度和校验和四部分;

UDP在通讯之前不需要建立连接,可以直接发送数据包,是一种无连接协议,不像TCP(Transmission Control Protocol传输控制协议)在通信之前需要进行三次握手连接,传输过程中也没有报文确认信息,因此资源消耗少,传输速度快,常用于音视频传输;

同时也使UDP存在不可靠的问题,在某些场景下需要使用UDP,但是也要防止丢包,即需要实现可靠的UDP传输,传输层无法保证传输的可靠性就只能转移到应用层进行实现,实现的方法可以参照TCP可靠传输的机制,在应用层模仿TCP传输层实现的机制,先不考虑拥塞处理的情况下,最先要解决的是丢包问题,即需要:

  1. 添加seq/ack机制,确保数据发送到对端;
  2. 添加重传机制,解决丢包问题。

Golang的socket编程:

Go语言通过标准库中的net包来实现UDP和TCP的socket编程。net包提供了用于创建和管理网络连接的函数,以及用于进行数据传输的相关类型和方法,不同于C++需要手动设置和管理socket API,不论实现UDP还是TCP都可以直接使用封装好的方法进行操作,大大简化了socket编程:

UDP实现:

  1. net.ResolveUDPAddr: 用于解析UDP地址字符串;
go 复制代码
func ResolveUDPAddr(network, address string) (*UDPAddr, error)
  1. net.DialUDP: 创建一个UDP连接,并返回一个UDPConn对象,用于发送和接收UDP数据;
go 复制代码
func DialUDP(network string, laddr, raddr *UDPAddr) (*UDPConn, error)
  1. UDPConn: 代表UDP连接,提供了用于发送和接收数据的方法,以及获取本地远程地址和关闭连接的各种方法;
    • UDPConn.Write: 发送UDP数据;
    • UDPConn.Read: 接收UDP数据;
    • UDPConn.LocalAddr: 获取本地地址;
    • UDPConn.RemoteAddr: 获取远程地址;
    • UDPConn.Close: 关闭UDP连接;
    • UDPConn.ReadFromUDP: 从UDP连接中接收数据,并返回发送方的地址信息;
    • UDPConn.WriteToUDP: 向指定的地址发送UDP数据。
go 复制代码
func (c *UDPConn) Write(b []byte) (int, error)
func (c *UDPConn) Read(b []byte) (int, error)
func (c *UDPConn) LocalAddr() Addr
func (c *UDPConn) RemoteAddr() Addr
func (c *UDPConn) Close() error
func (c *UDPConn) ReadFromUDP(b []byte) (int, *UDPAddr, error)
func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (int, error)
  1. net.ListenUDP: 创建一个UDP监听器,用于接收UDP数据包。
go 复制代码
func ListenUDP(network string, laddr *UDPAddr) (*UDPConn, error)

TCP实现:

  1. net.ResolveTCPAddr: 解析TCP地址字符串;
go 复制代码
func ResolveTCPAddr(network, address string) (*TCPAddr, error)
  1. net.DialTCP: 创建TCP连接,并返回一个TCPConn对象,用于发送和接收TCP数据;
go 复制代码
func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error)
  1. TCPConn: 代表TCP连接,提供了用于发送和接收数据的方法,以及获取本地远程地址和关闭连接的各种方法;
    • TCPConn.Write: 发送TCP数据;
    • TCPConn.Read: 接收TCP数据;
    • TCPConn.LocalAddr: 获取本地地址;
    • TCPConn.RemoteAddr: 获取远程地址;
    • TCPConn.Close: 关闭TCP连接。
go 复制代码
func (c *TCPConn) Write(b []byte) (int, error)
func (c *TCPConn) Read(b []byte) (int, error)
func (c *TCPConn) LocalAddr() Addr
func (c *TCPConn) RemoteAddr() Addr
func (c *TCPConn) Close() error
  1. net.ListenTCP: 创建一个TCP监听器,用于接收TCP连接;
go 复制代码
func ListenTCP(network string, laddr *TCPAddr) (*TCPListener, error)
  1. TCPListener.Accept: 接受TCP连接请求,并返回一个TCPConn对象,用于与客户端通信。
go 复制代码
func (l *TCPListener) Accept() (*TCPConn, error)

2.使用net包实现UDP通信

客户端实现:

首先创建一个UDP连接,连接本机IP和端口,读取用户输入数据后,通过UDP连接将数据传输给服务器,读取并打印服务器的响应数据,客户端会持续等待用户输入数据并发送和打印响应数据,实现UDP数据的基本发送和接收,实现简单的本地UDP通信客户端;

go 复制代码
package main

import (
	"bufio"
	"fmt"
	"net"
	"os"
)

// udp client
func main() {
	// 创建一个UDP连接到服务器的IP地址和端口号
	c, err := net.DialUDP("udp", nil, &net.UDPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: 8282,
	})
	if err != nil {
		fmt.Printf("dail,err:%v\n", err)
		return
	}
	defer c.Close()

	// 从标准输入读取用户输入的数据
	input := bufio.NewReader(os.Stdin)
	for {
		// 读取用户输入,直到遇到换行符 '\n'
		s, _ := input.ReadString('\n')

		// 将用户输入的数据转换为字节数组并通过UDP连接发送给服务器
		_, err = c.Write([]byte(s))
		if err != nil {
			fmt.Printf("send to server failed,err:%v\n", err)
			return
		}

		// 接收来自服务器的数据
		var buf [1024]byte
		n, addr, err := c.ReadFromUDP(buf[:])
		if err != nil {
			fmt.Printf("recv from udp failed,err:%v\n", err)
			return
		}
             
		// 打印来自服务器的数据
		fmt.Printf("服务器 %v,响应数据:%v\n", addr, string(buf[:n]))
	}
}

服务器实现:

首先创建UDP监听器监听指定IP和端口,等待连接客户端,连接后会读取客户端发来的数据并打印收到的数据,并将接收的响应信息返回发送给客户端,使用死循环使其能够持续获取客户端数据,同样实现了UDP的数据接收和发送,实现了简单的UDP服务器;

go 复制代码
package main

import (
	"fmt"
	"net"
)

// udp server
func main() {
	// 创建一个UDP监听器,监听本地IP地址的端口
	listen, err := net.ListenUDP("udp", &net.UDPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: 8282,
	})
	if err != nil {
		fmt.Printf("listen failed,err:%v\n", err)
		return
	}
	defer listen.Close()

	for {
		var buf [1024]byte
		// 从UDP连接中读取数据到buf中,n为读取到的字节数,addr为数据发送者的地址
		n, addr, err := listen.ReadFromUDP(buf[:])
		if err != nil {
			fmt.Printf("read from udp failed,err:%v\n", err)
			return
		}

		// 打印接收到的数据
		fmt.Println("接收到的数据:", string(buf[:n]))

		// 将接收到的数据原样发送回给数据发送者
		_, err = listen.WriteToUDP(buf[:n], addr)
		if err != nil {
			fmt.Printf("write to %v failed,err:%v\n", addr, err)
			return
		}
	}
}

运行情况:

首先运行服务器,等待客户端连接,再运行客户端并在控制台输入要传输的数据查看两端的数据传输效果:

客户端:

服务器:

3.实现seq/ack机制

seq/ack机制介绍:

上述简单UDP通信其实已经实现了最简单的ack,就是服务器获取到数据后发送响应数据给客户端,客户端收到响应信息后才继续发送数据给服务器,但是在实际应用中,大多时候都不是仅仅发送简单数据,还要发送文件,图片视频等大型数据,需要进行分包发送,就需要对每个包实行编号的机制,通过对编号的有序性判断来判断所有数据包是否都完整的发送到了接收端,因此可以模仿TCP的解决方法,在传输过程中维护包编号seq和ack来确保数据正确传输;

seq:sequence number,用于标识发送端发送的数据字节流中的第一个字节的序号,TCP 使用序列号来对数据进行编号,确保数据在传输过程中的有序性,发送方在发送每个 TCP 报文段时,都会为其分配一个唯一的序列号;

ack:acknowledge number,用于确认接收端期望收到的下一个字节的序号,当接收端收到一个 TCP 报文段后,它会向发送端发送一个ack报文段,其中包含下一个希望接收的字节的序号seq,这样,发送端就知道接收端已经成功收到了哪些数据,可以继续发送后续数据。

UDP添加seq/ack机制

实现思路:

要在上述UDP通信的基础上实现一个简单的seq/ack机制,即在每次客户端发送数据时,维护一个seq变量和数据一起发送,服务端则将响应数据变为维护一个ack变量,每收到一个seq对应的数据就发送回下一个希望接收的数据序号,即ack=seq+1,在客户端指定一个等待时间,用于等待服务器ack的回送,在时间内接收到ack后再继续发送后续数据,这样就能为UDP添加简单的seq/ack机制。

客户端:

定义结构体Message,包含序列号Seq和消息文本Msg两个字段,每次发送就直接发送一个Message对象,连接UDP后,声明一个string类型的数组input,作为传输的测试数据,其中有5条数据,通过for循环遍历input数组将带序号的包数据发送给服务端,每次发送后等待服务器的ack消息,并设置等待时间为5秒,超时就提示丢包并停止发包,接收到ack时,判断ack是否等于seq+1,不是则提示ack错误并停止发包,是的情况下才能继续发包,同时定义encodeMessage方法将发送数据编码成字节数据用于Write函数发送,decodeMessage方法则解码接收到的数据;

go 复制代码
package main

import (
	"fmt"
	"net"
	"strconv"
	"strings"
	"time"
)

type Message struct {
	Seq int
	Msg string
}

func main() {
	c, err := net.DialUDP("udp", nil, &net.UDPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: 8282,
	})
	if err != nil {
		fmt.Printf("dail,err:%v\n", err)
		return
	}
	defer c.Close()

	//示例数据
	input := []string{"Message 1", "Message 2", "Message 3", "Message 4", "Message 5"}
	seq := 0

	for _, msg := range input {
		seq++
		message := Message{Seq: seq, Msg: msg}
		fmt.Printf("Sending seq=%d: %s\n", message.Seq, message.Msg)

		// 发送带有序列号的数据包
		_, err = c.Write(encodeMessage(message))
		if err != nil {
			fmt.Printf("send to server failed,err:%v\n", err)
			return
		}

		// 等待ACK,设置超时时间
		buf := make([]byte, 1024)
		c.SetReadDeadline(time.Now().Add(5 * time.Second))
		n, _, err := c.ReadFromUDP(buf)
		if err != nil {
			fmt.Println("ACK not received. Timeout or Error.")
			return
		} else {
			ack := decodeMessage(buf[:n])
			if ack.Seq == seq+1 {
				fmt.Printf("ACK = %d\n", ack.Seq)
			} else {
				fmt.Println("Invalid ACK received. Retry.")
				return
			}
		}
	}
}

func encodeMessage(msg Message) []byte {
	// 将序列号和消息文本编码成字节数据
	return []byte(fmt.Sprintf("%d;%s", msg.Seq, msg.Msg))
}

func decodeMessage(data []byte) Message {
	// 解码收到的数据,提取序列号和消息文本
	parts := strings.Split(string(data), ";")
	seq, _ := strconv.Atoi(parts[0])
	msg := parts[1]
	return Message{Seq: seq, Msg: msg}
}

服务器:

接收到客户端发送的带有序列号的数据后,打印序列号和数据内容,然后对序列号进行+1操作作为ack发回客户端,即下一个期望的包序列是seq+1,同样定义encodeMessage方法将发送数据编码成字节数据用于Write函数发送,decodeMessage方法则解码接收到的数据;

go 复制代码
package main

import (
	"fmt"
	"net"
	"strconv"
	"strings"
)

type Message struct {
	Seq int
	Msg string
}

func main() {
	listen, err := net.ListenUDP("udp", &net.UDPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: 8282,
	})
	if err != nil {
		fmt.Printf("listen failed,err:%v\n", err)
		return
	}
	defer listen.Close()

	for {
		var buf [1024]byte
		n, addr, err := listen.ReadFromUDP(buf[:])
		if err != nil {
			fmt.Printf("read from udp failed,err:%v\n", err)
			return
		}

		// 处理接收到的数据,提取序列号和消息文本
		message := decodeMessage(buf[:n])
		fmt.Printf("Received seq=%d from %v: %s\n", message.Seq, addr, message.Msg)

		// 发送ACK回复给客户端,ACK=Seq+1
		ack := Message{Seq: message.Seq + 1, Msg: "ACK"}
		_, err = listen.WriteToUDP(encodeMessage(ack), addr)
		if err != nil {
			fmt.Printf("write to %v failed,err:%v\n", addr, err)
			return
		}
	}
}

func encodeMessage(msg Message) []byte {
	// 将序列号和消息文本编码成字节数据
	return []byte(fmt.Sprintf("%d;%s", msg.Seq, msg.Msg))
}

func decodeMessage(data []byte) Message {
	// 解码收到的数据,提取序列号和消息文本
	parts := strings.Split(string(data), ";")
	seq, _ := strconv.Atoi(parts[0])
	msg := parts[1]
	return Message{Seq: seq, Msg: msg}
}

运行情况:

客户端:

服务端:

小发现:

细心的同学可能会发现这里有个奇怪的地方,为什么服务器获取到的发送端端口不是我们定义好的8282,但是在前面那个简单UDP通信实现中端口号并没有改变,就是监听的8282,我们再运行几次这个增加了ack机制的UDP通信会发现,每一次端口号都会改变,似乎是随机的;

这个现象我也很奇怪,查找一些资料后猜测应该是操作系统设置的问题,在连接关闭后本地端口可能仍在TIME_WAIT状态,即端口没有释放,而不停的创建客户端时,操作系统为了避免端口冲突自动分配了其他本地端口供套接字使用;

具体是不是这个原因我也不清楚,缺乏这方面资料和经验,也不知道是不是和socket底层或者net包中的实现原理有关,有大佬知道的话可以解释一下!

4.实现超时重传

实现思路:

有了seq/ack机制,且在发送端设置了等待ack的时间,那么当超过时间没有收到ack回复的话就能确定是丢包了,这时候就要进行重传,也就是重传上一个ack对应的数据包,最简单的方法就是当超时没有收到ack回复或者ack回复错误乱序时,阻塞后续发包,先进行重传,把丢失的包重传后再继续发包,因此主要是在客户端更改一些发包的逻辑,服务端则没有变化;

客户端实现:

在seq/ack机制的基础上,将遍历数据进行分包发送的操作变成两个嵌套循环,外循环遍历数据进行分包,将seq作为索引,内循环则执行发送并等待ack并判断ack是否正确,正确的情况下退出内循环,发送下一个数据包,如果超时或者ack不正确,就continue内循环,重新发送一次当前包,直到获取到正确的ack,这样两个嵌套for循环就能实现超时重传的机制;

go 复制代码
package main

import (
	"fmt"
	"net"
	"strconv"
	"strings"
	"time"
)

type Message struct {
	Seq int
	Msg string
}

func main() {
	c, err := net.DialUDP("udp", nil, &net.UDPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: 8282,
	})
	if err != nil {
		fmt.Printf("dial,err:%v\n", err)
		return
	}
	defer c.Close()

	// 示例数据
	input := []string{"Message 1", "Message 2", "Message 3", "Message 4", "Message 5"}

	for seq, msg := range input {
		for {
			message := Message{Seq: seq + 1, Msg: msg}
			fmt.Printf("Sending seq=%d: %s\n", message.Seq, message.Msg)

			// 发送带有序列号的数据包
			_, err := c.Write(encodeMessage(message))
			if err != nil {
				fmt.Printf("send to server failed,err:%v\n", err)
				return
			}

			// 开始等待ACK,设置超时时间
			buf := make([]byte, 1024)
			c.SetReadDeadline(time.Now().Add(5 * time.Second))

			// 循环等待ACK,直到收到正确的ACK或超时
			n, _, err := c.ReadFromUDP(buf)
			if err != nil {
				// 超时或发生错误,需要重传
				fmt.Println("ACK not received. Timeout or Error. Retrying...")
				continue
			} else {
				ack := decodeMessage(buf[:n])
				if ack.Seq == seq+2 {
					fmt.Printf("ACK = %d\n", ack.Seq)
					// 收到正确的ACK,跳出内部循环,继续发送下一个消息
					break
				} else {
					// 收到错误的ACK,继续等待,内部循环会重发相同的消息
					fmt.Println("Invalid ACK received. Waiting for correct ACK...")
					continue
				}
			}
		}
	}
}

func encodeMessage(msg Message) []byte {
	// 将序列号和消息文本编码成字节数据
	return []byte(fmt.Sprintf("%d;%s", msg.Seq, msg.Msg))
}

func decodeMessage(data []byte) Message {
	// 解码收到的数据,提取序列号和消息文本
	parts := strings.Split(string(data), ";")
	seq, _ := strconv.Atoi(parts[0])
	msg := parts[1]
	return Message{Seq: seq, Msg: msg}
}

丢包模拟

为了验证重传的正确性,就需要模拟丢包的情况,最方便的方法就是在服务器进行模拟,设定一个随机丢包的机制,使用随机浮点数的方法设置一个丢包率,也就是从客户端获得的包有一定概率会被服务器给拦截,被拦截的包不进行ack响应处理,也不反回ack或其他响应信息,强行让客户端超时,验证客户端的重传机制是否正确,由于我的服务器使用for死循环一直等待客户端传入数据进行响应,只需在概率产生时continue循环而不继续后续ack处理操作就能实现拦截包,实现模拟丢包的逻辑了;

go 复制代码
package main

import (
	"fmt"
	"math/rand"
	"net"
	"strconv"
	"strings"
)

type Message struct {
	Seq int
	Msg string
}

func main() {
	listen, err := net.ListenUDP("udp", &net.UDPAddr{
		IP:   net.IPv4(127, 0, 0, 1),
		Port: 8282,
	})
	if err != nil {
		fmt.Printf("listen failed,err:%v\n", err)
		return
	}
	defer listen.Close()

	for {
		var buf [1024]byte
		n, addr, err := listen.ReadFromUDP(buf[:])
		if err != nil {
			fmt.Printf("read from udp failed,err:%v\n", err)
			return
		}

		// 以20%的概率模拟丢包
		if rand.Float32() < 0.2 {
			fmt.Printf("From %v lost package\n", addr)
			continue
		}

		// 处理接收到的数据,提取序列号和消息文本
		message := decodeMessage(buf[:n])
		fmt.Printf("Received seq=%d from %v: %s\n", message.Seq, addr, message.Msg)

		// 发送ACK回复给客户端,ACK=Seq+1
		ack := Message{Seq: message.Seq + 1, Msg: "ACK"}
		_, err = listen.WriteToUDP(encodeMessage(ack), addr)
		if err != nil {
			fmt.Printf("write to %v failed,err:%v\n", addr, err)
			return
		}
	}
}

func encodeMessage(msg Message) []byte {
	// 将序列号和消息文本编码成字节数据
	return []byte(fmt.Sprintf("%d;%s", msg.Seq, msg.Msg))
}

func decodeMessage(data []byte) Message {
	// 解码收到的数据,提取序列号和消息文本
	parts := strings.Split(string(data), ";")
	seq, _ := strconv.Atoi(parts[0])
	msg := parts[1]
	return Message{Seq: seq, Msg: msg}
}

运行情况:

客户端:

服务端:

5.优化策略

上述方法实现的丢包重传虽然能够正常工作,但是发送端使用双层循环嵌套,并且每次丢包都阻塞了后续发包,这样会导致重传的效率很低,只适用于小宽带低延时的情况,而且超时重传容易产生误判,主要有以下两种情况:

  1. 对方收到了数据包,但是ack发送途中丢失,其实就是我服务器模拟丢包的情况,服务器可能收到了数据,但是因为某种原因ack没能正确发送;
  2. ack在回传的途中,但是时间已经超过了发送端的ack等待时间即超过了一次RTO,这样也会导致接收端收到数据却仍然重传的问题。

为了解决这些问题,可以使用请求重传的机制,也就是接收端反馈机制,不再让发送端感知是否丢包,而是直接让接收端根据自己收到的包序号信息进行丢包判断,出现序号不连续的情况时就可以在ack中增加携带报文丢失的信息,将丢失的报文序号作为响应数据返回给发送端,发送端根据丢失信息进行有针对性的重传,可以避免阻塞后续的发包操作,只需要重传中间丢失的部分,这样可以提高效率,避免超时重传的一些问题;

但是请求重传也会有自身的问题,例如对包序有要求的传输要进一步整理包序,如果网络差,丢包率极高,接收端会不断的发送重传请求,发送端也会不断的进行重传,甚至造成网络风暴,解决方法是在发送端可以使用一个拥塞控制进行限流;

除了超时重传和请求重传的机制,还有FEC选择重传等高级方法,同时UDP可靠传输也不仅仅需要实现重传机制,还需要结合窗口和拥塞控制等技术进一步增加可靠性,这其中还有很多技术知识值得学习。

6.总结

这个实战是我根据学习到的Golang实现UDP通信的方法的基础上进行的可靠性改编,主要是超时重传机制的实现,很多地方可能功能和目的是达到了,但是实现方法和效率方面没有进一步优化,包括测试也不够完善,也没有针对一些大型数据进行测试,可靠性也还有很多优化的地方,滑动窗口、拥塞控制等都没有试着实现,还有很大的迭代空间;

但是也收获了很多,让我深入理解了seq/ack的原理和超时重传的逻辑,还有就是用Go语言进行socket编程的方法,对比以前使用C++的实现,简化了太多太多,很多函数都是底层已经封装好的,不用自己亲自去操作socket API,代码也更容易看懂,复习了UDP和TCP的一些知识,实际动手实现了一个比较简单易懂的超时重传UDP,计网真的有很多难点和技术点还需要深入,基础知识还是要抓牢啊!

相关推荐
滑滑滑2 天前
后端实践-优化一个已有的 Go 程序提高其性能 | 豆包MarsCode AI刷题
青训营笔记
柠檬柠檬2 天前
Go 语言入门指南:基础语法和常用特性解析 | 豆包MarsCode AI刷题
青训营笔记
用户967136399652 天前
计算最小步长丨豆包MarsCodeAI刷题
青训营笔记
用户52975799354723 天前
字节跳动青训营刷题笔记2| 豆包MarsCode AI刷题
青训营笔记
clearcold3 天前
浅谈对LangChain中Model I/O的见解 | 豆包MarsCode AI刷题
青训营笔记
夭要7夜宵3 天前
【字节青训营】 Go 进阶语言:并发概述、Goroutine、Channel、协程池 | 豆包MarsCode AI刷题
青训营笔记
用户336901104444 天前
数字分组求和题解 | 豆包MarsCode AI刷题
青训营笔记
dnxb1234 天前
GO语言工程实践课后作业:实现思路、代码以及路径记录 | 豆包MarsCode AI刷题
青训营笔记
用户916357440954 天前
AI刷题-动态规划“DNA序列编辑距离” | 豆包MarsCode AI刷题
青训营笔记
热的棒打鲜橙4 天前
数字分组求偶数和 | 豆包MarsCode AI刷题
青训营笔记