zap 日志库的死锁问题
前言
之前写了个日志处理的服务,用到了鼎鼎大名的 uber-go/zap 库落盘日志文件,但总会有疑似协程泄露的问题。
最近对这个服务做了重构,加了些日志协助定位,发现竟然是 zap 库的一个 Bug 导致协程阻塞,一直无法正常退出,进而带来之前的协程泄露问题。
问题细节
已经有人在这个 Issue 里,把问题的原因分析写得挺清楚了,这里再简单阐述下。
项目需要使用 zap
的 BufferedWriteSyncer
去落盘日志文件,它内部有一个常驻的 flushLoop
方法,会定期执行 Sync
方法把缓冲区的日志写入文件中。
1 | // flushLoop flushes the buffer at the configured interval until Stop is |
Sync
方法开始执行时,会先去获取锁,然后再调用内部 writer
的 Flush
方法。
1 | // Sync flushes buffered log data into disk directly. |
当我们调用 BufferedWriteSyncer
的 Stop
方法时,它有三个较关键的逻辑:
- 获取锁
- 通过关闭
stop
通道,通知flushLoop
退出 - 等待
flushLoop
退出
1 | // Stop closes the buffer, cleans up background goroutines, and flushes |
整个流程乍一看没啥大毛病,我之前排查问题的时候,也多次粗看过这部分代码,心想这么一个业界通用的库,应该这点逻辑不会存在啥大问题。但直到我在 Stop
前后都打了日志,问题再次出现时拿到了十足的证据,才确定是 zap
库的逻辑问题。
这里的关键问题在于flushLoop
里 for
循环里的两个 case
触发顺序,当以下情况发生时:
Stop
方法内部先取得了锁,然后通知flushLoop
退出flushLoop
先进入了ticker
的分支逻辑,然后调用Sync
方法Sync
方法尝试获取锁(但这个锁目前还未被Stop
方法释放)
Stop
方法在等 flushLoop
退出,而 flushLoop
调用的 Sync
方法在等 Stop
释放锁,进而导致死锁。
在实际应用时,flushLoop
大部分情况下会先取到 stop
的信号,然后正常退出,所以给这个问题的发生具有偶然性而且概率不高。
问题解决
官方修复的思路其实比较直接,详情可以查看 PR。
大概思路是,Stop
的锁逻辑主要是为正常读写其内部变量 stopped
,在写完 stopped
变量之后及时把锁释放,然后再等待 flushLoop
退出。
如果是我,我可能会另外加一个状态 stopping
用来描述当前处于正在停止的状态,只有 Stop
和 flushLoop
都正常退出了,才将 stopped
置为 true
。
小结
Golang 的 goroutine 让异步逻辑/多线程(协程)开发变得容易,但在处理多协程的时序上,依然很容易踩坑,比如 一个 docker logs 问题引发的系列探索 这里讲到的也是类似的问题。
所以在处理多协程/超时逻辑等问题上要尤其小心,避免不必要的协程泄露问题;另外在排查问题时,不要迷信某些库,他们其实也会犯错误。