记一次golang项目context引发的OOM故障
之前写过一篇一种基于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请求还是其他?
事后重现的示例进程。
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"
修复代码如下:
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清理,但是架不住无限循环导致的随地分配啊。
有如下简化实验:
for {
context.WithTimeout(context.Background(), time.Second*10)
}
GODEBUG = gotrace=1 ./sample 执行程序并打印gc日志:
有关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 = parent
done := 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
记一次golang项目context引发的OOM故障的更多相关文章
- Golang项目目录结构组织
其实golang的工程管理还是挺简单的,完全使用目录结构还有package名来推导工程结构和构建顺序. 当然,首先要说的是环境变量$GOPATH,项目构建全靠它.这么说吧,想要构建一个项目,就要将这个 ...
- golang项目中使用条件编译
golang项目中使用条件编译 C语言中的条件编译 golang中没有类似C语言中条件编译的写法,比如在C代码中可以使用如下语法做一些条件编译,结合宏定义来使用可以实现诸如按需编译release和de ...
- golang中Context的使用场景
golang中Context的使用场景 context在Go1.7之后就进入标准库中了.它主要的用处如果用一句话来说,是在于控制goroutine的生命周期.当一个计算任务被goroutine承接了之 ...
- 记一次SSM项目小结(一)
记一次SSM项目小结(一) ssm框架 环境配置 服务器配置 解决方法 拦截器重定向到localhost nginx和tomcat中session失效 mybatis的xml文件不生效 数据库用户创 ...
- Emacs中多个golang项目的配置方法
概述 最近使用golang开发项目时, 发现有时需要同时进行多个golang项目. 在这种情况下, 如果把所有的项目都放在 GOPATH 之下, 不仅管理麻烦(因为各个项目需要提交到不同的代码库), ...
- 关于go get安装git golang项目时报错的处理办法
关于go get安装git golang项目时报错的处理办法 使用go get安装github上的项目时一般来说,不可避免会出错.各种错误的处理办法: 必须条件: 1.安装git并配置环境变量.下载地 ...
- Golang项目的测试实践
Golang项目的测试实践 最近有一个项目,链路涉及了4个服务.最核心的是一个配时服务.要如何对这个项目进行测试,保证输出质量,是最近思考和实践的重点.这篇就说下最近这个实践的过程总结. 测试金字塔 ...
- 性能测试——记XX银行保全项目性能问题分析优化
记XX银行保全项目性能问题分析优化 数据库问题也许是大部分性能问题的关注点,但是JAVA应用与数据库交互的关节,JDBC 就像是我们人体的上半身跟下半身的腰椎,支持上半身,协调下半身运动的重要支撑点. ...
- Golang的Context介绍及其源码分析
简介 在Go服务中,对于每个请求,都会起一个协程去处理.在处理协程中,也会起很多协程去访问资源,比如数据库,比如RPC,这些协程还需要访问请求维度的一些信息比如说请求方的身份,授权信息等等.当一个请求 ...
- 深入理解golang:Context
一.背景 在golang中,最主要的一个概念就是并发协程 goroutine,它只需用一个关键字 go 就可以开起一个协程,并运行. 一个单独的 goroutine运行,倒也没什么问题.如果是一个go ...
随机推荐
- Linux查看进程所在目录
通过ps 或 top 查看进程信息时,只能查到进程的相对路径,查不到进程的详细信息,如绝对路径等,我们可以通过下面的方法进行查询 1. 通过ll /proc/PID 命令查看进程所在的目录位置 lin ...
- 一个使用 WPF 开发的管理系统
前言 最近发现有不少小伙伴在学习 WPF,今天大姚给大家分享一个使用 WPF 开发的管理系统,该项目包含了用户登录.人员管理.角色授权.插件管理.职位管理.主页功能(邮件.皮肤.设置)等功能,对于一个 ...
- 论文解读《LightRAG: Simple and Fast Retrieval-Augmented Generation》
博客:https://learnopencv.com/lightrag 视频:https://www.youtube.com/watch?v=oageL-1I0GE 代码:https://github ...
- uni-app上下级页面数据双向通信
前情 最近在做小程序项目,选用是当前比较火的uniapp技术栈,经常会遇到页面间消息传递的需求. 为什么要这么做? uniapp页面间数据通信方式有很多:通过url传参,状态管理库vuex/pinia ...
- django模型层(orm相关知识点)
目录 一.模型层之前期准备 模型层的了解 模型 模型层的前置知识点 二.ORM常用关键字 三.ORM执行SQL语句 四.神奇的双下划线查询 五.ORM外键字段的创建 复习MySQL外键关系 外键字段的 ...
- Netty 那些事儿 ——— 关于 “Netty 发送大数据包时 触发写空闲超时” 的一些思考
作者:tomas家的小拨浪鼓链接:https://www.jianshu.com/p/8fe70d313d78来源:简书 本文是笔者和朋友(笔名:oojeek)一起讨论该问题的一个记录.文章以讨论过程 ...
- Visual Studio2012编译C#项目时出错“LC.exe”已退出的解决方法
症状: Visual Studio2012编译C#项目时出错"LC.exe"已退出,代码为 -1. 原因: 因为证书的原因,把项目中"properties"目录 ...
- Python 抽象基类 ABC :从实践到优雅
今天我们来聊聊 Python 中的抽象基类(Abstract Base Class,简称 ABC).虽然这个概念在 Python 中已经存在很久了,但在日常开发中,很多人可能用得并不多,或者用得不够优 ...
- Centos-基础配置
切换下载源到阿里云 备份 mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.backup 下载新的 Cent ...
- 前端学习openLayers配合vue3(面的绘制,至少三个点)
我们学习了点和线的绘制,当然我们也可以绘制一个面 关键代码,需要注意的一点就是面的绘制需要三维数组,线的绘制是个二维数组 const polygonLayer = new VectorLayer({ ...