服务端与客户端间明显死锁问题:测试函数交互阻塞排查
解决Go测试中服务端客户端交互的死锁问题
这种场景我之前踩过好几次坑!本质上就是没处理好连接的读写阻塞、测试goroutine的同步,以及连接生命周期导致的。咱们一步步拆解问题,再给你靠谱的修复方案:
先揪出常见的死锁诱因
你的代码大概率踩了这些坑之一:
- 客户端发完消息没关写端:服务端用
io.ReadAll或者循环读的时候,会一直等更多数据,永远收不到EOF,根本触发不了回写逻辑。 - 客户端等回复时无限阻塞:如果服务端因为读阻塞没回传,客户端的
Read就会一直卡着,再加上没设超时,测试就直接僵住了。 - 服务端没正确处理连接关闭:比如服务端回写完没关连接,客户端的
io.ReadAll会一直等新数据,永远停不下来。 - 测试主goroutine没同步好:要是客户端跑在goroutine里,主goroutine没等它就结束了?不过你说测试无法完成,应该是主goroutine在等客户端,但客户端卡在读写上。
修复方案(附错误→正确代码对比)
我先写个典型的错误示例,再改成能正常跑的版本,你对照着看自己的代码哪里出问题:
错误示例(必死锁)
func TestEcho(t *testing.T) { // 启动服务端 ln, err := net.Listen("tcp", ":8080") if err != nil { t.Fatal(err) } defer ln.Close() go func() { conn, err := ln.Accept() if err != nil { return } defer conn.Close() // 这里会一直等,因为客户端没关写端,永远读不到EOF buf, _ := io.ReadAll(conn) // 永远执行不到这一步 conn.Write(buf) }() // 客户端 conn, err := net.Dial("tcp", ":8080") if err != nil { t.Fatal(err) } defer conn.Close() // 发消息 conn.Write([]byte("hello")) // 这里也会一直等,因为服务端没回写 buf, _ := io.ReadAll(conn) if string(buf) != "hello" { t.Error("echo mismatch") } }
这个代码的死锁链很清晰:客户端发完消息没关写端→服务端读不到EOF,卡在读操作→服务端不回写→客户端卡在读回复,测试直接僵住。
正确修复版本
import ( "errors" "io" "net" "testing" "time" ) func TestEcho(t *testing.T) { // 给测试加个超时兜底,哪怕出问题也不会一直卡着 t.SetTimeout(5 * time.Second) // 支持并行测试,更灵活 t.Parallel() // 用随机端口,避免测试时端口被占用的坑 ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("启动服务端失败: %v", err) } defer ln.Close() // 服务端开goroutine处理连接,避免阻塞 go func() { for { conn, err := ln.Accept() if err != nil { // 测试结束时ln.Close()会触发Accept错误,直接退出循环 return } // 每个连接单独开goroutine,支持同时处理多个请求 go func(c net.Conn) { defer c.Close() // 用io.Copy自动处理读写,读完就回写,遇到EOF就关闭 _, err := io.Copy(c, c) if err != nil && !errors.Is(err, io.EOF) { t.Logf("回写消息出错: %v", err) } }(conn) } }() // 客户端逻辑 conn, err := net.Dial("tcp", ln.Addr().String()) if err != nil { t.Fatalf("连接服务端失败: %v", err) } defer conn.Close() // 发送测试消息 testMsg := []byte("hello go echo") if _, err := conn.Write(testMsg); err != nil { t.Fatalf("发送消息失败: %v", err) } // 关键!发送完关闭写端,告诉服务端"我写完了,没有更多数据了" if err := conn.CloseWrite(); err != nil { t.Fatalf("关闭写端失败: %v", err) } // 读取服务端的回写消息 respMsg, err := io.ReadAll(conn) if err != nil { t.Fatalf("读取回复失败: %v", err) } // 验证消息一致性 if string(respMsg) != string(testMsg) { t.Errorf("消息不一致: 收到 %q, 预期 %q", respMsg, testMsg) } }
几个关键修复点
- 加测试超时:用
t.SetTimeout()给测试加个兜底时间,哪怕出问题也不会一直卡着CI或者本地测试。 - 客户端发完关写端:
conn.CloseWrite()是核心!它会向服务端发送EOF,服务端的io.Copy读到EOF后就会结束回写,然后关闭连接,客户端就能读到回复的EOF,结束io.ReadAll。 - 服务端用goroutine处理连接:不然单个连接会阻塞服务端,而且测试结束时关闭监听,服务端的
Accept会返回错误,自动退出循环,不会残留goroutine。 - 用随机端口:避免测试时端口被其他进程占用,让测试更稳定。
如果你的代码是手动读写而不是用io.Copy,那一定要注意在读完消息后主动回写,然后关闭连接;客户端读完回复后也要确认连接状态,别一直等。另外,如果客户端是在单独的goroutine里跑的,记得用sync.WaitGroup让主goroutine等它完成,不然主goroutine提前结束也会导致测试异常。
内容的提问来源于stack exchange,提问作者jeanluc
相关产品推荐
相关产品推荐

