FBRetainCycleDetector遇到NSMapTable的crash - 从发现到PR

自从项目接入了 MLeaksFinder + FBRetainCycleDetector 的内存泄漏检测方案,在收获了许多有效内存泄漏的同时,我们也收获了两个 FBRetainCycleDetector 的 crash。

首先抛出这两个 crash 的调用栈:

问题1:

Crashed: com.mapp.cycleDetector
0  libobjc.A.dylib                0x1903be058 objc_retain + 8
1  MAppInHouse                    0x10594e1ac FBWrapObjectGraphElement + 64 (FBRetainCycleUtils.m:64)
2  MAppInHouse                    0x10594c324 -[FBObjectiveCObject allRetainedObjects] + 83 (FBObjectiveCObject.m:83)
3  MAppInHouse                    0x10594a868 -[FBNodeEnumerator nextObject] + 34 (FBNodeEnumerator.mm:34)
4  MAppInHouse                    0x10594d0a8 -[FBRetainCycleDetector _findRetainCyclesInObject:stackDepth:] + 132 (FBRetainCycleDetector.mm:132)
5  MAppInHouse                    0x10594caac -[FBRetainCycleDetector findRetainCyclesWithMaxCycleLength:] + 65 (FBRetainCycleDetector.mm:65)
6  MAppInHouse                    0x1061c4174 __55-[NSObject(MemoryLeak) checkRetainCycleWithCompletion:]_block_invoke.165 + 256 (NSObject+MemoryLeak.m:256)
7  libdispatch.dylib              0x190348678 _dispatch_call_block_and_release + 24
8  libdispatch.dylib              0x1903491ec _dispatch_client_callout + 16
9  libdispatch.dylib              0x19032675c _dispatch_lane_serial_drain$VARIANT$armv81 + 564
10 libdispatch.dylib              0x190327178 _dispatch_lane_invoke$VARIANT$armv81 + 404
11 libdispatch.dylib              0x1903304bc _dispatch_workloop_worker_thread + 576
12 libsystem_pthread.dylib        0x190398f5c _pthread_wqthread + 304
13 libsystem_pthread.dylib        0x19039baa0 start_wqthread + 8

问题2:

Crashed: com.mapp.cycleDetector
0  CoreFoundation                 0x21f4c37e0 ___forwarding___ + 1448
1  CoreFoundation                 0x21f4c546c _CF_forwarding_prep_0 + 92
2  MAppInHouse                    0x1018e70d4 FBWrapObjectGraphElementWithContext + 43 (FBRetainCycleUtils.m:43)
3  MAppInHouse                    0x1018e72f4 FBWrapObjectGraphElement + 65 (FBRetainCycleUtils.m:65)
4  MAppInHouse                    0x1018e5454 -[FBObjectiveCObject allRetainedObjects] + 83 (FBObjectiveCObject.m:83)
5  MAppInHouse                    0x1018e3998 -[FBNodeEnumerator nextObject] + 34 (FBNodeEnumerator.mm:34)
6  MAppInHouse                    0x1018e61d8 -[FBRetainCycleDetector _findRetainCyclesInObject:stackDepth:] + 132 (FBRetainCycleDetector.mm:132)
7  MAppInHouse                    0x1018e5bdc -[FBRetainCycleDetector findRetainCyclesWithMaxCycleLength:] + 65 (FBRetainCycleDetector.mm:65)
8  MAppInHouse                    0x10215d2a4 __55-[NSObject(MemoryLeak) checkRetainCycleWithCompletion:]_block_invoke.165 + 256 (NSObject+MemoryLeak.m:256)
9  libdispatch.dylib              0x21eef56c8 _dispatch_call_block_and_release + 24
10 libdispatch.dylib              0x21eef6484 _dispatch_client_callout + 16
11 libdispatch.dylib              0x21eed0fa0 _dispatch_lane_serial_drain$VARIANT$armv81 + 548
12 libdispatch.dylib              0x21eed1ae4 _dispatch_lane_invoke$VARIANT$armv81 + 412
13 libdispatch.dylib              0x21eed9f04 _dispatch_workloop_worker_thread + 584
14 libsystem_pthread.dylib        0x21f0d90dc _pthread_wqthread + 312
15 libsystem_pthread.dylib        0x21f0dbcec start_wqthread + 4

FBRetainCycleDetector 是 facebook 出品的寻找循环引用的工具。简单来说,它通过class_copyIvarList获取一个类的实例变量列表,使用class_getIvarLayout判定是实例变量是否为强引用,然后使用有向图中找环的算法,获取循环引用的引用环。

光从调用栈上来看,我们对这一问题没有头绪。首先,这两个 crash 并非必现;其次,从崩溃用户的行为上看,也没有发现共性。

作为一个 facebook 出品,经过了多年验证的三方库,我们判断这两个 crash 并非一般的代码逻辑问题。解决这两个问题看起来会是一个挑战。

错误的判断

一开始我们以为问题 1 是一个多线程的问题,因为 FBRetainCycleDetector 有一段注释,表明它的确可能存在多线程问题,只是用 try catch 尝试缩小它的影响。

同时,问题 1 的调用栈中,的确有多个线程在进行找环操作。

我们曾尝试通过将并发队列改为串行队列的方式修复问题1,但是并未修好。

线索

来自 github issue

遇到疑难问题,特别是开源库的问题,我们迅速反应出,去网络上尝试寻找解决方案。

https://github.com/facebook/FBRetainCycleDetector/issues/60#issuecomment-503511056

从 FBRetainCycleDetector 的 github issue 上,我们发现了一个与问题1类似的问题描述。其中,提问者提到,这是遍历 NSMapTable 时遇到的。

NSMapTable 是我们获得的第一个线索。

一次偶然的复现

同时,我们在调试时,也偶然复现了一次问题2。这次复现给了我们关键的信息。

当时的现场是,正在找环过程中的object对象变成了一个指向0xffffffffffffffff地址的指针,而这个指针通过object_getClass竟然能取到对应的类,对应的类是__NSAtom

__NSAtom显然是一个私有类,而且它不继承自NSObject,没有isSubclassOfClass:方法,所以执行到这里的时候,触发消息转发最后EXC_BREAKPOINT了。

此时,我们想到了一个最简单的修复方式:在这里绕过isSubclassOfClass:方法,使用 runtime 的 API class_getSuperclass 来达到判断是否是子类的目的。

但是,不查明这个0xffffffffffffffff的由来,只修复问题的表面,也让我们心虚。0xffffffffffffffff显然是一个不符合预期的地址,而随意访问这种地址,可能会引爆更大的雷。

所以,我们不得不对这个问题做更多分析。

稳定复现

刚才的线索中,我们得到了两个重要信息:

  1. NSMapTable 是问题的来源
  2. 一个莫名其妙的数被当成了对象的地址

已知的是,NSMapTable 作为一个功能更强大的容器,不仅仅可以存放对象,还能存放一个简单的数字。所以,我们尝试用 NSMapTable 来稳定复现问题2。

复现的方式其实很简单。

创建一个NSPointerFunctionsOpaqueMemory类型的容器,往容器里塞入 -1 这个数,也就是 0xffffffffffffffff,然后让这个容器被找环算法遍历到。

xsqView.table = [[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsOpaqueMemory | NSPointerFunctionsIntegerPersonality valueOptions:0 capacity:0];
NSInteger i = -1;
[xsqView.table setObject:@"hahaha" forKey:(__bridge id)((void *)i)];

问题2被复现了出来。

而将这个数从 -1 改到 1,我们发现问题1也成了必现。

问题1和问题2,预期是同一个本质问题引起的。

分析问题1

稳定复现后,问题1的分析变得顺利了起来。

数字 “1” 被 FBRetainCycleDetector 遍历到的时候,FBRetainCycleDetector 使用了__strong 的 id 类型修饰它,导致运行时被调用了 _objc_retain,因此导致了 BAD ACCESS。

如果将这里的 id ,和 FBWrapObjectGraphElement 函数参数中的 id,都修改为 __unsafe_unretained id,这个 crash 堆栈立马变到了下一处对数字 “1” 进行强引用的地方。

所以问题1的本质原因被找到且证明了。FBRetainCycleDetector 并没有考虑到 NSPointerFunctionsOpaqueMemory 类型的容器,将容器内的元素都当作了对象来对待导致了问题1。

分析问题2

问题1的分析比较容易。但为什么将数字 "1" 改成 "-1" 后,问题1中 BAD ACCESS 的代码被顺利走过了,crash 堆栈变成了问题2呢?

搜索了一些资料,发现这是 tagged pointer 搞的鬼。

简单说,计算机中有内存对齐的说法,因此正常的指针,在 64 位设备上,最后 4 bit 必然是0。如果最后 4 bit 不是 0,说明这不是一个正常的指针。这个特性被苹果用于了tagged pointer。

http://www.phrack.org/issues/69/9.html 我从这篇博客里了解了一下tagged pointer)

由于一个对象的结构体中的第一个成员是 isa 指针,因此,如果 0xffffffffffffffff 被当作了一个对象,那么它实际也被当作了一个 isa 指针的值。而显然,这个 isa 指针还是个 tagged pointer。

如果一个 isa 指针是一个 tagged pointer 的话,它找到的 Class 的过程中会经历一个映射。经过映射,它最后可以被翻译为某一个属于 TaggerPointer 的类,比如 __NSAtom。所以,就出现了问题 2 中的崩溃栈。

(我们可以从开源的runtime代码中了解映射的过程https://opensource.apple.com/source/objc4/objc4-551.1/runtime/objc-private.h

问题根源

其实,通过分析 FBRetainCycleDetector 的找环逻辑,我们会发现,这些 “数字” 本来就不应该被遍历到。

因为存储了 NSPointerFunctionsOpaqueMemory 元素的容器,容器持有容器内元素的关系,并不是强引用。

FBRetainCycleDetector 其实也考虑到了这点,它有一个方法来判断容器是不是强引用:


但是对于 NSPointerFunctionsOpaqueMemory 的容器,usesWeakReadAndWriteBarriers 属性返回的是 NO,所以被误判成了强引用。

解决

NSPointerFunctions 没有开放接口判断它的 option 是什么。看起来我们无法分辨出 NSPointerFunctions 与元素的引用关系。但是在分析了 NSPointerFunctions 的接口文档后,我们发现了一个 trick 但合理的方案,就是利用它的 acquireFunction 属性。

官方文档是这样描述 acquireFunction 属性的。

The function used to acquire memory.

This specifies the function to use for copy-in operations.

这个属性是一个函数指针,当一个值被存入容器时,会调用这个函数,按需去 retain 这个即将被存入容器的元素。

我们做了个实验了。如果 option 是 NSPointerFunctionsStrongMemory,则这个 acquireFunction 是系统提供的函数,如果 option 是 NSPointerFunctionsOpaqueMemory,这个 acquireFunction 是空。

这个结果很好理解,也符合正常程序员的设计思路,当容器不想对存入的值做内存上的操作,什么也不干就行了。

所以我们可以推断,如果 acquireFunction 为空,说明这个容器并不会对元素的引用计数去 +1,这说明对元素的引用关系,并非是强引用。

当然这个论断反过来并不能推定。

所以我们可以在 FBRetainCycleDetector 的逻辑里加一个判断:

当容器的 NSPointerFunctions 的 acquireFunction 为空时,至少能说明它不会强引用存储的元素。可以直接放弃遍历其内部的元素。

验证

我们已经通过获取 acquireFunction 达成了如上推断,为了进一步验证,我们用 Hopper 查看了逆向出来的伪代码,发现至少在 iOS 12.3.1 上,我们的推测是正确的。

我们不能保证 NSPointerFunctionsOpaqueMemory 的容器的 acquireFunction 在任何版本的 iOS 上都是空,但是增加对 acquireFunction 的判断好过什么也不做。

提交

这个修复被首先提交到了项目中进行验证。证明修复有效后,我们给开源的 FBRetainCycleDetector 提交了同样的修复:

https://github.com/facebook/FBRetainCycleDetector/pull/79

同时在 FBRetainCycleDetector 的单元测试里增加了必现问题1的case。

总结

在这个问题发现的初期,我抱着绝望的态度,认为开源库中的 crash 必然难解。但是事实证明,通过收集线索、耐心分析问题、制造必现场景、理解 root cause、大胆假设小心验证,问题依然是有机会解决的。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容

  • 版权声明本文转自网易杭州前端技术部公众号,由作者授权发布。 前言 大白(Baymax),迪士尼动画《超能陆战队》中...
    XueYongWei阅读 2,015评论 2 11
  • 卷首语 欢迎来到 objc.io 第七期! 这个月,我们选择了 Foundation 框架作为我们的主题。 Fou...
    评评分分阅读 1,514评论 0 8
  • 本文基于objc4-709源码进行分析。关于源码编译:objc - 编译Runtime源码objc4-706 ob...
    WeiHing阅读 809评论 1 3
  • 最近吴大叔和小三闹得全网沸沸扬扬,前段时间陈羽凡吸毒出轨,著名主持人朱军也被举报性骚扰而在打官司,虽还未下...
    Miya姑娘阅读 156评论 0 0
  • 每次写007的文章都很仓促,所以文章质量其实并不高。把过去的文字一一看过,觉得即使这样,一个机制下自己能坚持地记录...
    金笛Jindi阅读 123评论 0 2