作为一名软件开发者,网络编程是必备知识。本文通过 Go 语言实现 TCP 套接字编程,并结合 tcpdump 工具,展示它的三次握手、数据传输以及四次挥手的过程,帮助读者更好地理解 TCP 协议与 Go 网络编程。
在实现 Go 的 TCP 代码前,我们先了解一下 Go 的网络编程模型。
网络编程属于 IO 的范畴,其发展可以简单概括为:多进程 -> 多线程 -> non-block + I/O 多路复用。
想必读者在初学 IO 模型时,一定对阻塞和非阻塞、同步和异步感到头疼,而 I/O 多路复用的回调更是让人抓狂。Go 在设计网络模型时,就考虑到需要帮助开发者简化开发复杂度,降低心智负担,同时满足高性能要求。
Go 语言的网络编程模型是同步网络编程。它基于 协程 + I/O 多路复用 (linux 下 epoll,darwin 下 kqueue,windows 下 iocp,通过网络轮询器 netpoller 进行封装),结合网络轮询器与调度器实现。
用户层 goroutine 中的 block socket,实际上是通过 netpoller 模拟出来的。runtime 拦截了底层 socket 系统调用的错误码,并通过 netpoller 和 goroutine 调度让 goroutine 阻塞在用户层得到的 socket fd 上。
Go 将网络编程的复杂性隐藏于 runtime 中:开发者不用关注 socket 是否是 non-block 的,也不用处理回调,只需在每个连接对应的 goroutine 中以 block I/O 的方式对待 socket 即可。
例如:当用户层针对某个 socket fd 发起 read 操作时,如果该 socket fd 中尚无数据,那么 runtime 会将该 socket fd 加入到 netpoller 中监听,同时对应的 goroutine 被挂起,直到 runtime 收到 socket fd 数据 ready 的通知,runtime 才会重新唤醒等待在该 socket fd 上准备 read 的那个goroutine。而这个过程从 goroutine 的视角来看,就像是 read 操作一直 block 在那个 socket fd 上似的。
一句话总结:Go 将复杂的网络模型进行封装,放在用户面前的只是阻塞式 I/O 的 goroutine,这让我们可以非常轻松地实现高性能网络编程。
在 Go 中,网络编程非常容易。我们通过 Go 的 net 包,可以轻松实现一个 TCP 服务器。
package main
import (
"log"
"net"
)
func main() {
// Part 1: create a listener
l, err := net.Listen("tcp", ":8000")
if err != nil {
log.Fatalf("Error listener returned: %s", err)
}
defer l.Close()
for {
// Part 2: accept new connection
c, err := l.Accept()
if err != nil {
log.Fatalf("Error to accept new connection: %s", err)
}
// Part 3: create a goroutine that reads and write back data
go func() {
log.Printf("TCP session open")
defer c.Close()
for {
d := make([]byte, 100)
// Read from TCP buffer
_, err := c.Read(d)
if err != nil {
log.Printf("Error reading TCP session: %s", err)
break
}
log.Printf("reading data from client: %s\n", string(d))
// write back data to TCP client
_, err = c.Write(d)
if err != nil {
log.Printf("Error writing TCP session: %s", err)
break
}
}
}()
}
}
根据逻辑,我们将以上代码分成三个部分。
第一部分:端口监听。我们通过 net.Listen("tcp", ":8000")
开启在端口 8000 的 TCP 连接监听。
第二部分:建立连接。在开启监听成功之后,调用 net.Listener.Accept()
方法等待 TCP 连接。Accept
方法将以阻塞式地等待新的连接到达,并将该连接作为 net.Conn
接口类型返回。
第三部分:数据传输。当连接建立成功后,我们将启动一个新的 goroutine 来处理 c
连接上的读取和写入。本文服务器的数据处理逻辑是,客户端写入该 TCP 连接的所有内容,服务器将原封不动地写回相同的内容。
同样,通过 net 包也能快速实现一个 TCP 客户端。
package main
import (
"log"
"net"
"time"
)
func main() {
// Part 1: open a TCP session to server
c, err := net.Dial("tcp", "localhost:8000")
if err != nil {
log.Fatalf("Error to open TCP connection: %s", err)
}
defer c.Close()
// Part2: write some data to server
log.Printf("TCP session open")
b := []byte("Hi, gopher?")
_, err = c.Write(b)
if err != nil {
log.Fatalf("Error writing TCP session: %s", err)
}
// Part3: create a goroutine that closes TCP session after 10 seconds
go func() {
<-time.After(time.Duration(10) * time.Second)
defer c.Close()
}()
// Part4: read any responses until get an error
for {
d := make([]byte, 100)
_, err := c.Read(d)
if err != nil {
log.Fatalf("Error reading TCP session: %s", err)
}
log.Printf("reading data from server: %s\n", string(d))
}
}
将以上代码分为四个部分。
第一部分:建立连接。我们通过 net.Dial("tcp", "localhost:8000")
连接一个 TCP 连接到服务器正在监听的同一个 localhost:8000
地址。
第二部分:写入数据。当连接建立成功后,通过 c.Write()
方法写入数据 Hi, gopher?
给服务器。
第三部分:关闭连接。启动一个新的 goroutine,在 10s 后调用 c.Close()
方法关闭 TCP 连接。
第四部分:读取数据。除非发生 error,否则客户端通过 c.Read()
方法(记住,是阻塞式的)循环读取 TCP 连接上的内容。
tcpdump 是一个非常好用的数据抓包工具,它可以帮助我们捕获和查看网络数据包。
现在,我们通过 tcpdump 来抓取上文 TCP 客户端与服务器通信全过程数据。
tcpdump -S -nn -vvv -i lo0 port 8000
在本例中,通过使用 -i lo0
指定捕获环回接口 localhost,使用 port 8000
将网络捕获过滤为仅与端口 8000 通信或来自端口 8000 的流量,-vvv
是为了打印更多的详细描述信息,-S
显示序列号绝对值。
当运行 tcpdump 后,我们分别启动服务端和客户端代码。
运行服务端代码
$ go run main.go
2021/09/20 19:41:17 TCP session open
2021/09/20 19:41:17 reading data from client: Hi, gopher?
2021/09/20 19:41:27 Error reading TCP session: EOF
服务器和客户端建立连接之后,从客户端读取到数据 Hi, gopher?
。在 10s 后,由于客户端关闭了连接,服务端读取到了 EOF 错误。
运行客户端代码
$ go run main.go
2021/09/20 19:41:17 TCP session open
2021/09/20 19:41:17 reading data from server: Hi, gopher?
2021/09/20 19:41:27 Error reading TCP session: read tcp 127.0.0.1:57596->127.0.0.1:8000: use of closed network connection
客户端和服务器建立连接之后,发送数据给服务端,服务端返回相同的数据 Hi, gopher?
回来。在 10s 后,客户端通过一个新的 goroutine 主动关闭了连接,因此阻塞在 c.Read
的客户端代码捕获到了错误:use of closed network connection
。
那我们通过 tcpdump 抓取的本次通信过程如何呢?首先,我们先通过一张图片回顾一下经典的 TCP 通信全过程。
以下是 tcpdump 抓取的结果
$ tcpdump -S -nn -vvv -i lo0 port 8000
tcpdump: listening on lo0, link-type NULL (BSD loopback), capture size 262144 bytes
19:41:17.109462 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 64, bad cksum 0 (->3cb6)!)
127.0.0.1.57596 > 127.0.0.1.8000: Flags [S], cksum 0xfe34 (incorrect -> 0x18e6), seq 2046827845, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 678438397 ecr 0,sackOK,eol], length 0
19:41:17.109547 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 64, bad cksum 0 (->3cb6)!)
127.0.0.1.8000 > 127.0.0.1.57596: Flags [S.], cksum 0xfe34 (incorrect -> 0x8b10), seq 1697569320, ack 2046827846, win 65535, options [mss 16344,nop,wscale 6,nop,nop,TS val 678438397 ecr 678438397,sackOK,eol], length 0
19:41:17.109558 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
127.0.0.1.57596 > 127.0.0.1.8000: Flags [.], cksum 0xfe28 (incorrect -> 0xec19), seq 2046827846, ack 1697569321, win 6379, options [nop,nop,TS val 678438397 ecr 678438397], length 0
19:41:17.109567 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
127.0.0.1.8000 > 127.0.0.1.57596: Flags [.], cksum 0xfe28 (incorrect -> 0xec19), seq 1697569321, ack 2046827846, win 6379, options [nop,nop,TS val 678438397 ecr 678438397], length 0
19:41:17.109767 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 63, bad cksum 0 (->3cb7)!)
127.0.0.1.57596 > 127.0.0.1.8000: Flags [P.], cksum 0xfe33 (incorrect -> 0xfb32), seq 2046827846:2046827857, ack 1697569321, win 6379, options [nop,nop,TS val 678438397 ecr 678438397], length 11
19:41:17.109781 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
127.0.0.1.8000 > 127.0.0.1.57596: Flags [.], cksum 0xfe28 (incorrect -> 0xec0e), seq 1697569321, ack 2046827857, win 6379, options [nop,nop,TS val 678438397 ecr 678438397], length 0
19:41:17.109862 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 152, bad cksum 0 (->3c5e)!)
127.0.0.1.8000 > 127.0.0.1.57596: Flags [P.], cksum 0xfe8c (incorrect -> 0xface), seq 1697569321:1697569421, ack 2046827857, win 6379, options [nop,nop,TS val 678438397 ecr 678438397], length 100
19:41:17.109872 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
127.0.0.1.57596 > 127.0.0.1.8000: Flags [.], cksum 0xfe28 (incorrect -> 0xebab), seq 2046827857, ack 1697569421, win 6378, options [nop,nop,TS val 678438397 ecr 678438397], length 0
19:41:27.113831 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
127.0.0.1.57596 > 127.0.0.1.8000: Flags [F.], cksum 0xfe28 (incorrect -> 0xc49f), seq 2046827857, ack 1697569421, win 6378, options [nop,nop,TS val 678448392 ecr 678438397], length 0
19:41:27.113910 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
127.0.0.1.8000 > 127.0.0.1.57596: Flags [.], cksum 0xfe28 (incorrect -> 0x9d93), seq 1697569421, ack 2046827858, win 6379, options [nop,nop,TS val 678448392 ecr 678448392], length 0
19:41:27.114089 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
127.0.0.1.8000 > 127.0.0.1.57596: Flags [F.], cksum 0xfe28 (incorrect -> 0x9d92), seq 1697569421, ack 2046827858, win 6379, options [nop,nop,TS val 678448392 ecr 678448392], length 0
19:41:27.114187 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 52, bad cksum 0 (->3cc2)!)
127.0.0.1.57596 > 127.0.0.1.8000: Flags [.], cksum 0xfe28 (incorrect -> 0x9d93), seq 2046827858, ack 1697569422, win 6378, options [nop,nop,TS val 678448392 ecr 678448392], length 0
我们重点关注内容 Flags []
,其中 [S]
代表 SYN 包,[F]
代表 FIN,[.]
代表对应的 ACK 包。例如 [S.]
代表 SYN-ACK,[F.]
代表 FIN-ACK。可以很明显看出 TCP 通信的全过程如下图所示。
本文简单介绍了 Go 同步编程模式的网络模型。有了 runtime 中网络轮训器与调度器的参与,使用 Go 进行高性能网络编程,高手与菜鸟开发者的差距被极大地缩小。
Go 原生的 net 库对 socket 编程进行了很好地封装,它提供的函数方法语义明朗,逻辑清晰。基于同步编程模式,每个人都可以很容易地进行 TCP 网络编程。利用 tcpdump 工具,我们能够进行网络分析和问题排查,建议实操掌握。
参考
https://tonybai.com/2015/11/17/tcp-programming-in-golang/
https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-netpoller/
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8