之前写过一篇一种基于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. 不求甚解--详解ansible-playbook中roles的用法

    前言 本文将详细介绍ansible-playbook中roles的各种用法,它允许你将相关的任务.变量.处理器.文件和模板等集合在一起,以便于在不同的项目中复用 环境准备 组件 版本 操作系统 Ubu ...

  2. C#获取用户客户端系统版本设备名称浏览器

    C#获取用户客户端系统版本设备名称浏览器 先看效果 使用 Neget引用包UAParser 在这里插入代码片 项目的github :https://github.com/ua-parser/uap-c ...

  3. 使用 ASP.NET Core 5 Web API 创建可发现的 HTTP API

    使用 ASP.NET Core 5 Web API 创建可发现的 HTTP API https://devblogs.microsoft.com/aspnet/creating-discoverabl ...

  4. PG 实现 Dynamic SQL

    CREATE OR REPLACE FUNCTION public.exec( text) RETURNS SETOF RECORD LANGUAGE 'plpgsql' AS $BODY$ BEGI ...

  5. Qt可视化大屏电子看板系统全平台效果图

  6. hhhhhhomework 验证码界面(非全部自己完成)

    import javax.swing.*;//import 代表"引入" //javax.swing 代表"路径" (在javax文件夹下的swing文件夹) ...

  7. [转]E1接口介绍

    E1 通道本来设计用来传输电话的,每个 E1(带宽 2.048M)可以传 30 路电话,后来扩大的应用范围,可以用作传网络,串口等不同的业务:E1 是一个基本的传输单元,其最终还是通过光纤来传输的,如 ...

  8. VC++2008、2010、2012、2015、2017等IDE中如何设置命令行参数进行程序调试

    有时我们在写程序时会从命令行中直接读入参数,形如: int main(int argc,char**argv){ //your code here return 0; } 其实在vc2010 IDE中 ...

  9. 在Eclipse配置并编译worldwind java2.1.0源码,选中Src目录下gov.nasa.worldwindx.examples包下ApplicationTemplate.java类文件run时提示“javax.xml.parsers.DocumentBuilderFactory.setFeature(Ljava/lang/String;Z)V”异常的解决办法

    问题现象: 在Eclipse配置并编译worldwind java2.1.0源码,选中Src目录下gov.nasa.worldwindx.examples包下ApplicationTemplate.j ...

  10. OpenMMLab AI实战营 第三课笔记

    OpenMMLab AI实战营 第三课笔记 目录 OpenMMLab AI实战营 第三课笔记 进入 mmclassification 目录 导入工具包 下载数据集 数据集目录结构 下载 config ...