为了监控线上的服务,我用 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
只有不停出现的 futex
和 SIGURG
,完全没有出现 read
和 write
。再次不知道如何下手了。
最后,我只好反复运行这个监控程序,竟一次也没有复现出一直运行不退出的情况。彻底陷入僵局了。
求助大神
好在我有个朋友,人称“李老师”,他最近刚刚还写了一本 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
。然后依次自增为 23
、24
、25
、26
。由于 % 24
了,所以从 24
开始是 0
、1
和 2
,这几个值与 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
目录
...
第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