记一次golang项目context引发的进程OOM故障

news/2025/2/22 16:47:13/文章来源:https://www.cnblogs.com/JulianHuang/p/18731012

之前写过一篇一种基于etcd实践节点自动故障转移的思路, 程序经历过一次线上进程内存持续上涨终OOOM的小事故, 本次技术复盘导致本次内存泄露的完整起因。

提炼代码:

业务函数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 }}}
}

程序日志显示: 程序进入死循环。

 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请求还是其他?

事后重现的示例进程。

image.png
ps -p <PID> -o etime=显示程序执行了20:33:12, 内存从7M上涨到184M,持续进行中。

执行go tool pprof -http=:8090 http://localhost:6060/debug/pprof/heap) 显示调用grpc请求时与context相关的2处堆内存占用较大且持续增长。

image.png

故障点二: 代码中的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"

修复代码如下:

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 passedreturn 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清理,但是架不住无限循环导致的随地分配啊。

有如下简化实验:

for {context.WithTimeout(context.Background(), time.Second*10)
}

GODEBUG = gotrace=1 ./sample 执行程序并打印gc日志:

image.png
有关gotrace=1 的输出解释,godoc https://pkg.go.dev/runtime 有详细介绍。

#->#-># 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填充取消信道,

func (c *cancelCtx) propagateCancel(parent Context, child canceler) {c.Context = parentdone := parent.Done()    // 此函数为timerCtx填充信道, 懒加载if done == 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的堆内存变动。

https://go.dev/src/context/context.go?s=9162:9288

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/888053.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

记一次golang项目context引发的OOM故障

之前写过一篇一种基于etcd实践节点自动故障转移的思路, 程序经历过一次线上进程内存持续上涨终OOOM的小事故, 本次技术复盘导致本次内存泄露的完整起因。 提炼代码: 业务函数etcdWatchLoop: 基于etcd的Watch机制持续监听/foo前缀键值对的变更; 收到Watch信道的变更消息,就…

R语言LCMM多维度潜在类别模型流行病学研究:LCA、MM方法分析纵向数据

全文代码数据:https://tecdat.cn/?p=39710 原文出处:拓端数据部落公众号 在数据分析领域,当我们面对一组数据时,通常会有已知的分组情况,比如不同的治疗组、性别组或种族组等。然而,数据中还可能存在未被观测到的分组,例如素食者与非素食者、经常锻炼者与不锻炼者,或…

TensorFlow域对抗训练DANN神经网络分析MNIST与Blobs数据集梯度反转层提升目标域适应能力可视化

全文链接:https://tecdat.cn/?p=39656 原文出处:拓端数据部落公众号 本文围绕基于TensorFlow实现的神经网络对抗训练域适应方法展开研究。详细介绍了梯度反转层的原理与实现,通过MNIST和Blobs等数据集进行实验,对比了不同训练方式(仅源域训练、域对抗训练等)下的分类性能…

【专题】2025年我国机器人产业发展形势展望:人形机器人量产及商业化关键挑战报告汇总PDF洞察(附原数据表)

原文链接:https://tecdat.cn/?p=39668 机器人已广泛融入我们生活的方方面面。在工业领域,它们宛如不知疲倦的工匠,精准地完成打磨、焊接等精细工作,极大提升了生产效率和产品质量;在日常生活里,它们是贴心的助手,扫地机器人默默清扫房间,陪伴机器人给予老人孩子温暖陪…

vba主动着色

原来的条件格式效率太低,改为主动方式着色 Sub SetColor() On Error Resume Next Dim hang As Integer 行数 Dim lie As Integer Dim IsBuy As Boolean Dim IsSell As Boolean hang = ActiveSheet.UsedRange.Rows.Count With ActiveSheet …

2025省选模拟13

2025省选模拟13\(T1\) P1025. Easy Problem \(40pts\)部分分\(40pts\)设 \(f_{i,j}\) 表示 \(p_{3j}=i\) 时 \([1,i]\) 对答案的贡献,状态转移方程为 \(f_{i,j}=\max\limits_{k=3(j-1)}^{i-3} \{ f_{k,j-1}+w(k+1,i) \}\) ,其中 \(w(k+1,i)\) 表示 \([k+1,i]\) 的次大值。 设…

installerX还你一个清爽的安装

相信大家都有被手机自带的软件安装器折磨的情况,各种禁止安装,这种验证和识别,不开启安全模式和开了没区别,针对这种情况有没有什么办法绕过呢? 我们可以使用开源软件installerX,这款软件使用拥有这类原生的安装体验,安装速度也不差,并且简洁高效,还可以进行降级安装。…

[Paper Writting] 论文画图指南

目录Motivation方法概念图新老对比类方法简图类实物示意图效果示意图Architecture Motivation 方法概念图 HPT新老对比类 OSXMOTRUniADMulti-modal 3D Human Pose Estimation方法简图类 MoCoconformerBEVFormerDETRDriveVLM实物示意图 emg2pose效果示意图 umetracktransmvshoid…

不到24小时,AOne让全员用上DeepSeek的秘诀是……

DeepSeek引发新一轮AI浪潮,面对企业数字化智能升级与数据安全红线的急迫需求,IT负责人的压力山大!如何在24小时内实现全员AI落地,同时为后续安全部署铺平道路?Step1:一键开启全员智能时代 基于国产大模型领军者DeepSeek(671B满血版&70B版),天翼云AOne搭载智能引擎…

Unity Addresable打包总结第一弹

前言 使用AB包很久了,一直没有机会做一个系统的总结,趁现在准备离职,时间空闲比较多,将项目内的Addresable使用经验大致的分析总结一下,以作日后备用。 使用介绍 下方的引用链接中,发哥已经总结的很详细了,但我这里还是稍微介绍一下基本流程。 基本流程在Package Manage…

AutoCAD 逆向工程中 Shx 字体文件解析

数据格式相关的文章代码实现 https://blog.csdn.net/qq_29830577/article/details/78604983#####愿你一寸一寸地攻城略地,一点一点地焕然一新#####

golang学习笔记——gorm

gen是gorm官方推出的一个GORM代码生成工具 官方文档:https://gorm.io/zh_CN/gen/ 1.使用gen框架生成model和dao 安装gorm gengo get -u gorm.io/gen假设有如下用户表CREATE TABLE user (`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 主键,`username` varchar(1…