之前写过一篇一种基于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故障的更多相关文章

  1. Golang项目目录结构组织

    其实golang的工程管理还是挺简单的,完全使用目录结构还有package名来推导工程结构和构建顺序. 当然,首先要说的是环境变量$GOPATH,项目构建全靠它.这么说吧,想要构建一个项目,就要将这个 ...

  2. golang项目中使用条件编译

    golang项目中使用条件编译 C语言中的条件编译 golang中没有类似C语言中条件编译的写法,比如在C代码中可以使用如下语法做一些条件编译,结合宏定义来使用可以实现诸如按需编译release和de ...

  3. golang中Context的使用场景

    golang中Context的使用场景 context在Go1.7之后就进入标准库中了.它主要的用处如果用一句话来说,是在于控制goroutine的生命周期.当一个计算任务被goroutine承接了之 ...

  4. 记一次SSM项目小结(一)

    记一次SSM项目小结(一) ssm框架 环境配置 服务器配置 解决方法  拦截器重定向到localhost nginx和tomcat中session失效 mybatis的xml文件不生效 数据库用户创 ...

  5. Emacs中多个golang项目的配置方法

    概述 最近使用golang开发项目时, 发现有时需要同时进行多个golang项目. 在这种情况下, 如果把所有的项目都放在 GOPATH 之下, 不仅管理麻烦(因为各个项目需要提交到不同的代码库), ...

  6. 关于go get安装git golang项目时报错的处理办法

    关于go get安装git golang项目时报错的处理办法 使用go get安装github上的项目时一般来说,不可避免会出错.各种错误的处理办法: 必须条件: 1.安装git并配置环境变量.下载地 ...

  7. Golang项目的测试实践

    Golang项目的测试实践 最近有一个项目,链路涉及了4个服务.最核心的是一个配时服务.要如何对这个项目进行测试,保证输出质量,是最近思考和实践的重点.这篇就说下最近这个实践的过程总结. 测试金字塔 ...

  8. 性能测试——记XX银行保全项目性能问题分析优化

    记XX银行保全项目性能问题分析优化 数据库问题也许是大部分性能问题的关注点,但是JAVA应用与数据库交互的关节,JDBC 就像是我们人体的上半身跟下半身的腰椎,支持上半身,协调下半身运动的重要支撑点. ...

  9. Golang的Context介绍及其源码分析

    简介 在Go服务中,对于每个请求,都会起一个协程去处理.在处理协程中,也会起很多协程去访问资源,比如数据库,比如RPC,这些协程还需要访问请求维度的一些信息比如说请求方的身份,授权信息等等.当一个请求 ...

  10. 深入理解golang:Context

    一.背景 在golang中,最主要的一个概念就是并发协程 goroutine,它只需用一个关键字 go 就可以开起一个协程,并运行. 一个单独的 goroutine运行,倒也没什么问题.如果是一个go ...

随机推荐

  1. sde解除锁定

    在sde数据被锁定的情况下,编辑.创建featureclass或者注册版本的时候会报告:Lock request conflicts with an established lock. 方法一:多半情 ...

  2. 【Amadeus原创】Docker容器的备份与还原

    主要作用: 就是让配置好的容器,可以得到复用,后面用到得的时候就不需要重新配置. 其中涉及到的命令有: docker commit 将容器保存为镜像 docker save -o 将镜像备份为tar文 ...

  3. 解决docker 容器设置中文语言包出现的问题_docker

    https://www.anquanclub.cn/5821.html 这篇文章主要介绍了解决docker 容器设置中文语言包出现的问题,具有很好的参考价值,希望对大家有所帮助.一起跟随小编过来看看吧 ...

  4. GenericObjectPool 避免泄漏

    GenericObjectPool GenericObjectPool 是 Apache Commons Pool 提供的对象池,使用的时候需要调用 borrowObject 获取一个对象,使用完以后 ...

  5. springboot拦截器过滤token,并返回结果及异常处理

    package com.xxxx.interceptor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.sp ...

  6. 区块链技术已经衰落了吗?(区块链已die)

    区块链技术已经好多年没有听到有人提了,不过比特币却一直是不是的又新闻出现,当然国内已经把比特币交易归入到了不合法的地位了.区块链技术是国家战略的技术,但是这个技术说实话确实不是很高深,或者说蛮easy ...

  7. Qt数据库应用15-通用数据库同步

    一.前言 数据库同步的主要功能是将本地的数据库记录同步到远程的数据库,其中数据库类型不限,比如本地是sqlite数据库,远程可以是mysql数据库,本地是mysql数据库,远程也可以是postgres ...

  8. 这些小 Bug,99% 的程序员都写过!

    "程序怎么运行不了,不应该啊?" "程序怎么能运行了,不应该啊!" 这句话是不是让程序员朋友们的 DNA 动了呢?今天鱼皮分享一些新手程序员常犯的小 Bug,很 ...

  9. 前端之canvas实现电子签约完成线上签署功能

    最近发现现在租房还是签合同,越来越多采用电子签约的方式进行,好处不用多说节约成本,节约时间.抱着好奇的心理,尝试自己动手实现一个电子签.原来并不复杂主要通过了canvas绘画能力进行实现的. 主要功能 ...

  10. ofd轻阅读超大文件优化方案

    本人使用Typescript开发了一款ofd 阅读器,参见文章<ofd轻阅读>.web端实现阅读功能有两种方案: ofd转svg:使用h5 canvas. 两种方案各有优劣,本人采用了ca ...