(一)背景
MyTopling 是基于 ToplingDB 的 MySQL,分叉自 MyRocks,ToplingDB 则分叉自 RocksDB,兼容 RocksDB 接口,从而 MyTopling 可以复用 MyRocks 的大部分成果。
ToplingDB 早已开源,MyTopling 也将于近期开源。
(二)现象:分布式 Compact 超时
大家可以先参考《MyTopling 分布式 Compact(一):从多线程到多进程》,然后接着看:
在我们的测试中,MyTopling 的分布式 Compact Worker 一直有一个问题:经常跑着跑着,Compact Worker 就假死了,这个问题之前在多线程模型中也偶尔出现,当时经过痛苦的排错定位,最后发现是 NFS (客户端) 超时引起的,这个概率虽然很低,但有时会严重到接近卡死。
既然是 NFS 问题,所以我们除了骂娘也无能为力,不过好在 Compact Worker 是无状态计算,这个问题反映到 DB 端只是一个分布式 Compact 任务超时,换一个结点重试即可。
后来在多进程模型中,碰到假死我们就直接重启进程。直到昨天晚上,我忽然意识到,以前多线程的时候,假死的频率很低,而现在多进程,跑上几个小时,几乎必然假死……
(三)重新分析
在多线程模型下,Debug 比较容易,直接挂上 gdb 就行,但是在多进程模型下,每个 Compact Job 都会 fork 出一个子进程,并且这个子进程很快就执行结束了。所以我们 Debug 的时候,将其设为多线程模型,因为不管是多进程还是多线程,代码都是复用的,当然用更省力的方式 Debug。
但是现在我们是要定位为何多进程时假死的概率要高出这么多,那就必须按多进程 Debug……
把 Compact Worker 集群限制到仅一个结点,打高压力,跑了六个多小时,假死得凉凉的!开始用 gdb 逐个查看进程,然后惊奇地发现,所有的 Compact Job 进程都卡在 __tz_convert 中:
而我们的 StrDateTimeNow 并没有调用 __tz_convert,然后发现,__tz_convert 是在 localtime 函数中调用的……
显而易见,__lll_lock_wait_private 是等在 mutex 上了,定位到这里,一下就知道原因了,几乎所有的最佳实践都强烈反对混用多线程和多进程,而我们这里的父进程偏偏就是多线程,于是在 fork 子进程时,意外就发生了,在同一时刻,发生了两件事情:
父进程的线程 1 在 __tz_convert 中拿到了 mutex lock
父进程的线程 2 执行 fork 产生子进程 A
因为 fork 在语义上是父进程的一个拷贝,所以在子进程 A 中,__tz_convert 中的那个 mutex 是处于 lock 状态的。在父进程中,线程 1 在 __tz_convert 中拿到 mutex lock,干完事情就会 unlock,然而子进程 A 中却并没有父进程中的线程 1,所以那个 mutex 就永远处于 lock 状态……
(四)解决问题
骂 glibc 的娘是必不可少的:有无数种方法可以避免 localtime 这样的函数使用全局锁,但它偏不!
骂娘归骂娘,问题是必须要解决的,稍微搜索一下就发现,不止我们碰到了该问题(关键词 localtime "fork"),我们复用了 redis 的解决方案,稍作修改之后,一并给上游 RocksDB 发了个 Pull Request #10652。
(五)讨论
最佳实践都说不要混用多进程与多线程,但是现实世界不是乌托邦,面对某些问题,我们必须采取非常手段,自然也就容易掉坑。就这个坑而言,glibc 甚至 posix 标准有义务把它填平,最低限度,也得提供一个内部无锁的 localtime 版本。
最后,大家可以讨论一下:这到底是个 Bug,还是个 Feature 呢?