青训营 X 豆包MarsCode 技术训练营 | 网络与部署 | TCP以及UDP实现的网络编程

104 阅读14分钟

1 TCP模型

在Go的网络编程过程中,无需关注socket是否是non-block的,也无需亲自注册文件描述符(FD),只需要将每个连接对应的goroutine以block I/O的方式对待socket处理就可以。
具体的流程也不需要向Unix/Linux socket编程那样以socketbindlistenacceptwriteread来实现,go的socket编程流程就相对简单:即server端通过Listen+Accept模式实现,例如一个典型的Go Server端程序如下

func handler(conn net.Conn) {
    defer conn.Close()
    for {
            // read connection
            // ...
            // write connection
    }
}

func main() {
	// 监听端口
	log.Printf("Listening...")
	server, err := net.Listen("tcp", ":8080")
	if err != nil {
		log.Fatal(err)
	}
	// 死循环等待客户端连接
	for {
	    conn, err := server.Accept()
	    if err != nil {
                log.Printf("Accept error: %#v\n", err)
                break
            }
            log.Printf("Get conn from port 8080")
            go handler(conn)
	}
}

主要的作用就是监听获取连接然后通过log打印状态,具体我们用户层眼中看到的goroutine中的“block socket”的实现细节暂时没有去细究,但是在搜索相关资料时大致了解到是通过Go runtime中的netpoller通过Non-block socket+I/O复用机制“模拟”出来的,真实的underlying socket实际上是non-block的。

1.1 TCP连接的建立

1.1.1 TCP数据的读取

有了刚才服务端的结构,接下来就需要客户端和服务端进行三次握手来进行通信。连接建立过程中,客户端Go语言使用的是net.Dial进行连接。

func main() {
	conn, err := net.Dial("tcp", ":8080")
	if err != nil {
            log.Println("Connect error: ", err)
            return
	}
	defer conn.Close()
        log.Println("Dial ok")
}

接下来从命令行分别运行server.go以及client.go,可以得到如下结果
server端

$ go run server.go
2024/11/15 22:07:14 Listening...
2024/11/15 22:07:17 Get conn from port 8080

client端

$ go run client.go 
2024/11/15 22:07:17 Dial ok

根据开始尝试的正常的连接建立,经过学习以及查阅相关资料跟着学习了TCP连接建立后几种特殊的异常情况。①当本机8080端口未有服务程序监听,执行client.go后,Dial会立马返回错误:

$ go run client.go 
2024/11/16 00:39:00 Connect error: dial tcp :8080: connect: connection refused

当socket中有部分数据,并且长度小于Read一次读取的长度,那么Read会一次性返回读取的结果

func handler(conn net.Conn) {
	defer conn.Close()
	for {
		var buf = make([]byte, 10)
		log.Println("Start to read...")
		n, err := conn.Read(buf)
		if err != nil {
			log.Println("Read error: ", err)
			return
		}
		log.Printf("Read %d bytes, content is %s\n", n, string(buf))
	}
}
func main() {
	conn, err := net.Dial("tcp", ":8080")
	if err != nil {
		log.Println("Connect error:", err)
		return
	}
	defer conn.Close()
	log.Println("Dial ok")

	time.Sleep(time.Second * 2)
	data := os.Args[1]
	_, err = conn.Write([]byte(data))
	if err != nil {
		log.Printf("write error %v", err)
	} 
	time.Sleep(time.Second * 10000)
}

会得到如下结果:

$ go run server.go
2024/11/16 17:18:31 Listening...
2024/11/16 17:18:34 Get conn from port 8080
2024/11/16 17:18:34 Start to read...
2024/11/16 17:18:36 Read 5 bytes, content is hello
$ go run client.go hello
2024/11/16 17:18:34 Dial ok

②而当socket中的数据大于Read一次能够读取的长度,Read先读取期望的长度,然后在第二次读取的时候把剩余的数据读出。通过client.go向server发送hello,marscode,server会先将前10个字节读出来(hello,mars),再将剩下的4个字节读取出来(code)

$ go run client.go hello,marscode
2024/11/16 17:28:49 Dial ok
$ go run server.go
2024/11/16 17:28:41 Listening...
2024/11/16 17:28:49 Get conn from port 8080
2024/11/16 17:28:49 Start to read...
2024/11/16 17:28:51 Read 10 bytes, content is hello,mars
2024/11/16 17:28:51 Start to read...
2024/11/16 17:28:51 Read 4 bytes, content is code
2024/11/16 17:28:51 Start to read...

③在server读取数据时,client端主动关闭了socket,这里分为两种情况:一种是“有数据关闭”,另一种是“无数据关闭”。当client在关闭时,socket中还有server未读取的数据称为“有数据关闭”,我们再以同样的方式给server端发送数据hello,可以得到

$ go run server.go
2024/11/16 17:54:08 Listening...
2024/11/16 17:54:11 Get conn from port 8080
2024/11/16 17:54:11 Start to read...
2024/11/16 17:54:13 Read 5 bytes, content is hello
2024/11/16 17:54:13 Start to read...
2024/11/16 17:54:15 Read error:  EOF

可以看到server在第一次成功读出client所发送的数据后,由于在第二次Read时client端的socket关闭,导致了Read的结果为EOF,而我们发送的数据长度本就小于server期望的数据长度,所以不难推测出在“无数据关闭”情况下的Read结果会直接返回EOF

1.1.2 TCP连接中数据的写入

既然Read中会出现各种异常的情况(举例的并不充分),那么调用Write操作也同样会遇到一些问题。那么如何判断是否Write成功,其中的一个依据就是在通过调用conn.Write()获得返回值n与预期写入的数据长度是否相同并且err=nil。 异常情况①写阻塞,由于TCP的全双工特性,接收方和发送方都会有自己的接收缓冲以及发送缓冲,所以当发送方将对方的接收缓冲区以及自己的发送缓冲区写满后,就会发生写阻塞现象,模拟一下实验场景
server端前10秒不进行任何Read操作,这样可以模拟client在写入一定量时会发生阻塞

func handler(conn net.Conn) {
	defer conn.Close()
	time.Sleep(time.Second * 10)
	for {
		time.Sleep(5 * time.Second)
		var buf = make([]byte, 60000)
		log.Println("Start to read...")
		n, err := conn.Read(buf)
		if err != nil {
			log.Println("Read error: ", err)
			if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
				continue
			}
		}
		log.Printf("Read %d bytes, content is %s\n", n, string(buf))
	}
}
func main() {
	conn, err := net.Dial("tcp", ":8080")
	if err != nil {
		log.Println("Connect error:", err)
		return
	}
	defer conn.Close()
	log.Println("Dial ok")

	buf := make([]byte, 65536)
	var total int
	for{
		n, err := conn.Write(buf)
		if err != nil {
			total += n
			log.Printf("Write %d bytes, errors: %v\n", n, err)
			break
		}
		total += n
		log.Printf("Write %d bytes this time, %d in total\n", n, total)
	}
	log.Printf("Write %d bytes in total\n", total)
	time.Sleep(time.Second * 10000)
}

分别运行server和client会得到如下情景

$ go run client.go
2024/11/16 18:39:53 Dial ok
2024/11/16 18:39:53 Write 65536 bytes this time, 65536 in total
2024/11/16 18:39:53 Write 65536 bytes this time, 131072 in total
....
2024/11/16 18:39:53 Write 65536 bytes this time, 2555904 in total <-----
2024/11/16 18:40:18 Write 65536 bytes this time, 2621440 in total
...
2024/11/16 18:40:19 Write 65536 bytes this time, 6356992 in total

可以看到在写入2555904字节的时候会发生阻塞,此时server端开始进行Read操作,client才可以继续进行Write操作

$ go run server.go
2024/11/16 18:39:49 Listening...
2024/11/16 18:39:53 Get conn from port 8080
2024/11/16 18:40:08 Start to read...
2024/11/16 18:40:08 Read 60000 bytes, content is 
2024/11/16 18:40:13 Start to read...
2024/11/16 18:40:13 Read 60000 bytes, content is 
2024/11/16 18:40:18 Start to read...
2024/11/16 18:40:18 Read 38666 bytes, content is 

②写入部分数据,当client在进行数据写入时我们挂掉server,这时可以看到client输出的日志:

....
2024/11/16 20:00:38 Write 65536 bytes this time, 2555904 in total
2024/11/16 20:00:38 Write 30689 bytes, errors: write tcp 127.0.0.1:47406->127.0.0.1:8080: write: connection reset by peer
2024/11/16 20:00:38 Write 2586593 bytes in total

可以看到在我们挂掉server的时候,并不是在2555904阻塞的,在后续又写入了30689个字节后发生了阻塞,也就是说经过Write操作返回了err!=nil且n=30689,所以程序需要对这30689字节做其他特定的处理
③写入超时如果client端写入超时会发生什么,在client的Write的操作前加入一个

conn.SetWriteDeadline(time.Now().Add(time.Microsecond * 10))

的操作,代表如果在当前时间内的10微秒未能成功将数据写入到conn,任何的Write操作都会返回一个error,这个error通常会是timeout,进行实验得到如下结果:

...
2024/11/16 20:21:36 Write 30689 bytes, errors: write tcp 127.0.0.1:55486->127.0.0.1:8080: i/o timeout
2024/11/16 20:21:36 Write 2586593 bytes in total

当client发生写入超时时,并不会完全阻塞数据写入,此时也会有30689字节的数据写入

1.2 TCP实战抓包

1.2.1 正常通讯情况下的抓包

经过对之前异常状况的简单学习,现在开始抓包server端和client端之间的通信,使用tcpdump工具进行抓包,由于只是对本机间的两个进程进行通讯,先运行server.go,再通过另一个命令行运行

tcpdump -i lo tcp port 8080 -w tcp_local.pcap

再运行client.go,这个抓包实验中我们实现client向server发送hello字符,通过使用Wireshark打开tcp_local.pcap文件,可以看到如下过程

通信过程抓包.png

在上述的过程中,我们可以看到从端口56710向8080发起SYN请求发起三次握手请求,三次握手建立后,端口56710向8080发送长度(len)为5的数据,8080端口回复ACK报文,其中Ack=6就向课程中所讲希望下一个报文的seq为6
点开其中一个请求发送过程,可以看到详细的信息,例如传输协议,传输的报文类型等等

详细通信过程.png

1.2.2 模拟丢包情景

因为是在同一台虚拟机上,所以无法通过像小林coding作者那样使用拔网线这种物理手段来让服务端挂掉,而如果不启动server端让client端直接发起三次握手又会直接返回panic导致进程中断无法发起重传,所以使用一种在客户端上加防火墙限制,把来自服务端的输入输出都丢弃,配置规则如下,它的作用是将来自服务端的数据都丢弃

iptables -I INPUT -p tcp dport 8080 -j DROP

像之前正常启动通讯的流程一样,配置防火墙规则后,启动server、再启动tcpdump、再启动client,抓包后的结果如下

第一次丢包重传.png 可以看到针对第一次握手的情况一共超时重传了3次,并且重传的时间是指数变化

2 UDP模型

UDP不像TCP那样在通讯前先建立连接,而是可以直接发送数据包,传输过程中也没有相应报文的确认信息,首部资源的开销也较TCP少,通常用于音视频传输。但就是因为没有确认和重传的机制,导致了UDP不可靠的问题,所以我们也同样需要防止丢包使UDP变得相对可靠,通过的方式就是模拟TCP在传输层实现的机制,只不过我们是在应用层模拟,实现:
1.通过UDP socket实现ack机制,确保数据发送到对端 2.添加重传机制,并感知在UDP中的丢包重传

2.1 UDP连接的建立

对于UDP连接的建立我们需要几个函数:
1.func ResolveUDPAddr(network, address string) (*UDPAddr, error)用于解析UDP字符串地址
2.func ListenUDP(network string, laddr *UDPAddr) (*UDPConn, error)用于创建UDP监听器
3.func DialUDP(network string, laddr, raddr *UDPAddr) (*UDPConn, error)用于创建一个UDP连接
从上面三个函数我们可以看到它们都有一个返回值UDPConn,它同样也是Conn的接口实现,那么需要用到的函数也和其有关,例如:

  • func (c *conn) Write(b []byte) (int, error):发送数据
  • func (c *conn) Read(b []byte) (int, error):读取数据
  • func (c *UDPConn) ReadFromUDP(b []byte) (n int, addr *UDPAddr, err error):从UDP连接中读取数据
  • func (c *UDPConn) WriteToUDP(b []byte, addr *UDPAddr) (int, error):向UDP连接中写入数据

从上面的方法我们可以看出,与TCP不同的是,UDP缺少对客户端连接请求的Accept方法,用这几个函数我们可以编写一个简单的基于UDP实现的server端以及client端:
Server端
创建监视器监听端口,等待客户端连接,连接后会读取客户端所发送的数据并打印出来,再将所读取的数据发送回客户端,和TCP的server端一样使用死循环持续接收客户端所发送来的信息,这样就实现了UDP数据的接收和发送

func handleClient(conn *net.UDPConn) {
	var buf [512]byte
	n, udpAddr, err := conn.ReadFromUDP(buf[:])
	if err != nil {
		log.Printf("Read from UDP conn error: %v", err)
		return
	}
	log.Printf("Read %d bytes, content is %s", n, string(buf[:n]))
	n, err = conn.WriteToUDP(buf[:n], udpAddr)
	if err != nil {
		log.Printf("Write to UDP conn error: %v", err)
		return
	}
	log.Printf("Write %d bytes in total", n)
}

func main() {
	udpAddr, err := net.ResolveUDPAddr("udp4", "127.0.0.1:8080")
	if err != nil {
		log.Printf("Resolve error: %v", err)
	}
	conn, err := net.ListenUDP("udp", udpAddr)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	for {
		handleClient(conn)
	}
}

Client端
解析出要进行连接的地址,随后创建UDP对所解析的地址的IP以及端口进行连接,连接成功后,通过对连接进行写入hello字符发送给server端,然后从UDP中读取由server端的响应数据,实现UDP的基本发送和接收功能

func main(){
	udpAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:8080")
	if err != nil {
		log.Printf("Resolve error: %v", err)
	}
        log.Println("Start listening...")
	conn, err := net.DialUDP("udp", nil, udpAddr)
	if err != nil {
		log.Printf("Dial error: %v", err)
                return
	}
	defer conn.Close()
	n, err := conn.Write([]byte("hello"))
	if err != nil {
		log.Printf("Write error: %v", err)
		return
	}
	var buf [512]byte
	n, addr, err := conn.ReadFromUDP(buf[:])
	log.Printf("Server response %v, content is %s, %d bytes in total", addr, string(buf[:n]), n)
	if err != nil {
		log.Printf("Get response error: %v", err)
	}
    // 丑陋阻塞
    time.Sleep(time.Second * 10000)
}

分别运行server.go和client.go,可以从控制台中看到以下响应:
server端

$ go run server.go 
2024/11/17 21:36:54 Start listening...
2024/11/17 21:36:56 Read 5 bytes, content is hello
2024/11/17 21:36:56 Write 5 bytes in total

client端

$ go run client.go 
2024/11/17 21:36:56 Server response 127.0.0.1:8080, content is hello, 5 bytes in total

至此,我们就实现了UDP socket编程中最基础的客户端等待服务端ack(也就是response)再发包(只不过我们没有在client端做下一步的响应)

2.2 实现seq/ack

我们可以在此基础上在server端维护一个ack信息,在client端维护一个seq以及数据,在收到seq后我们简单假设ack=seq+1(其实ack=seq+发送消息的长度)。并且在client端维护一个等待时间,用于等待server端数据的回送,如果在时间内没有收到数据,则判定为丢包。
Client端
通过定义一个结构体Message来维护一个Seq以及Msg变量,每次client端发送数据时就将Message打包发送过去。每次发送过后就等待server端的响应,接收到ack响应时判断ack.Seq == seq,若不是则提示ack错误并停止发包,如果是那就继续发包。

type Message struct {
	Seq		int
	Msg		string
}
func main(){
	udpAddr, err := net.ResolveUDPAddr("udp", "127.0.0.1:8080")
	if err != nil {
		log.Printf("Resolve error: %v", err)
	}
	conn, err := net.DialUDP("udp", nil, udpAddr)
	if err != nil {
		log.Printf("Dial error: %v", err)
	}
	defer conn.Close()
	
	input_message := []string{"test 1", "test 2", "test 3", "test 4"}
	seq := 0
	
	for _, msg := range input_message {
		seq++
		message := Message{Seq: seq, Msg: msg}
		log.Printf("Send msg seq: %d, content: %s", message.Seq, message.Msg)
		_, err = conn.Write([]byte(fmt.Sprintf("%d:%s",message.Seq, message.Msg)))
		if err != nil {
			log.Printf("Write error: %v", err)
			return
		}

		buf := make([]byte, 512)
		// 设置超时时间5s
		conn.SetReadDeadline(time.Now().Add(time.Second * 5))
		n, addr, err := conn.ReadFromUDP(buf[:])
		
		if err != nil {
			log.Printf("Get response error: %v", err)
		} else {
			ack := decodeMsg(buf[:n])
			if ack.Seq == seq + 1 {
				log.Printf("Get response ACK: %d\n", ack.Seq)
				log.Printf("Server response %v, content is %s, %d bytes in total", addr, ack.Msg, n)
			} else {
				log.Printf("Invalid ACK. Please retry.")
				return
			}
		}
	}
}

func decodeMsg(data []byte) Message {
	subpart := strings.Split(string(data), ":")
	seq, err := strconv.Atoi(subpart[0])
	if err != nil {
		log.Printf("Decode message error: %v", err)
	}
	msg := subpart[1]
	return Message{Seq: seq, Msg: msg}
}

Server端
在读取到client发送来的数据时,打印序列号以及读取到的内容,然后对序列号进行加1操作并作为ack发回给client,也就是希望下一个收到的包的seq=seq+1。

type Message struct {
	Seq		int
	Msg		string
}

func handleClient(conn *net.UDPConn) {
	var buf [512]byte
	n, udpAddr, err := conn.ReadFromUDP(buf[:])
	if err != nil {
		log.Printf("Read from UDP conn error: %v", err)
		return
	}
	message := decodeMsg(buf[:n])
	log.Printf("Received seq=%d from %v, content is %s\n", message.Seq, udpAddr, message.Msg)

	ack := Message{Seq: message.Seq + 1, Msg: "ACK"}
	n, err = conn.WriteToUDP([]byte(fmt.Sprintf("%d:%s", ack.Seq, ack.Msg)), udpAddr)
	if err != nil {
		log.Printf("Write to UDP conn error: %v", err)
		return
	}
	log.Printf("Write %d bytes in total", n)
}
func main() {
	udpAddr, err := net.ResolveUDPAddr("udp4", "127.0.0.1:8080")
	if err != nil {
		log.Printf("Resolve error: %v", err)
		return
	}
	log.Println("Start listening...")
	conn, err := net.ListenUDP("udp", udpAddr)
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()

	for {
		handleClient(conn)
	}
}

func decodeMsg(data []byte) Message {
	subpart := strings.Split(string(data), ":")
	seq, err := strconv.Atoi(subpart[0])
	if err != nil {
		log.Printf("Decode message error: %v", err)
	}
	msg := subpart[1]
	return Message{Seq: seq, Msg: msg}
}

分别运行server和client得到以下运行结果
Server端:

$ go run server.go 
2024/11/18 15:44:22 Start listening...
2024/11/18 15:44:24 Received seq=1 from 127.0.0.1:40150, content is test 1
2024/11/18 15:44:24 Write 5 bytes in total
2024/11/18 15:44:24 Received seq=2 from 127.0.0.1:40150, content is test 2
2024/11/18 15:44:24 Write 5 bytes in total
2024/11/18 15:44:24 Received seq=3 from 127.0.0.1:40150, content is test 3
2024/11/18 15:44:24 Write 5 bytes in total
2024/11/18 15:44:24 Received seq=4 from 127.0.0.1:40150, content is test 4
2024/11/18 15:44:24 Write 5 bytes in total

Client端:

$ go run client.go 
2024/11/18 15:44:24 Send msg seq: 1, content: test 1
2024/11/18 15:44:24 Get response ACK: 2
2024/11/18 15:44:24 Server response 127.0.0.1:8080, content is ACK, 5 bytes in total
2024/11/18 15:44:24 Send msg seq: 2, content: test 2
2024/11/18 15:44:24 Get response ACK: 3
2024/11/18 15:44:24 Server response 127.0.0.1:8080, content is ACK, 5 bytes in total
2024/11/18 15:44:24 Send msg seq: 3, content: test 3
2024/11/18 15:44:24 Get response ACK: 4
2024/11/18 15:44:24 Server response 127.0.0.1:8080, content is ACK, 5 bytes in total
2024/11/18 15:44:24 Send msg seq: 4, content: test 4
2024/11/18 15:44:24 Get response ACK: 5
2024/11/18 15:44:24 Server response 127.0.0.1:8080, content is ACK, 5 bytes in total

通过对通信过程抓包结果如下:

UDP抓包.png

能够看到client发送长度为8的Message(例如test 1:1),server端回应长度为5的Message(例如ACK:1)

3 总结

至此,我们就在UDP中模拟实现了TCP中的一个简单seq/ack机制,其他的机制例如超时重传、滑动窗口、拥塞控制等等都还没有实现,旨在对基于Golang实现socket编程进行一个基础的学习,也通过抓包等看到了一些具体的通信过程而不是单调的背八股,能够在一定程度上加深自己的理解,后面的基础还需要再进行巩固,需要赶紧跟上青训营的进度。