go 程序内存持续上涨且线程数达上百,通常并非因 goroutine 泛滥,而是底层阻塞系统调用(如日志写入、文件 i/o、cgo 调用等)触发 go 运行时创建新 os 线程,导致线程泄漏和内存累积。
在 Go 中,“大量 goroutine” ≠ “大量 OS 线程”。Go 运行时通过 M:N 调度模型(M 个 OS 线程调度 N 个 goroutine)实现高并发,正常情况下活跃线程数由 GOMAXPROCS 控制(默认为 CPU 核心数),远低于 goroutine 数量。你观察到 Threads: 177 且内存长期不释放,说明存在 非预期的 OS 线程驻留,根源在于阻塞式系统调用未及时返回,导致运行时无法复用线程。
当一个 goroutine 执行以下操作时,运行时会将其从当前 M(OS 线程)上解绑,并可能新建线程:
⚠️ 注意:标准库的 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 不被卡住,会分配新线程执行该阻塞调用。若日志高频且写入缓慢,线程将持续累积,且因未显式关闭,这些线程不会自动回收。
其他潜在风险点:
运行时检查线程状态:
# 查看进程所有线程的栈信息(需安装 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(可疑阻塞)。
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)通过环境变量限制最大 OS 线程数(防失控):
export GODEBUG="schedtrace=1000" # 每秒打印调度器状态(调试用) export GOMAXPROCS=4 # 严格限制 P 数(影响并发吞吐,慎用) # 注:Go 无直接 GOMAXTHREADS,但可通过 runtime.LockOSThread() + 池管理模拟
确保连接关闭时释放所有资源:
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)遵循以上方案,线程数将稳定在 GOMAXPROCS 附近,内存占用回归合理水平。