整理了5个Goroutine泄漏的Code Review检查点
上周线上服务内存暴涨,排查了3小时发现是Goroutine泄漏。后来定位到是一个channel没关闭,导致goroutine一直阻塞。Code Review时如果能提前发现这些问题,能省去很多半夜起床排查的麻烦。整理了5个必查点,建议收藏对照。
检查点1:Channel是否关闭
问题:向已关闭的channel发送数据会panic,但没关闭的channel会导致goroutine永远阻塞在接收端。
// ❌ 问题代码
func process(ch chan int) {
go func() {
for val := range ch {
fmt.Println(val)
}
fmt.Println("goroutine退出") // 这行永远不会执行
}()
// 函数退出了,但ch没关闭,上面的goroutine永远阻塞
}
修复:确保发送方关闭channel,或者使用context控制生命周期。
// ✅ 正确写法
func process(ctx context.Context, ch chan int) {
go func() {
defer fmt.Println("goroutine退出")
for {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
return // 收到取消信号,优雅退出
}
}
}()
}
检查口诀:range ch 的地方,ch谁来关闭?
检查点2:WaitGroup是否Done
问题:wg.Add() 和 wg.Done() 数量不匹配,导致 wg.Wait() 永远阻塞。
// ❌ 问题代码
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
if shouldSkip() {
return // 这里直接return,wg.Done()没执行!
}
doWork()
wg.Done()
}()
}
wg.Wait() // 永远阻塞在这里
修复:defer wg.Done() 放在函数开头,确保任何退出路径都会执行。
// ✅ 正确写法
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done() // 放这里,任何return都会执行
if shouldSkip() {
return
}
doWork()
}()
}
wg.Wait()
检查口诀:defer wg.Done() 是否紧跟在 wg.Add() 后面?
检查点3:无限循环是否有退出条件
问题:后台任务用了裸的 for {},没有监听取消信号,goroutine跑到程序结束。
// ❌ 问题代码
func startBackgroundTask() {
go func() {
for {
doSomething()
time.Sleep(time.Second)
}
// 没有退出条件,这个goroutine永远跑下去
}()
}
修复:用 select 监听 ctx.Done(),收到取消信号立即退出。
// ✅ 正确写法
func startBackgroundTask(ctx context.Context) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
fmt.Println("后台任务收到取消信号,退出")
return
case <-ticker.C:
doSomething()
}
}
}()
}
检查口诀:for 循环里有没有 select { case <-ctx.Done() }?
检查点4:HTTP请求是否设置了Timeout
问题:http.Client 默认没有超时,如果服务端不响应,连接会一直挂着。
// ❌ 问题代码
client := &http.Client{}
resp, err := client.Get("https://api.example.com/data")
// 默认无超时,如果api.example.com不响应,这里永远等下去
修复:必须设置 Timeout,包括连接超时和读取超时。
// ✅ 正确写法
client := &http.Client{
Timeout: 10 * time.Second, // 总超时
}
// 或者更精细的控制
client := &http.Client{
Transport: &http.Transport{
DialTimeout: 5 * time.Second, // 连接超时
ReadTimeout: 5 * time.Second, // 读取超时
},
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://api.example.com/data")
检查口诀:每个 http.Client 有没有 Timeout 字段?
检查点5:数据库连接是否归还
问题:sql.Rows、sql.Tx 或 sql.Stmt 忘记 Close,连接池被打满,新请求拿不到连接。
// ❌ 问题代码
func getUsers(db *sql.DB) error {
rows, err := db.Query("SELECT * FROM users")
if err != nil {
return err
}
// 忘记 rows.Close(),这个连接一直被占用
for rows.Next() {
// 处理数据
}
return nil
}
修复:defer rows.Close() 紧跟在错误检查后面。
// ✅ 正确写法
func getUsers(db *sql.DB) error {
rows, err := db.Query("SELECT * FROM users")
if err != nil {
return err
}
defer rows.Close() // 确保归还连接
for rows.Next() {
// 处理数据
}
return rows.Err() // 别忘了检查遍历错误
}
检查口诀:数据库操作后有没有 defer rows.Close()?
总结
这5个检查点覆盖了80%的Goroutine泄漏场景:
| 检查点 | 关键词 |
|---|---|
| Channel | 谁来关闭? |
| WaitGroup | defer Done? |
| 无限循环 | ctx.Done()? |
| HTTP请求 | Timeout? |
| 数据库连接 | defer Close()? |
建议保存下来,Code Review时对着看。如果你曾经因为漏掉某个检查点导致线上问题,欢迎在评论区分享,让其他人也避避坑。
参考:
- [Go官方博客:Pprof分析goroutine泄漏] https://go.dev/blog/pprof
- [Go Concurrency Patterns: Context] https://go.dev/blog/context