Golang因为有着比线程更加轻量级的协程的出现,使得并发编程的上手难度一下子变得亲民起来。而channel的引入,使得goroutine之间的通信变得异常的便捷。SOFMGUEBMWIDREWO
但好用并不意味着毫无风险,go channel使用不当,也极易引起goroutine泄漏。
何谓goroutine泄漏?就是开启了goroutine,却并没有及时回收,导致goroutine越积越多,如果程序及时关闭还不会出现问题,如果是在服务器中,程序长期运行,就会导致资源占用十分恐怖。
虽然goroutine比线程更轻量级,但每个goroutine至少也会有8~10K的空间,如果goroutine达到了一个恐怖的量级,内存的占用也是十分可怕的。
笔者近期就遇到了一个生产环境的goroutine泄漏问题。

问题复盘
我们有一套go语言开发的程序部署在客户的机器,该程序主要用来接收http请求,并将相应的请求解释成客户端配置文件,下发给客户端。服务器与客户端之间采用的是TCP长连接,二者之间靠心跳机制保活。
除此之外,该服务器还会接收客户端发过来的自监控性能指标信息,通过写ES或其他数据库的方式落到硬盘,以供监控分析。
以上这是背景。
起因是发现其中某一台机器上,该程序运行一段时间后,内存占用达到了数个G,而客户端的连接数量其实并不多。

image.png

通过pprof查看,发现goroutine的数量多得很不正常,甚至达到了13万多个。

image.png
用pprof工具定位,最终发现了问题所在,就是因为goroutine泄漏了。
那么,goroutine怎么会泄漏呢?经过分析代码终于发现了端倪,原来问题出在自监控信息上。
为了接受客户端自监控性能指标的信息,我们在服务器的配置文件中配置了数据库信息,如ES、MySQL、InfluxDb等,客户可以根据自己的需要选择合适的数据库进行存储。自监控信息通过心跳包携带上来。
在设计上是这么做的:心跳接受自监控信息和写数据库分别位于两个协程中,彼此之间通过channel通信。
channel的定义如下,它有1000个缓存:

Runner{
input: make(chan ProcessMetric, 1000),
config: config,
exporters: make(map[string]Exporter, 0),
}
在心跳接收的地方:

pm := exporters.ProcessMetric{
AgentId: agentID,
Timestamp: time.Unix(agentHeartbeatInfo.Monitor.Timestamp/1000, 0),
HostName: agentHeartbeatInfo.System.HostName,
Ip: agentHeartbeatInfo.System.Connection.Ip,
Pid: subProcess.Pid,
ProcessName: subProcess.Procname,
Cmd: subProcess.Cmd,
CpuUsageRate: subProcess.CpuUsageRate,
MemUsage: subProcess.MemUsage,
SendLines: subProcess.SendLines,
}
svr.exporterService.Input() <- pm
写入数据库的地方:

for {
select {
case metric := <-exporter.input:
exporter.process(metric)
case <-exporter.t:
return
}
}
乍看起来这样设计似乎没有什么问题,但是为了代码的健壮性,当用户在配置文件里一个数据库都没配置时,就不会去写到数据库。

if len(runner.config.Exporters) == 0 {
return nil
}
问题就出在这里。本来这段代码只是为了提高健壮性的,因为监控信息一般都会打开,偏偏真有客户因为机器上没有部署相应的数据库,所以没有打开,所以导致心跳一直在往channel里发信息,但是channel的接收端由于直接return了,导致无法读取,当1000个缓存满了之后,消息就全部阻塞在那里,导致goroutine越来越多,最终达到了数十万个。

问题重现
为了重现这个问题,我将代码抽象出来了,大致如下程序所示:

package main

import (
“fmt”
“net/http”
_ “net/http/pprof”
“runtime”
“time”
)

func main() {
go http.ListenAndServe(“0.0.0.0:6060”, nil) //注册pprof监控
ch := make(chan int, 5) //go channel,负责go routine之间通信,5个缓存
flag := false //bool类型标记,模拟配置信息,false表示没有配置
//第一个 go routine, 模拟写数据库信息,这里简化,直接读取channel的内容
go func() {
//当flag为false时,直接return,这行代码是导致go routine泄漏的关键
if !flag {
return
}
for {
select {
case recv := <-ch:
//读取 channel
fmt.Println(“recive channel message:”, recv)
}
}
}()

//for 循环模拟TCP长连接,每隔500ms向channel写一条数据,模拟心跳上报客户端自监控信息
for {i := 0time.Sleep(500 * time.Millisecond)go func() {fmt.Println("goroutine count:", runtime.NumGoroutine())i++ch <- i}()
}

}

运行上面的程序,发现当channel中5个缓存满了之后,每向channel中写一次数据,goroutine就会多一个,如果不停止程序,goroutine还将无限增加下去。

Feb-04-2021 11-38-07.gif
问题修复
修复这个问题的方法也很简单,在发送端也做一个判断,当flag为false的时候不向channel发送数据就可以了。

for {
i := 0
time.Sleep(500 * time.Millisecond)
go func() {
fmt.Println(“goroutine count:”, runtime.NumGoroutine())
i++
if flag {
ch <- i
}
}()
}
修复后运行情况如下所示:

Feb-04-2021 14-03-21.gif
可见无论运行多久,goroutine数量始终保持在3个,不会随着时间的推移无休止增加。

原理剖析
很多人可能不明白,为什么channel阻塞,会造成goroutine增加。看现象似乎每向channel写入一次数据,就会产生一个goroutine,而每从channel中取出一个数据,就会销毁这个goroutine。
其实这么理解也是说得通的。channel本来就是为了goroutine通信用的,数据的传输自然要借助于goroutine。
我们打开channel的源码(src/runtime/chan.go),可以看到向channel写入信息的函数其实是chansend1,内部调用的是chansend函数。

// entry point for c <- x from compiled code
//go:nosplit
func chansend1(c *hchan, elem unsafe.Pointer) {
chansend(c, elem, true, getcallerpc())
}
channel对应的数据结构封装在一个hchan的结构体里,这个结构体的结构如下所示:

type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G’s status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
在这个结构体里,维护了两类队列,一个基于数组的循环队列buf,主要用来缓存数据,还有两个用户缓存阻塞的goroutine的双向队列sendq和recvq。
这个buf的大小dataqsiz其实就是我们创建channel时指定的缓存大小。当qcount的数量小于dataqsiz的时候,其实数据是可以放入缓存的。

image.png
当buf满了之后,数据就无法再放入缓存队列中了,它就会阻塞在那地方,这些阻塞的数据会新创建一个goroutine,并把这个goroutine存放到sendq队列中。

// Block on the channel. Some receiver will complete our operation for us.
gp := getg()
//这里调用acquireSudog()创建一个goroutine
mysg := acquireSudog()
mysg.releasetime = 0
if t0 != 0 {
mysg.releasetime = -1
}
// No stack splits between assigning elem and enqueuing mysg
// on gp.waiting where copystack can find it.
mysg.elem = ep
mysg.waitlink = nil
mysg.g = gp
mysg.isSelect = false
mysg.c = c
gp.waiting = mysg
gp.param = nil
//放入sendq队列
c.sendq.enqueue(mysg)
//goroutine进入休眠
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
如果数据一直发不不去,那么这个goroutine将一直在这里休眠,直到有数据发送出去了,就会唤醒它。

// someone woke us up.
if mysg != gp.waiting {
throw(“G waiting list is corrupted”)
}
最后调用releaseSudog函数回收这个sudog(goroutine)。
通过上面的分析,我想大家已经理解了,为什么channel有数据阻塞,就会导致goroutine得不到释放,从而导致更严重的goroutine泄漏问题。

记一次goroutine泄漏的问题相关推荐

  1. 一起 goroutine 泄漏问题的排查

    作者: yanhengwang,腾讯 PCG 开发工程师 在 golang 中创建 goroutine 是一件很容易的事情,但是不合理的使用可能会导致大量 goroutine 无法结束,资源也无法被释 ...

  2. Goroutine泄漏

    概述 在Go中,goroutine很轻量级,随便创建成千上万个goroutine不是问题,但要注意,要是这么多的goroutine一致递增,而不退出,不释放资源,可就麻烦了. 本文介绍goroutin ...

  3. 记一次内存泄漏问题的排查经历

    源宝导读:随着系统越来越庞大,越来越复杂,疑难杂症问题也越来越多.本文将介绍明源研发协同平台团队针对一次内存泄露问题的排查过程和技术探索. 一.背景 内存泄漏,一个说大不大说下不小的瑕疵.作为开发者, ...

  4. go并发日记·避免goroutine泄漏/实现协程可控

    骐骥一跃,不能十步:驽马十驾,功在不舍. 干货的不能再干货了. 目录 不寻常的close goroutine内部逻辑来触发控制结束goroutine 姿势1 姿势2

  5. 记一次连接泄漏GetConnectionTimeoutException: wait millis 60000, active 20, maxActive 20, creating 0

    com.alibaba.druid.pool.GetConnectionTimeoutException: wait millis 60000, active 20, maxActive 20, cr ...

  6. 记一次jdbc泄漏与解决方案

    大家好,我是入错行的bug猫.(http://blog.csdn.net/qq_41399429,谢绝转载) 这是一次非常非常非常脑蛋疼的debug经历 上周五bug猫请假外出.在路上收到公司的紧急电 ...

  7. 记一次SOFA内存泄漏排查过程

    记一次内存泄漏排查过程 起因 某天中午大家还在安静的午休,睡得正香的时候突然被一阵手机滴-滴滴直响短信惊醒.一看是应用的服务器告警并且对应服务的所有机器都在告警"健康检查失败,自动拉下线&q ...

  8. golang常见内存泄漏

    1.有goroutine泄漏,goroutine"飞"了,zombie goroutine没有结束,这个时候在这个goroutine上分配的内存对象将一直被这个僵尸goroutin ...

  9. 记一次 K8s 控制平面排障的血泪经历!

    集群以及环境信息的信息: k8s v1.18.4 3 节点 Master 均为 8 核 16Gi, 50Gi-SSD 差异化配置的 19 节点 Minion control-plane 组件 (kub ...

最新文章

  1. 【Apollo源码分析】系列的第三部分【prediction】_slamcode的博客 -CSDN博客
  2. android视频录制(调用系统视频录制)
  3. 第三篇——第二部分——第三文 配置SQL Server镜像——域环境
  4. 【Transformer】ATS: Adaptive Token Sampling For Efficient Vision Transformers
  5. 智能家居(工厂模式)
  6. 阿里巴巴旗下平台口碑推出无人收银技术,改造便利店市场;重庆法院运用 AI 探索“智能判案”...
  7. 信息学奥赛C++语言:旅行
  8. 面试时如何在众多Java工程师中脱颖而出
  9. JavaScript模块
  10. DOM节点的属性和方法
  11. 卷积自编码器_GCLGP | 图卷积高斯过程
  12. Java 报表工具选择
  13. [leetcode题解] 第995题Minimum Number of K Consecutive Bit Flips
  14. 在2003服务器上预览时出现:您未被授权查看该页 您不具备使用所提供的凭据查看该目录或页的权限
  15. mail企业邮箱登录入口有哪些?
  16. python 操作word 修改页眉与页脚
  17. 打印机的4种色彩输出方式
  18. 乳腺癌(Breast cancer)数据集———Breakhis分享
  19. 深入理解Oracle中的case when then else end
  20. Swift面试资料 (一) —— Questions 和 Answers(一)

热门文章

  1. DeepFM模型调参
  2. 大数据 = 大机遇?
  3. 离线部署 Cloudera Manager 5 和 CDH 5.12.1 及使用 CDH 部署 Hadoop 集群服务
  4. oracle两种复制记录方式
  5. Nmap详细使用教程
  6. 飞机那么贵,为什么连个电动门都没有,还需要空姐推?
  7. Form 表单提交的几种方式
  8. 洛谷-P3654 First Step (ファーストステップ)
  9. MySql 建库建表脚本
  10. Flume原理深度解析