自從項目接入了 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顯然是一個不符合預期的地址,而隨意訪問這種地址,可能會引爆更大的雷。
所以,我們不得不對這個問題做更多分析。
穩定復現
剛才的線索中,我們得到了兩個重要信息:
- NSMapTable 是問題的來源
- 一個莫名其妙的數被當成了對象的地址
已知的是,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 為空時,至少能說明它不會強引用存儲的元素??梢灾苯臃艞壉闅v其內部的元素。
驗證
我們已經通過獲取 acquireFunction 達成了如上推斷,為了進一步驗證,我們用 Hopper 查看了逆向出來的偽代碼,發現至少在 iOS 12.3.1 上,我們的推測是正確的。
我們不能保證 NSPointerFunctionsOpaqueMemory 的容器的 acquireFunction 在任何版本的 iOS 上都是空,但是增加對 acquireFunction 的判斷好過什么也不做。
提交
這個修復被首先提交到了項目中進行驗證。證明修復有效后,我們給開源的 FBRetainCycleDetector 提交了同樣的修復:
https://github.com/facebook/FBRetainCycleDetector/pull/79
同時在 FBRetainCycleDetector 的單元測試里增加了必現問題1的case。
總結
在這個問題發現的初期,我抱著絕望的態度,認為開源庫中的 crash 必然難解。但是事實證明,通過收集線索、耐心分析問題、制造必現場景、理解 root cause、大膽假設小心驗證,問題依然是有機會解決的。