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)。
  • 只能事后检测,不能预测。生产上还是建议周期性采集,测试期用 goleak 和 testing/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:3001,Top(占用最多的函数)、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 关闭。竞态在于:Stop 和 run 可能先完成同步并双双退出,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 标记根做活性归纳,精确但只覆盖 channel、select 和 sync 原语上的永久阻塞,IO 阻塞不算,全局引用的原语会漏报,只能事后检测。
  • 两个真实 demo 的共性值得记住:泄漏点都落在一条等不到接收的发送上。

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

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

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

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

PREV
[操作系统] 从mmap→sendfile的性能优化
NEXT
[Golang] GO 1.27泛型方法更新

评论(0)

发布评论