Go 程序中线程数异常增长的原因与排查指南

go 程序内存持续上涨且线程数达上百,通常并非因 goroutine 泛滥,而是底层阻塞系统调用(如日志写入、文件 i/o、cgo 调用等)触发 go 运行时创建新 os 线程,导致线程泄漏和内存累积。

在 Go 中,“大量 goroutine” ≠ “大量 OS 线程”。Go 运行时通过 M:N 调度模型(M 个 OS 线程调度 N 个 goroutine)实现高并发,正常情况下活跃线程数由 GOMAXPROCS 控制(默认为 CPU 核心数),远低于 goroutine 数量。你观察到 Threads: 177 且内存长期不释放,说明存在 非预期的 OS 线程驻留,根源在于阻塞式系统调用未及时返回,导致运行时无法复用线程。

? 关键机制:什么情况下 Go 会创建新线程?

当一个 goroutine 执行以下操作时,运行时会将其从当前 M(OS 线程)上解绑,并可能新建线程:

  • 阻塞型系统调用(如 read()/write() 到慢速设备、open()、stat() 等);
  • 使用 cgo 调用 C 函数(尤其含阻塞逻辑);
  • 调用 runtime.LockOSThread() 显式绑定线程(你的代码未使用,可排除);
  • 某些第三方库内部封装的阻塞 I/O(如日志库、数据库驱动、加密库等)。

⚠️ 注意:标准库的 net.Conn 操作(如 conn.Read()/Write())是非阻塞的——它们基于 epoll/kqueue/io_uring 实现异步 I/O,不会导致线程增长。因此 handleClient 及 Session.handleRecv 中的网络读写本身不是元凶。

? 你的代码中最可疑的线程来源:日志写入

你使用了自定义日志模块 sanguo/base/log,并启用了文件写入:

filew := log.NewFileWriter("log", true)
err := filew.StartLogger() // 启动日志协程(极可能含阻塞 I/O)

若该日志器采用同步写文件(如直接 os.File.Write() + fsync()),尤其在磁盘负载高或 NFS 挂载时,每次写入都可能触发阻塞系统调用。Go 运行时为保障其他 goroutine 不被卡住,会分配新线程执行该阻塞调用。若日志高频且写入缓慢,线程将持续累积,且因未显式关闭,这些线程不会自动回收。

其他潜在风险点:

  • tcpkeepalive.EnableKeepAlive() 底层调用 setsockopt(),虽为轻量系统调用,但若其内部有锁竞争或错误路径,也可能间接引发线程行为;
  • json.Marshal() 本身无阻塞,但若 SendDirectly 中 sess.conn.Write() 因 TCP 窗口满而阻塞(罕见),理论上也可能触发线程切换(不过 net.Conn 默认非阻塞,实际概率极低)。

✅ 排查与修复方案

1. 验证线程归属

运行时检查线程状态:

# 查看进程所有线程的栈信息(需安装 delve 或使用 go tool pprof)
go tool pprof -threads http://localhost:6060/debug/pprof/threadcreate
# 或直接查看线程堆栈(Linux)
sudo cat /proc/$(pidof your_program)/stack | grep -A 5 -B 5 "sys"

重点关注栈中是否频繁出现 write, fsync, openat, epoll_wait(正常)或 futex, nanosleep(可疑阻塞)。

2. 替换/优化日志组件

  • 首选:改用异步日志库
    如 zap(带缓冲队列)或 logrus + hook 异步写入。
  • 次选:强制日志异步化
    将 filew.StartLogger() 改为启动 goroutine + channel 缓冲:
    logChan := make(chan string, 1000)
    go func() {
        for msg := range logChan {
            // 同步写文件,但由单一线程承担
            os.WriteFile("log.txt", []byte(msg+"\n"), 0644)
        }
    }()
    // 日志调用改为:logChan <- fmt.Sprintf("[DEBUG] %s", msg)

3. 设置线程上限(临时缓解)

通过环境变量限制最大 OS 线程数(防失控):

export GODEBUG="schedtrace=1000"  # 每秒打印调度器状态(调试用)
export GOMAXPROCS=4               # 严格限制 P 数(影响并发吞吐,慎用)
# 注:Go 无直接 GOMAXTHREADS,但可通过 runtime.LockOSThread() + 池管理模拟

4. 补充资源清理

确保连接关闭时释放所有资源:

func (sess *Session) Close() {
    sess.lock.Lock()
    if sess.ok {
        sess.ok = false
        close(sess.closeNotiChan)
        // ⚠️ 补充:关闭 recvChan 避免 goroutine 泄漏
        close(sess.recvChan)
        sess.conn.Close()
    }
    sess.lock.Unlock()
}

并在 handleDispatch 的 for 循环中处理 recvChan 关闭:

case msg, ok := <-sess.recvChan:
    if !ok { return } // chan closed
    log.Debug("msg", msg)
    sess.SendDirectly("helloworld", 1)

? 总结

  • Go 线程暴涨 ≠ goroutine 写错,而是阻塞系统调用未收敛所致;
  • 你的案例中,同步文件日志是最可能的罪魁祸首
  • 修复核心:将阻塞 I/O 移至专用 goroutine + 缓冲队列,或切换成熟异步日志库
  • 始终通过 pprof 和 /proc/PID/stack 验证线程行为,而非仅依赖猜测。

遵循以上方案,线程数将稳定在 GOMAXPROCS 附近,内存占用回归合理水平。