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 為空時,至少能說明它不會強引用存儲的元素??梢灾苯臃艞壉闅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、大膽假設小心驗證,問題依然是有機會解決的。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,156評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,401評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 176,069評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,873評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,635評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,128評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,203評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,365評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,881評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,733評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,935評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,475評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,172評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,582評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,821評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,595評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,908評論 2 372

推薦閱讀更多精彩內容

  • 版權聲明本文轉自網易杭州前端技術部公眾號,由作者授權發布。 前言 大白(Baymax),迪士尼動畫《超能陸戰隊》中...
    XueYongWei閱讀 2,045評論 2 11
  • 卷首語 歡迎來到 objc.io 第七期! 這個月,我們選擇了 Foundation 框架作為我們的主題。 Fou...
    評評分分閱讀 1,549評論 0 8
  • 本文基于objc4-709源碼進行分析。關于源碼編譯:objc - 編譯Runtime源碼objc4-706 ob...
    WeiHing閱讀 842評論 1 3
  • 最近吳大叔和小三鬧得全網沸沸揚揚,前段時間陳羽凡吸毒出軌,著名主持人朱軍也被舉報性騷擾而在打官司,雖還未下...
    Miya姑娘閱讀 157評論 0 0
  • 每次寫007的文章都很倉促,所以文章質量其實并不高。把過去的文字一一看過,覺得即使這樣,一個機制下自己能堅持地記錄...
    金笛Jindi閱讀 125評論 0 2