今天在回顾公司项目的一个 Crash 报告时,突然想明白它是怎么发生的了。这个问题隐藏在复杂设计的背后,不容易发现,但理清楚以后却又这么简单,这让我直接有了感悟:不要做复杂的设计,同时对底层机制要保持清楚的认识。
让我简单的来描述下这个 Crash 的来龙去脉吧。
这个问题,从用户的角度来表现是这样的:
一个客户端程序,用户打开它以后,它自动刷新内容,在刷新的过程中,用户轻触了某个条目点进去看细节。这个时候,非常低的几率会 crash。
而这个动作的背后后,主要是有 ReactiveCocoa 和 CoreData 在做事情:
刷新内容的时候,CoreData 会在自己的 Queue 里面对从网络上获取到的数据进行本地持久化,完成后会更新 UI;而用户点击某个具体条目时,也是在 CoreData 的 Queue 里面判断条目的评论是不是都获取了,如果没有,则进行网络获取,不同的是,在这里获取后会进行内容渲染(在 ReactiveCocoa 的代码里,需要回到主线程),获取和渲染都完成后,发送 RACSignal 信号并进行 UI 的更新。
刚接手这个项目接触这段代码时,我也觉得有点复杂,不过它一直没出过什么问题,也就不在意了。
后来当我在项目里集成了 Crash 汇报机制后,偶尔收到的 Crash 报告让我引起了注意,这个 Crash 报告是,来自 CoreData 在存储对象时先查询这个对象之前是否存在的代码:
NSGenericException*** Collection <__NSCFSet: 0x1742769c0> was mutated while being enumerated.
这个 Crash 的意思是,在查询对象时,查询对象所在的区域正在被修改。但是我几次确认了代码,查询和存储 CoreData 对象的代码都是在 performBlock 块里面的,只有渲染内容时才会回到主线程,理应不会发生这样的问题。到底是怎么了?
今天我就突然顿悟了,根据下图作解释:
这里的线程情况有点迷惑人,直观看代码还以为只有 commentRenderSeq 和 entryRenderSeq 是 ReactiveCocoa 的 Signal,会跑在自己的线程里。实际上,还有图片箭头所指的 retrieveComments 也是一个 Signal,它通过 flattenMap 功能会创建渲染时的 Signal,它们的全部完成 commentRenderSeq 才算完成。
于是乎,在 CoreData 的 Private Queue 里面执行这个 retrieveComments 的时候,这个代码块实际执行块已经脱离当前 CoreData 的线程,跃迁至 ReactivieCocoa 自己维护的线程里面去干活了。这个时候,如果恰好有另外一个 CoreData 线程在修改数据,而这个 ReactiveCocoa 也在动相关数据,于是上述的 crash 问题造成了。
问题在我去看 Crash 报告时的所有线程情况时得到了验证(啊,如果我一早就养成检查所有线程情况的习惯,这个问题就能更快解决了)
这个 #8 是 Crash 的线程,如图所示,这个线程来自 ReactiveCocoa,在 CoreData 的 NSManagedObjectContext 查询时发生异常
与此同时,#4 线程,也就是通过 CoreData 的 performBlock 里自己维护的一条独立线程,在这个时候它也在 perform 数据,于是就产生了前面所述的两条线程同时修改数据造成的问题了。
怎么解决这个问题?重构这部分代码,放弃这个复杂的设计,用更简单直白的即可。
通过这个 Bug,我感悟到现在很多问题都是由多线程引起的,而我在 Debug 的时候却只看当前线程,没有养成查看所有线程的习惯,导致这个问题困扰我很久。幸好对 ReactiveCocoa 和 CoreData 的基本特性还算了解,知道它们都是会维护自己的线程并在里面做事,不然还真找不到这个问题。
最后,不要做复杂设计的,线程不是量子,不要跃迁三次以上!不然还真找不到 Bug :D