You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

服务端与客户端间明显死锁问题:测试函数交互阻塞排查

解决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)
    }
}

几个关键修复点

  1. 加测试超时:用t.SetTimeout()给测试加个兜底时间,哪怕出问题也不会一直卡着CI或者本地测试。
  2. 客户端发完关写端:conn.CloseWrite()是核心!它会向服务端发送EOF,服务端的io.Copy读到EOF后就会结束回写,然后关闭连接,客户端就能读到回复的EOF,结束io.ReadAll。
  3. 服务端用goroutine处理连接:不然单个连接会阻塞服务端,而且测试结束时关闭监听,服务端的Accept会返回错误,自动退出循环,不会残留goroutine。
  4. 用随机端口:避免测试时端口被其他进程占用,让测试更稳定。

如果你的代码是手动读写而不是用io.Copy,那一定要注意在读完消息后主动回写,然后关闭连接;客户端读完回复后也要确认连接状态,别一直等。另外,如果客户端是在单独的goroutine里跑的,记得用sync.WaitGroup让主goroutine等它完成,不然主goroutine提前结束也会导致测试异常。

内容的提问来源于stack exchange,提问作者jeanluc

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.21 07:38:40