Go goroutine 泄漏:跑了 47 天 OOMKilled 后我们找到的 5 个真实场景

Go 服务跑了 47 天内存涨到 6.2GB,OOMKilled。本文复盘 goroutine 泄漏的 5 个真实场景:channel 写没人读、for-select 缺退出分支、HTTP body 没读完、time.After 滥用、WaitGroup 卡死。每个场景附最小复现 + 修法 + pprof 排查命令。

一个 Go 服务跑了 47 天,内存从 800MB 涨到 6.2GB,K8s OOMKilled。重启后又开始稳定地往上爬,大约每天 +100MB。这种症状只有一个解释:goroutine 泄漏。本文把我们这两年遇到的 5 个真实泄漏场景写下来,每个都附最小复现代码和修法。

泄漏的本质

Go 的 goroutine 不是 OS 线程,每个起步 2KB 栈,可以并发跑几百万个。但「轻量」不等于「免费」—— 每个活着的 goroutine 都至少占 2-8KB 内存,持有它引用的所有对象。如果一个 goroutine 启动后永远不会退出,就成了泄漏。

怎么判断有泄漏?用 pprof:

// 在 main 里加这一行,启动 6060 端口的 pprof
import _ "net/http/pprof"
import "net/http"

func main() {
    go func() {
        _ = http.ListenAndServe(":6060", nil)
    }()
    // 你的业务代码...
}
# 抓 goroutine 快照
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2 > goroutines.txt

# 看总数
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | head -3
# goroutine profile: total 38421     ← 38000+ 就明显泄漏了

# 按调用栈分组
go tool pprof -top http://localhost:6060/debug/pprof/goroutine
# Showing nodes accounting for 38241, 99.5% of 38421 total
#   flat  flat%   sum%    cum    cum%
#  35921 93.5%  93.5%  35921 93.5%  example.com/svc.fanoutWorker
#   1893  4.9%  98.4%   1893  4.9%  example.com/svc.kafkaConsumer.poll
#    ...

看哪个调用栈占比最高,基本就锁定泄漏点。下面 5 个场景按出现频率排序。

场景 1:channel 写没人读(最常见)

真实代码,简化版:

// 一个发邮件的辅助函数,异步发,不阻塞主流程
func SendEmailAsync(to, subject, body string) {
    resultCh := make(chan error, 1)   // 错!没人读的 channel

    go func() {
        err := smtpClient.Send(to, subject, body)
        resultCh <- err              // 写完没人读,但因为是 buffered=1 不会卡死
        // 但 goroutine 不会退出,因为函数没结束
        // 等等,这看起来会退出?...
    }()

    // 主流程立刻返回,resultCh 没人读
    // 但 goroutine 因为已经写入并完成,会正常退出
    // 看起来没事?
}

这段代码表面没事,但实际是另一种泄漏模式 —— resultCh 这个 channel 占着内存,GC 不回收(它没被显式 close 也没人持有写引用)。如果 SendEmailAsync 调用频繁,内存慢慢涨。

更隐蔽的泄漏是这种:

// 真实场景:fan-out 模式,聚合多个下游响应
func aggregateAll(ctx context.Context, urls []string) ([]string, error) {
    resultCh := make(chan string)      // 无缓冲

    for _, url := range urls {
        go func(u string) {
            data := fetchSlow(u)        // 可能很慢
            resultCh <- data           // 如果没人读,goroutine 永久阻塞!
        }(url)
    }

    results := make([]string, 0, len(urls))
    timeout := time.After(2 * time.Second)
    for i := 0; i < len(urls); i++ {
        select {
        case r := <-resultCh:
            results = append(results, r)
        case <-timeout:
            return results, errors.New("timeout")   // 直接返回,后续 fetchSlow 的 goroutine 全部卡在写 channel
        }
    }
    return results, nil
}

第 8 行起的 goroutine 在写 resultCh,但当 timeout 触发 return 后,主函数不再读 channel。还没完成 fetchSlow 的 goroutine 会卡在 resultCh <- data 永远阻塞 —— 这才是经典的 goroutine 泄漏。

修法 1:用 buffered channel,确保写不会阻塞:

resultCh := make(chan string, len(urls))   // 缓冲 = urls 长度
// 即使主流程不读,goroutine 也能完成写入并退出

修法 2:goroutine 监听 context,主流程超时时 cancel:

func aggregateAll(parent context.Context, urls []string) ([]string, error) {
    ctx, cancel := context.WithTimeout(parent, 2*time.Second)
    defer cancel()                       // 关键:函数退出时取消所有 goroutine

    resultCh := make(chan string, len(urls))
    for _, url := range urls {
        go func(u string) {
            // 给 fetch 也传 ctx,这样 HTTP 请求会被取消
            data, err := fetchWithCtx(ctx, u)
            if err != nil {
                return                   // ctx 取消时 fetchWithCtx 立刻返回
            }
            select {
            case resultCh <- data:
            case <-ctx.Done():
                return                   // 防止 buffered 满了还阻塞
            }
        }(url)
    }

    results := make([]string, 0, len(urls))
    for i := 0; i < len(urls); i++ {
        select {
        case r := <-resultCh:
            results = append(results, r)
        case <-ctx.Done():
            return results, ctx.Err()
        }
    }
    return results, nil
}

场景 2:for-select 缺少退出分支

第二常见的泄漏:有一个 worker 监听 channel 处理任务,但永远没人告诉它什么时候停。

// 错:worker 永远不退出
func StartWorker(jobCh <-chan Job) {
    go func() {
        for {
            select {
            case j := <-jobCh:
                process(j)
            // 缺少 ctx.Done() 分支,这个 goroutine 永生
            }
        }
    }()
}

// 对:用 context 控制生命周期
func StartWorker(ctx context.Context, jobCh <-chan Job) {
    go func() {
        for {
            select {
            case j, ok := <-jobCh:
                if !ok {
                    return           // channel 关了就退
                }
                process(j)
            case <-ctx.Done():
                return               // ctx 取消就退
            }
        }
    }()
}

修复策略很简单:每个长跑 goroutine 都要有明确的退出条件 —— context 取消或 channel close,两条路径。

场景 3:HTTP body 没读完没 Close

这个泄漏不是 goroutine 直接泄漏,而是 HTTP 连接没释放,间接导致 transport 的 goroutine 增多。

// 错:resp.Body 没读完就只 Close
resp, err := http.Get(url)
if err != nil { return err }
if resp.StatusCode != 200 {
    resp.Body.Close()    // 关了,但 body 里还有 byte 没读
    return errors.New("bad status")
}
// 错:仅判断了 status,实际响应 body 没读完
// 连接不能复用,会留在 transport 的等待队列里

// 对:不管什么情况,都把 body 读完再 close
resp, err := http.Get(url)
if err != nil { return err }
defer func() {
    io.Copy(io.Discard, resp.Body)      // 读完丢弃
    resp.Body.Close()
}()
if resp.StatusCode != 200 {
    return errors.New("bad status")
}

Go 的 http.Transport 在 body 没读完时无法复用连接(它不知道还有多少字节没拿走)。每次发请求都新建连接,每个连接对应几个内部 goroutine。久了 goroutine 数飙升,内存涨上来。

场景 4:time.After 在 select 里(被坑过两次)

// 错:每次循环都创建新的 timer,旧的不会立即释放
for {
    select {
    case msg := <-msgCh:
        process(msg)
    case <-time.After(5 * time.Second):
        log.Warn("idle 5s")
    }
}

time.After 返回一个 channel,内部启动一个 timer goroutine。如果 msgCh 高频有消息,每次循环都创建新的 timer,旧 timer 直到 5 秒后才能被 GC。高 QPS 下短时间内积累大量 timer goroutine。

// 对:复用一个 timer
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
    select {
    case msg := <-msgCh:
        process(msg)
        if !timer.Stop() {
            <-timer.C
        }
        timer.Reset(5 * time.Second)
    case <-timer.C:
        log.Warn("idle 5s")
        timer.Reset(5 * time.Second)
    }
}

Go 1.23 之前这是必备技巧。1.23 起 time.After 被 GC 友好化了,这个坑减小,但养成复用 timer 的习惯不亏。

场景 5:WaitGroup.Wait 卡死

典型场景:启动一组 goroutine,某个 goroutine panic 没 recover,Add/Done 不匹配,主流程 Wait 永远不返回。

// 错:goroutine panic 时 Done 不会被调用
var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)
    go func(it Item) {
        defer wg.Done()
        process(it)        // 如果这里 panic,defer 会执行 Done,看起来没事
        // 但如果是 goroutine 没起来呢?
    }(item)
}
wg.Wait()                   // 大多数情况能等到

// 真实坑:Add 和 go 之间被取消
ctx, cancel := context.WithTimeout(ctx, 1*time.Second)
defer cancel()

var wg sync.WaitGroup
for _, item := range items {
    select {
    case <-ctx.Done():
        return ctx.Err()    // 注意:可能 wg.Add(1) 已经调用但 go 没起来!
    default:
    }
    wg.Add(1)
    go func(it Item) {
        defer wg.Done()
        // 业务逻辑
    }(item)
}
wg.Wait()

修复办法 1:把 Add 放在 goroutine 内部之外的同一处:

// 用 errgroup 替代:它自带 panic recover 和取消传播
import "golang.org/x/sync/errgroup"

func processAll(ctx context.Context, items []Item) error {
    g, gctx := errgroup.WithContext(ctx)
    g.SetLimit(10)                          // 限制并发数
    for _, it := range items {
        it := it                            // 1.22 之前要这一行,避免闭包共享变量
        g.Go(func() error {
            select {
            case <-gctx.Done():
                return gctx.Err()
            default:
                return process(gctx, it)
            }
        })
    }
    return g.Wait()
}

errgroupsync.WaitGroup 在大多数场景下更好用,它自带:

  • panic recover(避免 goroutine 异常退出导致 WaitGroup 卡住)
  • 第一个错误就取消所有子 goroutine
  • 并发数限制(SetLimit)

排查 goroutine 泄漏的 5 步法

  1. 打开 pprof:import _ "net/http/pprof"
  2. 记录基线:服务启动后跑 5 分钟,curl /debug/pprof/goroutine?debug=1 看正常值(通常 50-200)
  3. 对比快照:1 小时后再抓一次,看哪个调用栈数量翻了
  4. 找最大的几栈:go tool pprof -top 看 top 5
  5. 读代码找根因:大多是 channel 写没人读,或 for-select 缺退出
# 一键脚本
HOST=localhost:6060
echo "=== Goroutine count ==="
curl -s http://$HOST/debug/pprof/goroutine?debug=1 | head -1

echo "=== Top 10 stacks ==="
go tool pprof -top -nodecount=10 http://$HOST/debug/pprof/goroutine 2>/dev/null

echo "=== Heap top 10 ==="
go tool pprof -top -nodecount=10 http://$HOST/debug/pprof/heap 2>/dev/null

echo "=== Blocked goroutines (looking for 'goroutine X [chan send' or '[select') ==="
curl -s http://$HOST/debug/pprof/goroutine?debug=2 | grep -E '^goroutine.*\[(chan|select)' | head -20

预防比修复更重要

把这 3 条加到团队 Go 代码 review checklist:

  1. 每个 go func() 都问:它什么时候退出? 答不出来的拒绝合并。
  2. 每个 channel 写都问:写不进去会怎样? 是 buffered 不会阻塞,还是有 select default,还是有 ctx.Done?
  3. HTTP / DB / Stream 资源,defer Close 是基础,defer 读完 body 也是基础

这 3 条做到位,我们厂 Go 服务的 goroutine 泄漏案例从「每月 2-3 次」降到「半年遇到 1 次」。最近一次还是因为引入了新的第三方库,库自己有泄漏。

Goroutine 泄漏不像内存泄漏那么显眼,因为单个 goroutine 占用小。但累积到几万、几十万,内存就会以肉眼可见的速度涨,直到某天 OOMKilled。预防它的成本远低于排查它。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

凌晨被叫起来排查 TIME_WAIT 堆 5 万的故事:从端口耗尽到连接池治理

2026-5-19 10:19:00

技术教程

K8s Pod OOMKilled 排查指南:6 种真实原因 + 每种修法

2026-5-19 10:29:27

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索