[Go] 如何妥善处理 TCP 代理中连接的关闭

如何妥善处理 TCP 代理中连接的关闭

相比较于直接关闭 TCP 连接,只关闭 TCP 连接读写使用单工连接的场景较少,但通用的 TCP 代理也需要考虑这部分场景。

背景

今天在看老代码的时候,发现一个 TCP 代理的核心函数实现的比较粗糙,收到 EOF 后直接粗暴关闭两条 TCP 连接。

go 复制代码
func ConnCat(uConn, rConn net.Conn) {
	wg := sync.WaitGroup{}
	wg.Add(2)

	go func() {
		defer wg.Done()
		io.Copy(uConn, rConn)
		uConn.Close()
		rConn.Close()
	}()

	go func() {
		defer wg.Done()
		io.Copy(rConn, uConn)
		uConn.Close()
		rConn.Close()
	}()

	wg.Wait()
}

一般场景下是感知不到问题的,但是做为一个代理,应该只透传客户端/服务端的行为,多余的动作不应该发生,比如客户端关闭写,代理只需要把关闭传递给服务端即可。

连接关闭

调用 close 关闭连接是通用做法,相关的还有一个 shutdown 系统调用。shutdownclose 相比可以更精细的控制连接的读写,但是不负责 fd 资源的释放,换而言之,无论是否调用 shutdownclose 最后都是需要调用的。

对于 shutdown 第二参数的说明

  • SHUT_RD 连接关闭读,仍然可以继续写。
  • SHUT_WR 连接关闭写,仍然可以继续读;并且会发送一个 FIN 包。
  • SHUT_RDWR 连接读写都被关闭;并且会发送一个 FIN 包。

对于上层应用而言,只需要关注 read 的结果,收到 FIN (也就是 EOF)虽然不能判断对端是关闭读写还是只关闭写,但后续处理并不会受影响。

根据读取数据来处理后续逻辑

  1. 判断已读数据是否符合预期来决定 关闭连接 或者 写入数据
  2. 向连接写入数据,失败的话直接关闭连接即可,不失败的话当前连接则为单工模式
  3. 关闭连接

Go 中连接关闭读写的示例

测试代码展示两个 TCP 连接分别关闭读(写)再进行写(读)

go 复制代码
func TestTCPClose(t *testing.T) {
	lis, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345})
	if err != nil {
		t.Fatal(err)
	}

	var (
		conn0     *net.TCPConn
		conn1     *net.TCPConn
		acceptErr error
	)

	acceptDoneCh := make(chan struct{})
	go func() {
		conn0, acceptErr = lis.AcceptTCP()
		close(acceptDoneCh)
	}()

	conn1, err = net.DialTCP("tcp", nil, lis.Addr().(*net.TCPAddr))
	if err != nil {
		t.Fatal(err)
	}
	<-acceptDoneCh
	if acceptErr != nil {
		t.Fatal(acceptErr)
	}

	wg := sync.WaitGroup{}
	wg.Add(2)

	go func() {
		conn1.Write([]byte("hello"))
		time.Sleep(time.Second * 1)
		conn1.CloseWrite()
		b := make([]byte, 1024)
		conn1.Read(b)
		wg.Done()
	}()

	go func() {
		b := make([]byte, 1024)
		conn0.Read(b)
		conn0.CloseRead()
		time.Sleep(time.Second * 2)
		conn0.Write([]byte("test"))
		wg.Done()
	}()

	wg.Wait()
	conn0.Close()
	conn1.Close()
}

通过 tcpdump 抓包也可以看到 CloseWrite 会发送一个 FIN

txt 复制代码
17:21:09.877056 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [S], seq 4257116181, win 65495, options [mss 65495,sackOK,TS val 3165750919 ecr 0,nop,wscale 7], length 0
17:21:09.877069 IP 127.0.0.1.12345 > 127.0.0.1.44158: Flags [S.], seq 188514168, ack 4257116182, win 65483, options [mss 65495,sackOK,TS val 3165750919 ecr 3165750919,nop,wscale 7], length 0
17:21:09.877081 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [.], ack 1, win 512, options [nop,nop,TS val 3165750919 ecr 3165750919], length 0
17:21:09.877211 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [P.], seq 1:6, ack 1, win 512, options [nop,nop,TS val 3165750920 ecr 3165750919], length 5
17:21:09.877219 IP 127.0.0.1.12345 > 127.0.0.1.44158: Flags [.], ack 6, win 512, options [nop,nop,TS val 3165750920 ecr 3165750920], length 0
17:21:10.878149 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [F.], seq 6, ack 1, win 512, options [nop,nop,TS val 3165751920 ecr 3165750920], length 0
17:21:10.920263 IP 127.0.0.1.12345 > 127.0.0.1.44158: Flags [.], ack 7, win 512, options [nop,nop,TS val 3165751963 ecr 3165751920], length 0
17:21:11.877430 IP 127.0.0.1.12345 > 127.0.0.1.44158: Flags [P.], seq 1:5, ack 7, win 512, options [nop,nop,TS val 3165752920 ecr 3165751920], length 4
17:21:11.877460 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [.], ack 5, win 512, options [nop,nop,TS val 3165752920 ecr 3165752920], length 0
17:21:11.882928 IP 127.0.0.1.12345 > 127.0.0.1.44158: Flags [F.], seq 5, ack 7, win 512, options [nop,nop,TS val 3165752925 ecr 3165752920], length 0
17:21:11.882957 IP 127.0.0.1.44158 > 127.0.0.1.12345: Flags [.], ack 6, win 512, options [nop,nop,TS val 3165752925 ecr 3165752925], length 0

分析

一个完整建立的 TCP 连接图如下,每条线代表一条单工连接。

txt 复制代码
┌────────┐  R              W  ┌────────┐  R              W  ┌────────┐
│        │  ◄───────────────  │        │  ◄───────────────  │        │
│ Client │       UConn        │ Proxy  │        RConn       │ Server │
│        |  ───────────────►  │        │  ───────────────►  │        │
└────────┘  W              R  └────────┘  W              R  └────────┘

对于 Proxy 而言,需要将一条连接的包传递至另外一条连接,收到数据包则进行转发,读取到 EOF 则关闭另一条连接的写(也可以关闭本连接的读,多调用一次系统调用)

整个关闭的流程由 Client(Server 同样适用) 发起,是一个击鼓传花的过程:

  1. Client 关闭 UConn 连接的写端(或读端,后续数据写入报错则进入错误处理)
  2. Proxy 收到 UConn 的 EOF,关闭 RConn 连接的写端
  3. Server 收到 RConn 的 EOF,关闭 RConn 连接的写端
  4. Proxy 收到 RConn 的 EOF,关闭 UConn 连接的写端
  5. 所有单工连接被关闭,连接代理完成

核心实现

直接拿 docker-proxy 的实现修改一下,额外支持了主动退出的逻辑。

  • from.CloseRead() 这行代码可以不需要,已经 EOF,这条连接不会再出现数据了。
  • 读取或者写入失败的场景全部包含在 io.Copy 中,并且忽略了错误处理,尽可能减小两个代理过程的相互影响。
go 复制代码
func ConnCat(ctx context.Context, client *net.TCPConn, backend *net.TCPConn) {
	var wg sync.WaitGroup

	broker := func(to, from *net.TCPConn) {
		io.Copy(to, from)
		from.CloseRead()
		to.CloseWrite()
		wg.Done()
	}

	wg.Add(2)
	go broker(client, backend)
	go broker(backend, client)

	finish := make(chan struct{})
	go func() {
		wg.Wait()
		close(finish)
	}()

	select {
	case <-ctx.Done():
	case <-finish:
	}
	client.Close()
	backend.Close()
	<-finish
}

参考

  1. https://github.com/moby/moby/blob/master/cmd/docker-proxy/tcp_proxy_linux.go#L27, docker-proxy 的 tcp 代理实现
相关推荐
梦想很大很大2 小时前
使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)
前端·后端·go
lekami_兰7 小时前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘11 小时前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤11 小时前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt111 天前
AI DDD重构实践
go
Grassto2 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto4 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室5 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题5 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo
啊汉7 天前
古文观芷App搜索方案深度解析:打造极致性能的古文搜索引擎
go·软件随想