
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 附近,内存占用回归合理水平。










