G

[Golang] Go 1.27的goroutine泄漏检测器(pprof)

RoLingG Golang 2026-09-05

Go 1.27 的goroutine泄漏检测器(pprof)

goroutine 泄漏是 Go 程序最常见也最难发现的 bug 之一:goroutine 卡死在一个永远等不到的 channel 收发上,它和它引用的内存从此无法回收,随时间累积,服务跑久了内存悄悄上涨、连接数飙高,重启就恢复。

1.27 之前唯一的排查手段是抓 /debug/pprof/goroutine,拿到全部 goroutine 的调用栈,人工分辨哪些是泄漏、哪些是设计上的阻塞。在 Go 1.27 新增 了/debug/pprof/goroutineleak,该配置能在运行时自动判定泄漏,直接给出清单,list 能定位到具体泄露的相关代码行。

这篇文章分三部分:检测器更新了什么、怎么做到的,把它用起来需要的 pprof 基本功;最后实测两个来自 Kubernetes 和 etcd 的真实泄漏。所有命令和代码在 Go 1.27.1 + Windows 11 上实际跑过。

原文:Goroutine Leak Profiles

新增的 goroutineleak 泄露检测器

pprof 多了一个 profile 类型 goroutineleak,只报告被判定为泄漏的 goroutine。服务里已有 net/http/pprof 的话,什么都不用做,1.27 起新端点自动可用。

/debug/pprof/goroutine(老)/debug/pprof/goroutineleak(1.27 新)
报告内容所有 goroutine 的栈只报判定为泄漏的
误报高(设计性阻塞也算)几乎为零
判定方式人工看栈、比趋势运行时自动判定

老端点的两个痛点,正是新端点要解决的:

  • 分不清泄漏和设计上的阻塞——微服务流量高峰时,几千个 goroutine 阻塞在 channel 等待被接收。
  • 少量泄漏混在几千条栈里,靠人工 diff 增长趋势才能发现,服务协程多年漏检是常态。

借 GC 做活性归纳

其判断原理来自一个归纳定义的活性(liveness):

一个 goroutine 是"活"的,当且仅当它没有被任何并发原语阻塞,或者它阻塞所依赖的原语,被另一个"活"的 goroutine 引用着。

Go 的 GC 本来就会计算内存可达性,检测器的改动很聪明:把 GC 的标记根从"所有 goroutine"改成"只有未阻塞的 goroutine",然后归纳传播——阻塞在"被活 goroutine 引用的原语"上的,也算活。跑完一轮,没被标活的阻塞 goroutine 就是泄漏。

边界

虽然 goroutineleak 的新增带来了很大的 goroutine 泄漏检测的便利性,但它的限制还是有些多:

  • 只认 Go 并发原语:channel 收发(含 nil channel)、无 default 的 select(包括空 select)、sync.Mutex/RWMutex/WaitGroup/Cond。网络/文件 IO、系统调用这类阻塞不算永远不会被报告
  • 原语一直被全局变量或可运行 goroutine 引用时,阻塞其上的 goroutine 不会被报告,哪怕它将来真的不会再被使用(官方称为 memory overreach)。
  • 只能事后检测,不能预测。生产上还是建议周期性采集,测试期用 goleaktesting/synctest 兜底。
  • 内存开销可忽略,但检测可能比普通 GC 慢(最坏的 "菊花链" 场景是 O(n²)),低频采集是性价比最优解。
对于 "菊花链" 场景,建议还是看一下官方的 blog 文章,有配图,写的很清楚。

pprof 基本使用

检测器长在 pprof 上,要用它得先把 pprof 接进程序。顺手把 pprof 的通用用法过一遍——泄漏检测只是它的一个维度。

接入:三行代码,八个端点

pprof 接入有两种姿势。runtime/pprof 适合一次性程序和 benchmark,在代码里主动 dump 到文件;长期运行的服务用 net/http/pprof,注册常驻 HTTP 端点,生产环境最常用。后者只要三行:

import (
    "net/http"
    _ "net/http/pprof" // ← 匿名导入,副作用是注册所有 /debug/pprof 处理器
)

go func() {
    log.Println(http.ListenAndServe("localhost:6060", nil))
}()

装好之后,这些端点全都有了:

端点Type内容
/debug/pprof/goroutinegoroutine所有 goroutine 的调用栈
/debug/pprof/goroutineleakgoroutineleak只报判定为泄漏的 goroutine(1.27 新增)
/debug/pprof/heapheap存活对象的内存分配
/debug/pprof/allocsallocs历史累计分配
/debug/pprof/profile?seconds=30cpuCPU 采样(默认 30 秒)
/debug/pprof/blockblock阻塞原语上的等待时间
/debug/pprof/mutexmutex锁竞争的持有者
/debug/pprof/threadcreatethreadcreateOS 线程创建栈

服务部署在远端的话,把 localhost:6060 换成域名走网关同样能抓。两个注意:别把 pprof 端口暴露到公网,要么只绑 localhost 要么前面加鉴权——profile 里的函数名和调用关系本身就是信息泄露面;另外刚切 1.27 时编辑器可能出红线但 go build 通过,那多半是 IDE 分析引擎还没跟上新语法,以命令行编译结果为准

Web UI 抓取与查看

pprof 能够自起一个本地 Web UI 界面:

go tool pprof -http=:3001 http://localhost:6060/debug/pprof/heap

浏览器打开 http://localhost:3001Top(占用最多的函数)、Graph(调用图)、Flame Graph(火焰图)都有。分析内存时火焰图尤其直观,每一格的宽度就是内存占用。想离线存文件:

go tool pprof -output=heap.svg http://localhost:6060/debug/pprof/heap
绘制 Graph 和火焰图需要 Graphviz:官网(https://www.graphviz.org/download/)下载,bin 目录加进环境变量,重开终端生效。

交互模式

交互模式适合精确到行:

go tool pprof http://localhost:6060/debug/pprof/goroutineleak

这条命令自己发请求、自己解析,最省事也最跨平台。进去之后常用的就这几个:

  • top 按采样值排序列出最重的函数。
  • list 函数名 把采样值标注到源码行上,定位泄漏最常用。
  • traces 打印所有调用栈原文。
  • web 生成调用图用浏览器打开。

Windows 下用原生 curl 配 -o 落盘:

curl.exe -o leak.prof "http://localhost:6060/debug/pprof/goroutineleak"

进交互模式后开头几行有 File(profile 来自哪个二进制)、Build ID(哪次构建)、Type(profile 类型)、Time(快照时刻),够把这份 profile 和某次构建对上号。

交互模式示例

这里我们拿 goroutineleak 这一新出的 profile 作为示例:

package main

import (
    "errors"
    "log"
    "net/http"
    _ "net/http/pprof"
    "time"
)

type workItem int
type workResult int

func processWorkItem(w workItem) (workResult, error) {
    time.Sleep(10 * time.Millisecond)
    if w == 5 {
        return 0, errors.New("simulated error")
    }
    return workResult(w * 2), nil
}

type result struct {
    res workResult
    err error
}

func processWorkItems(ws []workItem) ([]workResult, error) {
    ch := make(chan result)
    // ch := make(chan result, len(ws))
    for _, w := range ws {
        go func() {
            res, err := processWorkItem(w)
            ch <- result{res, err}
        }()
    }

    var results []workResult
    for range len(ws) {
        r := <-ch
        if r.err != nil {
            return nil, r.err
        }
        results = append(results, r.res)
    }
    return results, nil
}

func main() {
    // Start pprof server
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    // Repeatedly trigger the leak
    for {
        items := []workItem{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
        _, err := processWorkItems(items)
        if err != nil {
            log.Printf("Error processing items: %v", err)
        }

        time.Sleep(time.Second)
    }
}

运行该示例程序时,执行:

curl -o leak.prof "http://localhost:6060/debug/pprof/goroutineleak"

即会从示例程序的 6060 端口获取对应的 .prof 分析文件,接着执行:

go tool pprof leak.prof

File: ___go_build_goroutineLeak_go.exe
Build ID: C:\Users\xxx\AppData\Local\JetBrains\GoLand2026.2\xxx\GoLand\___go_build_goroutineLeak_go.exe2026-09-04 10:21:02.0969552 +0800 CST
Type: goroutineleak
Time: 2026-09-04 10:21:15 CST
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

接着就可以对有协程的函数执行分析了,示例里面是 processWorkItems 开启的协程,我们执行:

File: ___go_build_goroutineLeak_go.exe
Build ID: C:\Users\xxx\AppData\Local\JetBrains\GoLand2026.2\xxx\GoLand\___go_build_goroutineLeak_go.exe2026-09-04 10:21:02.0969552 +0800 CST
Type: goroutineleak
Time: 2026-09-04 10:21:15 CST
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) list processWorkItems
Total: 29
ROUTINE ======================== main.processWorkItems.func1 in D:\GoLand\1.27.1test\goroutineLeak.go
         0         29 (flat, cum)   100% of Total
         .          .     31:           go func() {
         .          .     32:                   res, err := processWorkItem(w)
         .         29     33:                   ch <- result{res, err}
         .          .     34:           }()
         .          .     35:   }
         .          .     36:
         .          .     37:   var results []workResult
         .          .     38:   for range len(ws) {
(pprof)

pprof 就会根据分析文件,读取出 curl 产出 .prof 文件时,对应程序运行时产生 goroutine 泄露的具体代码行了。

我们根据结果可以看出,当时有 29 次泄露,在总进程里 processWorkItems 泄露占了 100%,其 33 行的 ch <- result{res, err} 共占据了 29 次。

泄漏的原因也很简单:

  • ch无缓冲 channel,每个 worker 发送时必须等主 goroutine 同步接收;
  • w == 5 那项返回 error,接收循环 return nil, r.err 提前退出,不再接收;
  • 剩下的 worker 醒来后往 ch 发送,永远等不到接收,卡死。goroutine 和它引用的内存都无法回收,随时间累积。

修复只需一行,将 ch 从无缓冲 channel 改成有缓冲 channel 即可:

ch := make(chan result, len(ws))

缓冲区装下全部结果,即使 processWorkItems 提前返回,所有 worker 也能把结果放进缓冲后正常退出。

根据上面例子,我们虽然不一定能直接从分析结果看出问题所在(下文的实验台示例里面就能感受的出),但对应问题出处的范围该分析直接帮助我们定位到了,这对于工程师修复该类问题提供了很有效的帮助。

什么症状使用什么 profile

日常排查大部分时候就是查表的事:

症状首选 profile看什么
goroutine 数量只涨不跌goroutineleak直接给出泄漏清单,list 到行
内存缓慢上涨,重启恢复heap + goroutineinuse_space 的分配大户;goroutine 栈是否只增不减
API P99 变慢,CPU 却不高mutex + blocksync.(*Mutex).Lock 的等待占比、锁被谁长期持有
CPU 被打满cpu(?seconds=30热点函数——正则回溯、json.Marshal、反射都是常客
偶发"灵异卡顿"traceGoroutine analysis 里的 Network wait / Syscall 耗时

trace 的抓法不同:

curl.exe -o trace.out "http://localhost:6060/debug/pprof/trace?seconds=5"

落盘 trace.out,再 go tool trace trace.out 打开。

pprof 还有 label 机制(runtime/pprof.Do),配合中间件把采样按接口维度打标,单独看某个接口的内存占用——属于用到再查的深水区,这里只提一句,我也没实际用过。


两个真实项目的泄漏 demo

官方博客的 Additional examples 一节列了九个真实世界的泄漏模式,这里取两个出自大型项目真实 issue 的:一个来自 Kubernetes,channel 和锁互锁;一个来自 etcd,发送输给了竞态。每个都是完整独立的程序,分开保存、各自 go run

两个 demo 的 main 里保活那两条注释,是写这种 demo 才会撞见的坑,官方博客没展开讲:select{} 会被检测器报为泄漏没有 GC 活动时泄漏数据不会更新

Kubernetes:channel 和锁的相互阻塞

出自 Kubernetes 的真实 issue,锁和 channel 混用,两个 goroutine 互相等对方让路:

package main

import (
    "log"
    "net/http"
    _ "net/http/pprof"
    "sync"
    "time"
)

type Connection struct {
    closeChan chan bool
}

type idleAwareFramer struct {
    resetChan chan bool
    writeLock sync.Mutex
    conn      *Connection
}

func (i *idleAwareFramer) monitor() {
    var resetChan = i.resetChan
    for range i.conn.closeChan {
        i.writeLock.Lock() // ← 若 WriteFrame 正持锁卡在发送上,这里永远等不到
        close(resetChan)
        i.resetChan = nil
        i.writeLock.Unlock()
        break
    }
}

func (i *idleAwareFramer) WriteFrame() {
    i.writeLock.Lock()
    defer i.writeLock.Unlock()
    if i.resetChan == nil {
        return
    }
    i.resetChan <- true // ← 没有任何接收者 → 永远阻塞,且锁不释放
}

func NewIdleAwareFramer() *idleAwareFramer {
    return &idleAwareFramer{
        resetChan: make(chan bool),
        conn: &Connection{
            closeChan: make(chan bool),
        },
    }
}

func Kubernetes6632() {
    i := NewIdleAwareFramer()

    go func() {
        i.conn.closeChan <- true // 触发关闭信号
    }()
    go i.monitor()
    go i.WriteFrame()
}

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    // 循环放大命中率:demo 是竞态命中才泄漏,单跑一次未必中
    for range 100 {
        Kubernetes6632()
    }

    // 保持进程存活,写法有两个讲究:
    // 1) 不能用 select{} 保活——空 select 本身就是检测器覆盖的阻塞原语,
    //    会把 main 自己报成泄漏;
    // 2) 留一个低速率分配循环。泄漏检测搭 GC 的便车:
    //    程序完全静止、GC 不跑,检测结果就不刷新。(当然也可以用 sleep 控制,还安全一些)
    for {
        _ = make([]byte, 1<<20)
        time.Sleep(100 * time.Millisecond)
    }
}

死锁链条读一遍:

  1. WriteFrame 拿到 writeLock,向 resetChan 发送,但是没有任何接收者,会触发锁等待直接卡死。
  2. monitor 收到关闭信号,去 Lock()——锁在 WriteFrame 手里,阻塞卡死。
  3. 两个 goroutine 互相等对方,双双泄漏。

profile 的形状和单一卡点不一样,两段 ROUTINE、各占一半

Entering interactive mode (type "help" for commands, "o" for options)
(pprof) list AwareFramer
Total: 28
ROUTINE ======================== main.(*idleAwareFramer).WriteFrame in D:\GoLand\xxx\k8sLeak.go
         0         14 (flat, cum) 50.00% of Total
         .          .     32:func (i *idleAwareFramer) WriteFrame() {
         .          .     33:   i.writeLock.Lock()
         .          .     34:   defer i.writeLock.Unlock()
         .          .     35:   if i.resetChan == nil {
         .          .     36:           return
         .          .     37:   }
         .         14     38:   i.resetChan <- true // ← 没有任何接收者 → 永远阻塞,且锁不释放
         .          .     39:}
         .          .     40:
         .          .     41:func NewIdleAwareFramer() *idleAwareFramer {
         .          .     42:   return &idleAwareFramer{
         .          .     43:           resetChan: make(chan bool),
ROUTINE ======================== main.(*idleAwareFramer).monitor in D:\GoLand\xxx\k8sLeak.go
         0         14 (flat, cum) 50.00% of Total
         .          .     21:func (i *idleAwareFramer) monitor() {
         .          .     22:   var resetChan = i.resetChan
         .          .     23:   for range i.conn.closeChan {
         .         14     24:           i.writeLock.Lock() // ← 若 WriteFrame 正持锁卡在发送上,这里永远等不到
         .          .     25:           close(resetChan)
         .          .     26:           i.resetChan = nil
         .          .     27:           i.writeLock.Unlock()
         .          .     28:           break
         .          .     29:   }
(pprof) 

一段卡 channel 发送、一段卡锁获取,各占 50%——看到这个形状,就可以按图索骥去找那对互相牵制的原语了。

官方给的修复:monitor 收到关闭信号后,另起一个 goroutine 先清空 resetChan,再去拿锁——让发送方有机会送完退出、还回锁。更通用的教训是:别让"持有锁"和"可能阻塞的 channel 操作"出现在同一段临界区里。

etcd:输给竞态的发送

出自 etcd 的真实 issue,run/Status/Stop 三个方法并发时,Status 的发送输掉了和 Stop 的竞态:

package main

import (
    "log"
    "net/http"
    _ "net/http/pprof"
    "time"
)

type node struct {
    status chan chan struct{}
    stop   chan struct{}
    done   chan struct{}
}

func (n *node) Status() struct{} {
    c := make(chan struct{})
    n.status <- c // ← 若 run 已随 Stop 退出,这次发送永远没人接
    return <-c
}

func (n *node) run() {
    for {
        select {
        case c := <-n.status:
            c <- struct{}{}
        case <-n.stop:
            close(n.done) // ← run 收到 stop 后关闭 done 并退出
            return
        }
    }
}

func (n *node) Stop() {
    select {
    case n.stop <- struct{}{}:
    case <-n.done:
        return
    }
    <-n.done
}

func Etcd6857() {
    n := &node{
        status: make(chan chan struct{}),
        stop:   make(chan struct{}),
        done:   make(chan struct{}),
    }
    go n.run()
    go n.Status()
    go n.Stop()
}

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    // 循环放大命中率:demo 是竞态命中才泄漏,单跑一次未必中
    for range 100 {
        Etcd6857()
    }

    // 保持进程存活,写法有两个讲究:
    // 1) 不能用 select{} 保活——空 select 本身就是检测器覆盖的阻塞原语,
    //    会把 main 自己报成泄漏;
    // 2) 留一个低速率分配循环。泄漏检测搭 GC 的便车:
    //    程序完全静止、GC 不跑,检测结果就不刷新。
    for {
        _ = make([]byte, 1<<20)
        time.Sleep(100 * time.Millisecond)
    }
}

时序读一遍:run 持续接收 status 上的请求,同时随时可能收到 stop——收到就关闭 done 并退出。Stop 发出信号后等 done 关闭。竞态在于:Stoprun 可能先完成同步并双双退出,Status 发出的请求没人接收,n.status <- c 永久阻塞。

profile 只报一个卡点:

(pprof) list Status
Total: 17
ROUTINE ======================== main.(*node).Status in D:\GoLand\xxx\etcdLeak.go
         0         17 (flat, cum)   100% of Total
         .          .     16:func (n *node) Status() struct{} {
         .          .     17:   c := make(chan struct{})
         .         17     18:   n.status <- c // ← 若 run 已随 Stop 退出,这次发送永远没人接
         .          .     19:   return <-c
         .          .     20:}
         .          .     21:
         .          .     22:func (n *node) run() {
         .          .     23:   for {
(pprof) 

官方给的修复:把发送包进 select,另一个分支等 done——和 Stop 竞争失败时优雅退出:

func (n *node) Status() struct{} {
    c := make(chan struct{})
    select {
    case n.status <- c:
    case <-n.done: // ← 竞争失败就走这条分支退出,不再干等
        return struct{}{}
    }
    return <-c
}

两个 demo 都是竞态命中才泄漏,所以各自 main 里用循环放大命中率。


总结

把检测器和 pprof 串成一条完整的排查流程:

  • 接入只要三行——net/http/pprof 匿名导入加一个 ListenAndServe,八个端点全部就位,1.27 新增的 goroutineleak 自动可用。
  • 新检测器借 GC 标记根做活性归纳,精确但只覆盖 channelselectsync 原语上的永久阻塞,IO 阻塞不算,全局引用的原语会漏报,只能事后检测
  • 两个真实 demo 的共性值得记住:泄漏点都落在一条等不到接收的发送上。

    • Kubernetes 的发送者手里还握着锁,把等待传染给了拿锁方。
    • etcd 的发送者输给了竞态,对手退场后无人接收。

    前者的解决方法是别在临界区里做阻塞的 channel 操作,后者的解决方法是 select 里给竞争失败方留一条 done 分支。检测器只负责把卡点指到行,问题出处还得工程师对着检测器对应代码区域找。

对于泄露场景的相关实例,有兴趣的话建议还是看官方原文的例子,有些像 Mutex goroutinechannelselect 混合使用的复杂场景,我经验不足,还是不太能做很好的分析,这里就不继续展开了。

总之官方愿意在 Go 原生下作并发问题检测工具还是很好的,能用工具找,总比工程师挠破头对着整个项目用 pprof 对着慢慢找要快很多,找到了也只是定位到区域,还得工程师用并发经验和处理经验去解决问题。

PREV
[操作系统] 从mmap→sendfile的性能优化

评论(0)

发布评论