自從項目接入了 MLeaksFinder + FBRetainCycleDetector 的內(nèi)存泄漏檢測方案切距,在收獲了許多有效內(nèi)存泄漏的同時,我們也收獲了兩個 FBRetainCycleDetector 的 crash丢胚。
首先拋出這兩個 crash 的調(diào)用棧:
問題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 出品的尋找循環(huán)引用的工具。簡單來說,它通過class_copyIvarList
獲取一個類的實例變量列表乖订,使用class_getIvarLayout
判定是實例變量是否為強引用终息,然后使用有向圖中找環(huán)的算法夺巩,獲取循環(huán)引用的引用環(huán)。
光從調(diào)用棧上來看周崭,我們對這一問題沒有頭緒柳譬。首先,這兩個 crash 并非必現(xiàn)续镇;其次美澳,從崩潰用戶的行為上看,也沒有發(fā)現(xiàn)共性。
作為一個 facebook 出品制跟,經(jīng)過了多年驗證的三方庫柴墩,我們判斷這兩個 crash 并非一般的代碼邏輯問題。解決這兩個問題看起來會是一個挑戰(zhàn)凫岖。
錯誤的判斷
一開始我們以為問題 1 是一個多線程的問題江咳,因為 FBRetainCycleDetector 有一段注釋,表明它的確可能存在多線程問題哥放,只是用 try catch 嘗試縮小它的影響歼指。
同時,問題 1 的調(diào)用棧中甥雕,的確有多個線程在進行找環(huán)操作踩身。
我們曾嘗試通過將并發(fā)隊列改為串行隊列的方式修復(fù)問題1,但是并未修好社露。
線索
來自 github issue
遇到疑難問題挟阻,特別是開源庫的問題,我們迅速反應(yīng)出峭弟,去網(wǎng)絡(luò)上嘗試尋找解決方案附鸽。
https://github.com/facebook/FBRetainCycleDetector/issues/60#issuecomment-503511056
從 FBRetainCycleDetector 的 github issue 上,我們發(fā)現(xiàn)了一個與問題1類似的問題描述瞒瘸。其中坷备,提問者提到,這是遍歷 NSMapTable 時遇到的情臭。
NSMapTable 是我們獲得的第一個線索省撑。
一次偶然的復(fù)現(xiàn)
同時,我們在調(diào)試時俯在,也偶然復(fù)現(xiàn)了一次問題2竟秫。這次復(fù)現(xiàn)給了我們關(guān)鍵的信息。
當(dāng)時的現(xiàn)場是跷乐,正在找環(huán)過程中的object對象變成了一個指向0xffffffffffffffff地址的指針肥败,而這個指針通過object_getClass竟然能取到對應(yīng)的類,對應(yīng)的類是__NSAtom
劈猿。
__NSAtom
顯然是一個私有類拙吉,而且它不繼承自NSObject
,沒有isSubclassOfClass:
方法揪荣,所以執(zhí)行到這里的時候筷黔,觸發(fā)消息轉(zhuǎn)發(fā)最后EXC_BREAKPOINT
了。
此時仗颈,我們想到了一個最簡單的修復(fù)方式:在這里繞過isSubclassOfClass:
方法佛舱,使用 runtime 的 API class_getSuperclass
來達到判斷是否是子類的目的椎例。
但是,不查明這個0xffffffffffffffff的由來请祖,只修復(fù)問題的表面订歪,也讓我們心虛。0xffffffffffffffff顯然是一個不符合預(yù)期的地址肆捕,而隨意訪問這種地址刷晋,可能會引爆更大的雷。
所以慎陵,我們不得不對這個問題做更多分析眼虱。
穩(wěn)定復(fù)現(xiàn)
剛才的線索中,我們得到了兩個重要信息:
- NSMapTable 是問題的來源
- 一個莫名其妙的數(shù)被當(dāng)成了對象的地址
已知的是席纽,NSMapTable 作為一個功能更強大的容器捏悬,不僅僅可以存放對象,還能存放一個簡單的數(shù)字润梯。所以过牙,我們嘗試用 NSMapTable 來穩(wěn)定復(fù)現(xiàn)問題2。
復(fù)現(xiàn)的方式其實很簡單纺铭。
創(chuàng)建一個NSPointerFunctionsOpaqueMemory類型的容器寇钉,往容器里塞入 -1 這個數(shù),也就是 0xffffffffffffffff彤蔽,然后讓這個容器被找環(huán)算法遍歷到摧莽。
xsqView.table = [[NSMapTable alloc] initWithKeyOptions:NSPointerFunctionsOpaqueMemory | NSPointerFunctionsIntegerPersonality valueOptions:0 capacity:0];
NSInteger i = -1;
[xsqView.table setObject:@"hahaha" forKey:(__bridge id)((void *)i)];
問題2被復(fù)現(xiàn)了出來庙洼。
而將這個數(shù)從 -1 改到 1顿痪,我們發(fā)現(xiàn)問題1也成了必現(xiàn)。
問題1和問題2油够,預(yù)期是同一個本質(zhì)問題引起的蚁袭。
分析問題1
穩(wěn)定復(fù)現(xiàn)后,問題1的分析變得順利了起來石咬。
數(shù)字 “1” 被 FBRetainCycleDetector 遍歷到的時候揩悄,F(xiàn)BRetainCycleDetector 使用了__strong 的 id 類型修飾它,導(dǎo)致運行時被調(diào)用了 _objc_retain
鬼悠,因此導(dǎo)致了 BAD ACCESS删性。
如果將這里的 id ,和 FBWrapObjectGraphElement 函數(shù)參數(shù)中的 id焕窝,都修改為 __unsafe_unretained id蹬挺,這個 crash 堆棧立馬變到了下一處對數(shù)字 “1” 進行強引用的地方。
所以問題1的本質(zhì)原因被找到且證明了它掂。FBRetainCycleDetector 并沒有考慮到 NSPointerFunctionsOpaqueMemory 類型的容器巴帮,將容器內(nèi)的元素都當(dāng)作了對象來對待導(dǎo)致了問題1。
分析問題2
問題1的分析比較容易。但為什么將數(shù)字 "1" 改成 "-1" 后榕茧,問題1中 BAD ACCESS 的代碼被順利走過了垃沦,crash 堆棧變成了問題2呢?
搜索了一些資料用押,發(fā)現(xiàn)這是 tagged pointer 搞的鬼肢簿。
簡單說,計算機中有內(nèi)存對齊的說法蜻拨,因此正常的指針译仗,在 64 位設(shè)備上,最后 4 bit 必然是0官觅。如果最后 4 bit 不是 0纵菌,說明這不是一個正常的指針。這個特性被蘋果用于了tagged pointer休涤。
( http://www.phrack.org/issues/69/9.html 我從這篇博客里了解了一下tagged pointer)
由于一個對象的結(jié)構(gòu)體中的第一個成員是 isa 指針咱圆,因此,如果 0xffffffffffffffff 被當(dāng)作了一個對象功氨,那么它實際也被當(dāng)作了一個 isa 指針的值序苏。而顯然,這個 isa 指針還是個 tagged pointer捷凄。
如果一個 isa 指針是一個 tagged pointer 的話忱详,它找到的 Class 的過程中會經(jīng)歷一個映射。經(jīng)過映射跺涤,它最后可以被翻譯為某一個屬于 TaggerPointer 的類匈睁,比如 __NSAtom。所以桶错,就出現(xiàn)了問題 2 中的崩潰棧航唆。
(我們可以從開源的runtime代碼中了解映射的過程https://opensource.apple.com/source/objc4/objc4-551.1/runtime/objc-private.h)
問題根源
其實,通過分析 FBRetainCycleDetector 的找環(huán)邏輯院刁,我們會發(fā)現(xiàn)糯钙,這些 “數(shù)字” 本來就不應(yīng)該被遍歷到。
因為存儲了 NSPointerFunctionsOpaqueMemory 元素的容器退腥,容器持有容器內(nèi)元素的關(guān)系任岸,并不是強引用。
FBRetainCycleDetector 其實也考慮到了這點狡刘,它有一個方法來判斷容器是不是強引用:
但是對于 NSPointerFunctionsOpaqueMemory 的容器享潜,usesWeakReadAndWriteBarriers 屬性返回的是 NO,所以被誤判成了強引用颓帝。
解決
NSPointerFunctions 沒有開放接口判斷它的 option 是什么米碰∥迅铮看起來我們無法分辨出 NSPointerFunctions 與元素的引用關(guān)系。但是在分析了 NSPointerFunctions 的接口文檔后吕座,我們發(fā)現(xiàn)了一個 trick 但合理的方案虐译,就是利用它的 acquireFunction 屬性。
官方文檔是這樣描述 acquireFunction 屬性的吴趴。
The function used to acquire memory.
This specifies the function to use for copy-in operations.
這個屬性是一個函數(shù)指針漆诽,當(dāng)一個值被存入容器時,會調(diào)用這個函數(shù)锣枝,按需去 retain 這個即將被存入容器的元素厢拭。
我們做了個實驗了。如果 option 是 NSPointerFunctionsStrongMemory撇叁,則這個 acquireFunction 是系統(tǒng)提供的函數(shù)供鸠,如果 option 是 NSPointerFunctionsOpaqueMemory,這個 acquireFunction 是空陨闹。
這個結(jié)果很好理解楞捂,也符合正常程序員的設(shè)計思路,當(dāng)容器不想對存入的值做內(nèi)存上的操作趋厉,什么也不干就行了寨闹。
所以我們可以推斷,如果 acquireFunction 為空君账,說明這個容器并不會對元素的引用計數(shù)去 +1繁堡,這說明對元素的引用關(guān)系,并非是強引用乡数。
當(dāng)然這個論斷反過來并不能推定椭蹄。
所以我們可以在 FBRetainCycleDetector 的邏輯里加一個判斷:
當(dāng)容器的 NSPointerFunctions 的 acquireFunction 為空時,至少能說明它不會強引用存儲的元素瞳脓∷芙浚可以直接放棄遍歷其內(nèi)部的元素。
驗證
我們已經(jīng)通過獲取 acquireFunction 達成了如上推斷劫侧,為了進一步驗證,我們用 Hopper 查看了逆向出來的偽代碼哨啃,發(fā)現(xiàn)至少在 iOS 12.3.1 上烧栋,我們的推測是正確的。
我們不能保證 NSPointerFunctionsOpaqueMemory 的容器的 acquireFunction 在任何版本的 iOS 上都是空拳球,但是增加對 acquireFunction 的判斷好過什么也不做审姓。
提交
這個修復(fù)被首先提交到了項目中進行驗證。證明修復(fù)有效后祝峻,我們給開源的 FBRetainCycleDetector 提交了同樣的修復(fù):
https://github.com/facebook/FBRetainCycleDetector/pull/79
同時在 FBRetainCycleDetector 的單元測試里增加了必現(xiàn)問題1的case魔吐。
總結(jié)
在這個問題發(fā)現(xiàn)的初期扎筒,我抱著絕望的態(tài)度,認為開源庫中的 crash 必然難解酬姆。但是事實證明嗜桌,通過收集線索、耐心分析問題辞色、制造必現(xiàn)場景骨宠、理解 root cause、大膽假設(shè)小心驗證相满,問題依然是有機會解決的层亿。