记一次由context引起的进程OOM问题
之前写过《一种基于etcd实践节点自动故障转移的思路》, 程序经历过一次线上进程OOM的小事故, 本次技术复盘导致内存泄露的完整起因。
提炼代码:
业务函数etcdWatchLoop: 基于etcd的Watch机制
持续监听/foo
前缀键值对的变更; 收到Watch信道的变更消息,就去查询当前键值对。
func etcdWatchLoop() error {
ctx, cancle := context.WithTimeout(context.Background(), time.Second*5)
defer cancle()
wchan := eClient.Watch(ctx, "/foo", clientv3.WithPrefix())
var tick = time.NewTicker(time.Minute * 1)
defer tick.Stop()
for {
select {
case <-tick.C: // 1min 探测一次,防止假死
fmt.Println("watch tick")
case resp := <-wchan:
fmt.Printf("watch result: %v \n", resp)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
if r, err := eClient.Get(ctx, "/foo"); err != nil {
fmt.Println(err)
} else {
// todo logic
}
}
}
}
程序日志显示: 程序进入死循环。
代码语言:javascript代码运行次数:0运行复制 watch result: {{0 0 0 0 {} [] 0} [] 0 false false <nil> }
watch result: {{0 0 0 0 {} [] 0} [] 0 false false <nil> }
watch result: {{0 0 0 0 {} [] 0} [] 0 false false <nil> }
当时etcd底层正在压缩或者发生网络问题,watch方法产生的信道resp := <-wchan
被cancle了,信道被关闭,程序进入了无限循环。
故障产生的第一点: 没有关注到从closed的信道中能持续读取到零值,导致进入无限循环。
无限循环(持续发送到etcd的get请求) 导致了OOM, 那具体是哪块内存泄露呢,高频grpc请求还是其他?
事后重现的示例进程。
ps -p <PID> -o etime=
显示程序执行了20:33:12, 内存从7M上涨到184M,持续进行中。
执行go tool pprof -http=:8090 http://localhost:6060/debug/pprof/heap) 显示grpc请求时与context相关的2处堆内存占用较大且持续增长。
故障点二: 代码中的defer cancel()函数并不会执行,因为是无限循环,函数不会返回,defer压栈的cancel
函数无法出栈执行。
godoc: Calling the CancelFunc cancels the child and its children, removes the parent's reference to the child, and stops any associated timers. Failing to call the CancelFunc leaks the child and its children until the parent is "canceled" or the "timer fires"
修复代码如下:
代码语言:javascript代码运行次数:0运行复制 func etcdWatchLoop1() error {
ctx, cancle := context.WithTimeout(context.Background(), time.Second*5)
defer cancle()
wchan := eClient.Watch(ctx, "/foo", clientv3.WithPrefix()) // 使用超时机制模拟 信道关闭
var tick = time.NewTicker(time.Minute * 1)
defer tick.Stop()
for {
select {
case <-tick.C:
fmt.Println("watch tick")
case resp, ok := <-wchan: // 从cancled信道或者超时信道中,信道会关闭,从closed信道会读取到零值,导致死循环
if ok {
fmt.Printf("watch result: %v \n", resp)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
fmt.Printf("watch ptr: %p, %p \n", &ctx, cancel)
if _, err := eClient.Get(ctx, "/foo"); err != nil {
fmt.Println(err)
} else {
// todo logic
}
cancel()
} else {
wchan = eClient.Watch(ctx, "/foo", clientv3.WithPrefix())
}
}
}
}
- 利用读信道的参数2, 来判断信道是否关闭,如果关闭了,重新初始化监听信道。
- context.WithTimeout 产生的cancel,在业务逻辑结束后迅速主动执行。
在本例中, 与context相关的内存泄露有两处,且有关联。
<1> 业务函数context.WithTimeout
无限循环,未能执行cancel(), 导致高频产生的timerCtx
堆内存迟迟无法释放。
<2> grpc请求底层源码以第一处产生的timerCtx为父级, 产生的子级cancelCtx
接收父级取消传播,此处为父级timerCtx填充了取消信道。
第<1>处:未能调用cancel 导致的内存泄露。
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
产生了timerCtx
对象, 因函数返回逃逸到堆上(由栈区返回值ctx引用)。
func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// The current deadline is already sooner than the new one.
return WithCancel(parent)
}
c := &timerCtx{ // withTmeout实际产生了timerCtx对象
deadline: d,
}
c.cancelCtx.propagateCancel(parent, c)
dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded, cause) // deadline has already passed
return c, func() { c.cancel(false, Canceled, nil) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
c.timer = time.AfterFunc(dur, func() { // 异步启动goroutine执行定时器触发逻辑
c.cancel(true, DeadlineExceeded, cause)
})
}
return c, func() { c.cancel(true, Canceled, nil) }
}
`context.WithTimeout`返回的cancel函数和timer触发函数做了相同的动作:
- 形成了闭包,捕获了timerCtx对象
- 与父级context解绑, 停止timer资源
区别在于释放的时机: 定时器触发函数捕获的timerCtx,要在定时器触发之后才能释放,也就是说timerCtx堆内存被硬生生持有了timeout=10s(连带上timerCtx附加的timer资源)。
于是在本例中,理论上高频产生的timerCtx在10s之后被GC清理,但无限循环快速分配内存的效率远大于GC清理的效率,且GC有延迟性(Go 的垃圾回收器(GC)并不是实时运行的,它会在堆内存达到一定阈值时触发), 最终内存持续上涨OOM。
有如下简化实验:
代码语言:javascript代码运行次数:0运行复制 for {
context.WithTimeout(context.Background(), time.Second*10)
}
GODEBUG = gotrace=1 ./sample 执行程序并打印gc日志:
有关gotrace=1 的输出解释,godoc /runtime 有详细介绍。
代码语言:javascript代码运行次数:0运行复制#->#-># MB heap size at GC mark start, at GC Mark end, and live heap
当第三列值持续上升,说明发生了内存泄露 (每次GC之后 live heap在持续上升)。
第<2>处的内存泄露:
在grpc一元请求内部函数newClientStreamWithParams内会产生子context: cancelCtx
, 也会逃逸到堆上(由另一个栈区变量ctx引用)。
> WithCancel returns a copy of parent with a new Done channel. The returned
> context's Done channel is closed when the returned cancel function is called
> or when the parent context's Done channel is closed, whichever happens first.
newClientStreamWithParams
--- ctx, cancel = context.WithCancel(ctx)
--- defer func() {
if err != nil {
cancel()
}
}()
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancel(parent)
return c, func() { c.cancel(true, Canceled, nil) }
}
func withCancel(parent Context) *cancelCtx {
if parent == nil {
panic("cannot create context from nil parent")
}
c := &cancelCtx{}
c.propagateCancel(parent, c)
return c
}
根据火焰图,此处产生内存泄露的地方是 propagateCancel
函数:
设置接受父级的取消传播, 此处是通过懒加载的方式为父级timerCtx填充取消信道。
代码语言:javascript代码运行次数:0运行复制 func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
c.Context = parent
done := parent.Done() // 此函数为timerCtx填充信道, 懒加载
ifdone == nil {
return // parent is never canceled
}
}
......
func (c *cancelCtx) Done() <-chan struct{} {
d := c.done.Load()
if d != nil {
return d.(chan struct{})
}
c.mu.Lock()
defer c.mu.Unlock()
d = c.done.Load()
if d == nil {
d = make(chan struct{})
c.done.Store(d)
}
return d.(chan struct{})
}
总结
本文复盘了golang项目生产进程OOM的现场,记录了本人未能强化的golang知识点。
- 从closed信道能持续读取零值
- defer 函数压栈,在函数返回之前出栈
- 在业务逻辑结束后尽早执行cancel() 解绑子级关系和释放timer资源,避免内存泄露
- 强化了pprof的使用方式、理解了火焰图的指标意义
- GODEBUG=gotrace=1 输出了gc日志,观察每次gc的堆内存变动。
有些坑位看起来,是菜鸟级别, 老鸟轻喷, 有则改之无则加勉。
本篇文字和图片均为原创,读者可结合图片探索源码, 欢迎反馈 ~。。~。
本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。原始发表:2025-02-22,如有侵权请联系 cloudcommunity@tencent 删除函数进程内存程序定时器