[go] pprof 定位 gnet 项目内存泄漏

2026-03-06
Go  gnet

最近在维护一个以 gnet 框架做长连接服务的后端网关服务系统,在 OnTraffic 读包时出现了内存泄漏。

为了定位和修复,给服务增加了 pprof 端口(6060),通过 heap profile 对比分析,快速锁定了泄漏源头。

再由 pprof 数据反查业务代码,定位到 gnet 的 Peek() / Next() / Discard() API 使用不当,最终彻底修复。


1. 背景

项目是一个基于 gnet 的事件驱动长连接网关,核心网络模型是 reactor + epoll,少量 event-loop 管理大量连接。

在用户量在不断新增过程中,观察到程序内存占用持续上涨。

排查内存问题,Go 生态绕不开 pprof。只要项目中引入,就能零侵入地拿到运行时的 heap / goroutine / CPU profile。


2. 启用 pprof

在服务中加几行代码,监听一个独立的 HTTP 端口(生产环境建议绑定内网 IP,避免暴露到公网):

1
2
3
4
5
6
7
8
9
10
import (
    _ "net/http/pprof"
    "net/http"
)

func init() {
    go func() {
        http.ListenAndServe("127.0.0.1:6060", nil)
    }()
}

3. 采集 heap profile

curl 直接拉取当前 heap 快照。建议采集前先触发一次 GC,拿到的是 “存活对象”,排除可回收的临时垃圾,数据更干净:

1
2
# 在服务端手动触发 GC 后再采集
curl -s "http://127.0.0.1:6060/debug/pprof/heap?gc=1" > heap_gc_$(date +%Y%m%d_%H%M%S).out

如果要对比”增量”,采集两个时间点的快照:

1
2
3
4
5
6
7
# 第一份快照(基线)
curl -o heap1.pprof http://127.0.0.1:6060/debug/pprof/heap

# 等待一段时间让内存继续涨...

# 第二份快照(问题状态)
curl -o heap2.pprof http://127.0.0.1:6060/debug/pprof/heap

如果是远程服务器,把文件 scp 到本地再做分析:

1
scp *.pprof root@103.xx.xx.196:/tmp/

4. 分析 profile

4.1. 查看整体内存占用

go tool pprof 打开 profile,-unit=MB 让输出以 MB 为单位更直观:

1
go tool pprof -unit=MB -text ./XSever ./heap_gc_20260302_202045.out

4.2. 火焰图可视化

pprof 内置 Web 界面,可以生成火焰图和调用图:

1
go tool pprof -http=:8080 ./XSever ./heap_gc_20260302_202045.out

4.3. 对比两个快照

这是定位泄漏增量最有效的方式——只看”新分配但没回收”的部分:

1
go tool pprof -diff_base=heap1.pprof -http=:8080 heap2.pprof

浏览器打开 http://localhost:8080,选择 Flame Graph 视图,很容易能看到哪个调用路径在持续堆积内存。

4.4. 交互式分析

go tool pprof 也支持交互模式,常用命令:

1
2
3
4
5
6
# go tool pprof ./XSever ./heap_gc_20260302_202045.out

(pprof) top20          # 前 20 个占用最多的调用
(pprof) list FuncName  # 查看某函数的具体代码行开销
(pprof) traces         # 完整调用栈
(pprof) peek FuncName  # 某个函数的 caller / callee

5. 问题定位

5.1. 泄漏现场

使用 pprof 工具采集到数据后,用 top20 命令可以看到异常 —— byteslice.(*Pool).Get 占了 204.28MB,占整个 heap 的 86.84%

1
2
3
4
5
6
(pprof) top20
Showing nodes accounting for 235.25MB, 100% of 235.25MB total
      flat  flat%   sum%        cum   cum%
  204.28MB 86.84% 86.84%   204.28MB 86.84%  github.com/panjf2000/gnet/v2/pkg/pool/byteslice.(*Pool).Get
   22.90MB  9.73% 96.57%    22.90MB  9.73%  github.com/confluentinc/confluent-kafka-go/v2/kafka.NewProducer
    1.50MB  0.64% 97.21%        2MB  0.85%  XSever/core.(*ServerHandler).OnOpen

用 peek 命令看清完整的调用链:

1
2
3
4
(pprof) peek byteslice.Get
                  204.28MB   100%  |   github.com/panjf2000/gnet/v2/pkg/buffer/ring.(*Buffer).grow (inline)
0     0%     0%   204.28MB 86.84%  |   github.com/panjf2000/gnet/v2/pkg/pool/byteslice.Get
                  204.28MB   100%  |   github.com/panjf2000/gnet/v2/pkg/pool/byteslice.(*Pool).Get

调用路径非常清晰:

1
eventloop.read → conn.processIO → ring.Buffer.Write → ring.Buffer.grow → byteslice.Pool.Get

gnet 每次读数据都往 ring buffer 写,buffer 不够就 grow,grow 向 byteslice 池申请内存——但这块内存一直没有归还,越积越多。


5.2. 从 pprof 反查业务代码 —— 根因

pprof 告诉你 “哪里在分配”,但 “为什么不释放” 需要回到代码里找。

锁定 byteslice.Pool.Get 后,沿调用链回溯到业务代码 ReadFullPacketDataFromConn —— 这是每次 OnTraffic 回调里读包的入口函数。

泄漏前的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (h *ServerHandler) OnTraffic(c gnet.Conn) (action gnet.Action) {
    ...
    err, packetData := ReadFullPacketDataFromConn(c, addr)
    ...
}

func ReadFullPacketDataFromConn(c gnet.Conn, remoteIp string) (err error, data []byte) {
    ...
    // 再看一眼包头数据
    bufTmp, err := c.Peek(packets.HEADER_BASE_LEN)  // ← 又分配一次
    ...
    // 最后 Next 取走整个包
    buf, err := c.Next(int(packetLen))              // ← Next 依然不释放 c.cache
    data = buf[:packetLen]
    return nil, data
}

问题就在这里:gnet 的 c.Peek() 会把数据拷贝到内部的 c.cache 缓冲区(这个 cache 的底层就是 byteslice.Pool.Get)分配的)。而 c.Next() 只移动读取游标,不会释放 cache。每次 OnTraffic 进来,Peek 分配新的 cache,但旧的 cache 因为引用还在,GC 没法回收 —— 204MB 就是这么堆积出来的。

可以这样理解:

Peek = 把水舀到自家水缸里(c.cache),水缸只增不减。
Next = 告诉别人”这桶水我用完了”,但水缸里的水还在。
Discard = 把水缸倒掉。


5.3. 修复方案

核心思路:OnTraffic 结束时,使用链接的 Discard 强制清空 gnet 的内部缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func ReadFullPacketDataFromConn(c gnet.Conn, addr string) (err error, data []byte) {
    // 【核心修复】函数退出时,强制清空 Peek 产生的 c.cache
    defer func() {
        if _, errDiscard := c.Discard(0); errDiscard != nil {
            log.Warn().Msgf("Discard cache failed, addr: %v, err: %v",
                addr, errDiscard)
        }
    }()
    ...
    // 正常读包:Peek → Next → 深拷贝 → 返回
    headerBuf, err := c.Peek(packets.HEADER_MIN_LEN)
    ...
    fullPacketBuf, err := c.Next(packetLen)
    ...
    data = make([]byte, packetLen)
    copy(data, fullPacketBuf)
    return nil, data
}

5.4. 修复后验证

修复代码后重新采集 heap:

1
2
3
4
5
6
(pprof) top20
Showing nodes accounting for 30.50MB, 100% of 30.50MB total
      flat  flat%   sum%        cum   cum%
   22.90MB 75.08% 75.08%    22.90MB 75.08%  github.com/confluentinc/confluent-kafka-go/v2/kafka.NewProducer
    1.03MB  3.37% 78.45%     1.03MB  3.37%  golang.org/x/net/html.map.init.1
       1MB  3.29% 81.74%        1MB  3.29%  runtime.allocm
指标 修复前 修复后
总 heap 235.25 MB 30.50 MB
byteslice.Pool.Get 204.28 MB (86.8%) 0(完全消失)
最大单一分配 kafka.NewProducer (22.90 MB) kafka.NewProducer (22.90 MB)

byteslice 池的 204MB 彻底消失,堆内存从 235MB 降到了 30MB。


6. 总结

6.1. 排查路线

1
2
3
4
5
6
7
8
RSS 异常上涨
    → 接入 pprof,采集 heap profile
    → top20 发现 byteslice.Pool.Get 占 86%
    → peek 追溯完整调用链:eventloop.read → ring.Buffer.grow → byteslice.Get
    → 回到业务代码 ReadFullPacketDataFromConn 查 Peek/Next 用法
    → 定位:c.Peek() 分配 c.cache 但不释放,Next() 只移游标
    → 修复:defer c.Discard(0) 强制清 cache + OnClose 清理引用
    → 验证:heap 从 235MB 降到 30MB

6.2. 经验教训

  1. pprof 是线上排障的手段 —— 生产服务一定要留。后续还可以在 OnTick 中还加了定时输出内存指标的日志,便于持续观测。

  2. gnet 的 Peek / Next / Discard 语义要区分清楚:

    API 作用 是否释放 c.cache
    Peek(N) 预读 N 字节(拷贝到 c.cache)
    Next(N) 消费 N 字节(移动读游标)
    Discard(N) 丢弃 N 字节 ✅(N=0 时清空全部)

    如果你用 Peek 看了数据,就一定要在合适时机 Discard(0) 清掉 cache,否则就是慢性泄漏。


7. 后记

排查完内存泄漏问题后,自己会反思,当前项目这点用户量是否适合使用 gnet 这样的复杂框架?

其实在处理这个问题前,还遇到了其它框架阻塞问题,前同事直接在 OnTraffic 回调中编写复杂的阻塞业务逻辑。

这样来看,他压根没有理解透彻这个框架的使用。

有时候,领导常常会将产品的前景描绘得如何牛逼,但实际开发中往往需要考虑研发成本和复杂性。

如果项目规模不大,还是建议使用更简单易用的框架。


8. 参考