记一次 Go 定时任务占用大量 CPU 资源的排查过程

121 阅读6分钟

为了监控线上的服务,我用 Go 写了一个小监控程序,检查过去若干个小时线上服务产生的数据。由于这个程序只需要每小时执行一次,执行完退出即可,所以我把它加入到了开发机的 crontab 中。关键代码如下所示,还是十分简单的:

// ...
now := time.Now().UTC()
startTime := now.Add(-time.Duration(config.MonitorWindowInHour) * time.Hour)
// round startTime to hour
startTime = time.Date(startTime.Year(), startTime.Month(), startTime.Day(), startTime.Hour(), 0, 0, 0, time.UTC)

endHour := now.Hour()
startHour := startTime.Hour()

for h := startHour; h%24 != endHour+1; h++ {
  // 逐一检查从 startHour 开始,到 endHour 结束,期间每个小时线上服务的数据
  //(考虑到跨天的情况,所以需要 % 24)
	// ...
}

起初这个监控程序一切正常,但没过两天,就有同事反应开发机越来越慢,原因竟然是系统同时运行了多个监控程序,而且每个监控程序的进程都占用了几乎 100% 的 CPU 资源

发生了什么?

这个监控程序在测试时,每次都能很快执行完,显然不应该运行这么长时间,以至于在下一次 crontab 定时执行之前还没有退出,只有这样才会导致多个监控程序在同时运行。

那这个程序迟迟不退出是在执行什么呢,而且还占用了几乎 100% 的 CPU 资源?我最先想到的是用 dlv attach 来看一下当前正在执行的代码是什么:

> runtime.schedule() /usr/local/go/src/runtime/proc.go:3842 (PC: 0x438f56)
  3837:	// One round of scheduler: find a runnable goroutine and execute it.
  3838:	// Never returns.
  3839:	func schedule() {
  3840:		mp := getg().m
  3841:
=>3842:		if mp.locks != 0 {
  3843:			throw("schedule: holding locks")
  3844:		}
  3845:
  3846:		if mp.lockedg != 0 {
  3847:			stoplockedm()

尽管不停按下 N 键来不断执行下一条语句,但似乎程序陷入到了 runtime.schedule() 这个函数当中,反反复复执行 3840、3842、3846 这几行代码。

一看到是 runtime 包中的函数我就知道没戏,肯定看不懂了,不得不再换一个思路。会不会是读写数据库失败,我又图省事没有在监控程序中设置超时,导致进程死等数据库呢?

于是,我又用 strace 观察了一下系统调用的情况:

futex(0x4ec620, FUTEX_WAIT_PRIVATE, 0, NULL) = 0
--- SIGURG {si_signo=SIGURG, si_code=SI_TKILL, si_pid=19723, si_uid=1000} ---
rt_sigreturn({mask=[]})                 = -6148914672793933123

只有不停出现的 futexSIGURG,完全没有出现 readwrite。再次不知道如何下手了。

最后,我只好反复运行这个监控程序,竟一次也没有复现出一直运行不退出的情况。彻底陷入僵局了。

求助大神

好在我有个朋友,人称“李老师”,他最近刚刚还写了一本 Go 语言的书Go底层原理与工程化实践》。李老师一直热爱钻研技术,于是我打算向他请教这个“疑难杂症”的解法。

以下是我与李老师的对话(略加整理):

:李老师,遇到个奇怪的问题。Go 程序,也没有死循环、等锁操作之类的操作,有一定概率一直执行不退出。而且一直在发 SIGURG 的信号。是 crontab 执行的,不是每次必现

李老师:Go 这个信号是用来抢占调度的,就是某一个协程执行时间太长了,辅助线程 sysmon 会发这个信号,然后让调度器调度其他协程。不过,确定没有死循环吗?或者是某个循环执行时间太长了?

:没有死循环(此时我心里想,就这么简单一段监控程序,我也写了这么多年代码了,还能写出死循环来)。哦对,代码里有个读写 DB 的地方,可能没给超时时间

李老师:读写 DB 一般不会导致这种情况。这种情况是因为协程一直在执行,并没有阻塞,当然这就意味着,应该没有管道读写,网络,系统调用等。能通过 pprof 看看热点代码么,试试能不能看到哪里执行时间过长?

:哦,我再把 pprof 加上看看。不过刚刚 dlv 看过了,没啥头绪

李老师:dlv不适合找这种问题

还真是死循环

加上了 pprof 之后,还真发现了一个死循环!

// $ go tool pprof http://localhost:6060/debug/pprof/profile?seconds=60
(省略……)
(pprof) top
Showing nodes accounting for 56.50s, 99.70% of 56.67s total
Dropped 27 nodes (cum <= 0.28s)
      flat  flat%   sum%        cum   cum%
    55.34s 97.65% 97.65%     56.50s 99.70%  main.main
     1.16s  2.05% 99.70%      1.16s  2.05%  runtime.asyncPreempt
         0     0% 99.70%     56.50s 99.70%  runtime.main
(pprof) list main.main
Total: 56.67s
ROUTINE ======================== main.main in /home/vagrant/high_cpu.go
    55.34s     56.50s (flat, cum) 99.70% of Total
(省略……)
         .          .     26:	endHour := now.Hour()
         .          .     27:	endHour = 23
         .          .     28:	startHour := startTime.Hour()
         .          .     29:
    55.34s     56.50s     30:	for h := startHour; h%24 != endHour+1; h++ {
(省略……)
for h := startHour; h%24 != endHour+1; h++ {

这里还特意考虑到跨天的情况了,比如当前时间是凌晨 1:32(即 endHour == 1) ,我要监控最近 4 个小时(即 22、23、0、1)的数据, 那么 h 的初始值为 22。然后依次自增为 23242526。由于 % 24 了,所以从 24 开始是 012,这几个值与 endHour+1(值为 2 比较),都没有问题。

但如果当前时间在 23:00~23:59 之间会怎么样呢?此时,!= 右侧的 endHour+1 就是 24 了,而任何数 % 24 的结果必然在 [0, 23] 之间。也就是说,h%24 != 24 是永远成立的!这不就死循环了吗?之前都是在白天调试的这个程序,不过,就算刚好在夜里11点测试,可能一时也找不到问题。

哎,看看人家李老师的直觉,上来就觉得是死循环。

随后,我又找到了李老师,

:李老师,我知道咋回事了,特别 low 的一个问题……(略)

李老师:哦哦这啊,这代码确实不好看出来。遇到这类问题,多尝试用一些工具分析分析,有些时候只通过代码和日志很难看出来

:是啊,瞪眼法不灵啊


修复完 Bug,我打开了京东,浏览起李老师的大作《Go底层原理与工程化实践》的目录……

值得一提的是,本书的另一位作者陈雷还撰写过《PHP 7底层设计与源码实现》《Redis 5设计与源码分析

Go底层原理与工程化实践u.jd.com/esXoQNZ

WX20240722-123108@2x

目录

...

第2章 Go语言并发模型

2.1 GMP调度模型

2.2 协程管理

2.3 调度器

2.3.1 调度器实现原理

2.3.2 时间片调度

2.3.3 基于协作的抢占式调度

2.3.4 基于信号的抢占式调度

“2.3.4 基于信号的抢占式调度”,嘿,这是不是就是死循环时不断发送的那个 SIGURG 信号啊,runtime.schedule() 这函数名也是“调度”的意思,于是我毫不犹豫地点击了“加入购物车”按钮。

Go底层原理与工程化实践u.jd.com/esXoQNZ