頭條穩定性治理:ARC 環境中對 Objective-C 物件賦值的 Crash 隱患

語言: CN / TW / HK

ARC 環境下在多執行緒中執行賦值程式碼可能會產生野指標,導致 EXC_BAD_ACCESS 崩潰。

這種崩潰發生的概率很低,在開發和灰度階段即使執行到相應程式碼也很難崩潰,因此容易遺漏到正式環境。在上億級使用者的 App 往往會成為 Top 問題,對指標造成影響,並且很難排查。

今日頭條在治理 Crash 的過程中徹底解決了數十個此類崩潰,發現其具有一定共性。本文詳細分析崩潰發生的過程,以及總結了容易出現問題的場景,希望在大家遇到此類問題時能提供一些思路。

1. 原理

Objective-C 物件的賦值過程包含建立新值、保留舊值、載入新值、釋放舊值四步。相比 MRC,ARC 環境中編譯器會自動插入保留與釋放舊值的步驟:

NSObject *_instance; void foo(void) {     _instance = [[NSObject alloc] init]; }

圖片

圖片

這點在 AutomaticReferenceCounting [1] 文件中有提到,通過彙編程式碼也可以分析:

圖片

圖片

objc_release 會減小物件的引用計數,減小到 0 時物件就會被銷燬,假如這時有其它執行緒正在使用這個物件,那麼使用物件的執行緒就很可能發生崩潰。

2. 崩潰場景

為了演示僅一行賦值程式碼就能造成崩潰,以及清晰地分析崩潰的原因,我設計了一個 Demo,在 B 執行緒中釋放 A 執行緒建立的物件使 C 執行緒崩潰:

圖片

復現過程:

圖片

  1. A、B、C 三個執行緒同時進入 foo 函式

  2. A 執行緒先建立初始值 _instance

    A 執行緒執行到 _instance = x0, 建立了新值並賦給 _instance;此時 _instance 引用計數為 1;

  3. B、C 執行緒讀取到 A 執行緒建立的初始值 _instance

    B、C 執行緒分別執行到 x1 = _instance 時,從 _instance 中讀到執行緒 A 建立的物件,儲存到各自的上下文中;_instance 引用計數仍為 1;

  4. B 執行緒釋放 _instance

    B 執行緒執行 objc_release(x1) 後會釋放 _instance;_instance 引用計數變為 0,被銷燬;

  5. C 執行緒訪問 _instance

    C 執行緒執行到 objc_release(x1) 時訪問 _instance;由於 _instance 已經被銷燬,訪問時會發生崩潰。

使用 lldb 的 thread continue 指令 [2] 來控制整個流程,它可以僅讓一個執行緒執行,其它執行緒保持掛起。

  1. 3 個執行緒同時進入 foo 函式

    操作步驟:在 foo 函式裡面打上斷點,可以多次測試讓 3 個執行緒同時進入斷點。

    如圖,執行緒 2 3 4 同時進入了 foo 函式:

圖片

  1. 執行緒 2 執行到 _instance = x0,建立初始值並賦給 _instance

    操作步驟:在 Thread 2 中給彙編程式碼第 10 行打斷點,執行 thread continue,使 Thread 2 執行完 _instance = x0。

    可以看到 Thread 2 建立的例項為 0x000000002813e400:

圖片

  1. 執行緒 3、4 執行到 x1 = _instance,讀取到執行緒 2 建立的 _instance

    操作步驟 1:刪除所有斷點,切換到 Thread 3 ,給第 9 行打斷點,執行 thread continue

    操作步驟 2:刪除斷點,切換到 Thread 4,給第 9 行打斷點,執行 thread continue

    執行緒 3、4 從 _instance 中讀到了執行緒 2 建立的 _instance 0x000000002813e400:

圖片

  1. 執行緒 3 執行完 objc_release,_instance 引用計數變為 0,被銷燬

    操作步驟:刪除斷點,切換到 Thread 3,給第 12 行打斷點,執行 thread continue。

    執行後列印 0x000000002813e400 出現隨機值,說明 _instance 已經被銷燬:

圖片

  1. 執行緒 4 執行 objc_release,訪問被銷燬的 _instance,出現崩潰

    操作步驟:刪除斷點,切換到 Thread 4,給第 12 行打斷點,執行 thread continue。

    由於 _instance 已經被銷燬,再次訪問它時發生 EXC_BAD_ACCESS 崩潰。

圖片

3. 崩潰原因

如下圖,為什麼會發生 EXC_BAD_ACCESS 崩潰?

圖片

ldr x17, [x2, #0x20] 指令認為暫存器 x2 中存放的是地址,將該地址和 0x20 相加獲得一個新地址,再從新地址中讀取 8 位元組存放到 x17 中。

本例中可以分析出暫存器 x2 存放的是 Class 的地址,x2+0x20 是 Class 的成員變數 bits 的地址,這個地址是 0x00000007374040e0。從這個地址中讀值時作業系統發現它是非法記憶體地址,從而產生 EXC_BAD_ACCESS 異常並報出這個錯誤地址。

附:Class 的結構體及成員變數的偏移

圖片

為什麼 Class->bits 的地址會是 0x00000007374040e0 ,這個非法地址是怎麼來的?

_instance 物件被銷燬後,記憶體被系統隨機改寫,通過崩潰截圖中 lldb 列印的日誌可知:

  • 物件的 ISA 位置存放的隨機值是 0x000010d7374040c0
  • Class = ISA & ISA_MASK = 0x00000007374040c0
  • Class->bits = 0x00000007374040c0 + 0x20 = 0x00000007374040e0

ISA 是隨機值,那麼 Class、Class->bits 也都是隨機值,很容易是一個非法的記憶體地址,訪問非法記憶體地址就會產生 EXC_BAD_ACCESS 異常。

在執行 objc_release 函式之前 _instance 就已經銷燬了,為什麼執行到 ldr x17, [x2, #0x20] 這一行指令時才發生崩潰,之前沒有崩潰?

EXC_BAD_ACCESS 異常發生在訪問非法記憶體地址時。在 ldr x17, [x2, #0x20] 之前僅有 ldr x16, [x0] 中使用方括號 [] 訪問了 x0 中儲存的地址。此時 x0 中儲存的是 _instance 的地址,_instance 銷燬後物件的記憶體被系統隨機改寫,而 x0 中的地址是之前就存進來的合法地址,訪問合法地址不會出現異常。

4. 更多崩潰場景

上述崩潰發生在 objc_release 堆疊中,但實際可能發生在任意堆疊,這與 _instance 使用的場景有關。下面構造了一些常見的崩潰堆疊,感興趣的讀者可以參照復現。

4.1 崩潰在 objc_retain 中

圖片

圖片

崩潰原因:_instance 作為引數傳遞到 bar 函式,在函式開始執行時會保留引數 objc_reatin(_instance),結束執行時會釋放參數objc_release(_instance)。若保留引數時 _instance 已被其它執行緒銷燬,就會導致崩潰在 objc_reatin 中。

4.2 崩潰在 objc_msgSend 中

圖片

圖片

崩潰原因:第 7 行程式碼向 _instance 傳送了 isEqual: 訊息,在執行到崩潰指令 ldr x11,[x16, #0x10] 時,暫存器 x16 存放的是 _instance 的 Class,[x16, #0x10] 指令想要讀取 Class->cache,進而從 cache 中尋找快取的方法。_instance 銷燬後 ISA、Class、Class->cache 會成為隨機值,如果 Class->cache 是非法地址,在執行 [x16, #0x10] 時就會崩潰。

4.3  崩潰在 objc_autoreleasePoolPop 中

圖片

圖片

崩潰原因:若物件使用非 new/alloc/copy/mutableCopy 開頭的介面建立,並且不滿足 Autorelease elision [3] 策略,會被新增到自動釋放池中。本例建立的 _instance 被新增到子執行緒的自動釋放池中,子執行緒任務執行完成後會對池中的物件 pop,依次呼叫 objc_release 進行釋放,若次此時 _instance 已在其它執行緒中銷燬,就會發生崩潰。

4.4  EXC_BREAKPOINT 崩潰

除了上面提到的 EXC_BAD_ACCESS 異常,這類問題也能導致其它型別的異常,這裡舉一個 EXC_BREAKPOINT 異常的例子。

圖片

圖片

崩潰原因:-[NSString stringWithFormat:@"%@",_instance] 會呼叫 objc_opt_respondsToSelector 函式並將 _instance 作為引數傳入。在 objc_opt_respondsToSelector 函式發生崩潰前,x16 儲存的是引數 _instance 的 Class。

指標認證 [4] 相關的指令會使 x16 暫存器與 x17 暫存器相等,然後用 xpacd x17 對 x17 暫存器中高位清零,再比較 x16 與 x17,不相等則執行 brk 指令觸發 EXC_BREAKPOINT 異常。xpacd 對合法指標清零不會改變指標的值,不會執行 brk 指令產生異常。當引數被銷燬後,x16 可能被改寫為非法指標並賦給 x17,xpacd x17 對非法指標高位清零會改變 x17,使 x17 不等於 x16,導致 EXC_BREAKPOINT 異常。

5. 典型業務場景

業務中有三種常見導致崩潰的場景,本文從每個場景中挑選了兩個典型案例。

5.1 場景一 對全域性變數賦值

典型案例 1

圖片

這段程式碼定義了全域性變數 geckoSettingDict,並在在一個懶載入方法中對它初始化。最初這段程式碼正常執行在於 A 業務中,後面被 B 業務拷貝走,B 業務存在多執行緒呼叫的場景,在 geckoSettingDict 未初始化時,多個執行緒可以同時進入 if (geckoSettingDict == nil) 對 geckoSettingDict 賦值,導致 geckoSettingDict 被提前銷燬產生崩潰。

由於使用了 dictionaryWithContestOfFile: 介面初始化,geckoSettingDict 會被新增到自動釋放池中,導致崩潰發生在 objc_autoreleasePoolPop 堆疊裡,很難追查。這個問題困擾頭條半年之久,最終藉助位元組內部 APM 提供的線上工具定位到原因:

圖片

修復辦法是使用 dispatch_once 保證 geckoSettingDict 只賦值一次:

圖片

典型案例 2

圖片

圖片

在圖片監控的組中件, queue 被設計為全域性變數,在 startImageMonitor: 中對它初始化,這是啟動監控功能的方法,呼叫一次就可以了。但使用方在某次改動中,無意間在另一個執行緒中多呼叫了一次 startImageMonitor: 方法,使 queue 被同時賦值了兩次,導致它提前銷燬。

另一執行緒在使用 dispatch_async(queue,^{}) 介面時,由於 queue 已經被銷燬,在 dispatch_async 堆疊中發生崩潰:

圖片

圖片

崩潰在 ldr x3, [x16, #0x58] 是因為 x16 儲存的是 dispatch_async 的引數 queue,queue 被銷燬後,queue + 0x58 可能是一個非法記憶體地址,從該非法地址讀值會導致異常。

修復辦法是業務方調整了呼叫邏輯,圖片監控元件中也優化了程式碼,使用 dispatch_once 保證 queue 只能賦值一次。

場景小結

這類問題常見於開發者設計了全域性變數,並在對外暴露的介面中對全域性變數進行賦值,開發者預期變數只會初始化一次,但實際介面被呼叫的環境不可控。

修復建議:使用 dispatch_once,保證全域性變數只被賦值一次。

5.2 場景二 對屬性賦值

典型案例 1

圖片

圖片

某類設計了屬性 extraParam 用於儲存透傳引數,並在 updateExtraParams: 方法中更新該屬性。最初 updateExtraParams: 也在多執行緒中被呼叫,但沒有造成很大影響,某次需求增大了它被同時呼叫的概率,引發了大面積的崩潰。

典型案例 2

圖片

圖片

A 業務設計了單例類 Configure 並提供了對外的屬性 autoResolutionParams。B 業務對 Configure 的屬性 autoResolutionParams 重新賦值使它被銷燬,導致其它正在使用 autoResolutionParams 的執行緒崩潰。

場景小結

這類問題常見於類向外部提供了介面來更新成員變數,但介面被呼叫的環境不可控。

單例的屬性更容易被外界訪問,更容易在多執行緒下出現賦值,因此這類問題也最多。

修復建議:涉及多執行緒修改的屬性,使用 atomic 修飾。

5.3 場景三 屬性懶載入

典型案例 1

圖片

某類在懶載入方法中對 _interceptUrls 賦值,在 addADparamsToRequest 方法中呼叫 self.interceptUrls 觸發懶載入。由於業務環境複雜,addADparamsToRequest 在主執行緒、網路回撥執行緒、通知執行緒等多個場景中被呼叫,多執行緒下同時對 _interceptUrls 賦值導致它被提前銷燬,產生崩潰。

修復辦法是將 _interceptUrls 的初始化放在 init 方法中,保證它只被賦值一次。

圖片

典型案例 2

圖片

某類在懶載入方法中對 _userCache 賦值,在 cacheUserInfo:removeCachedUserInfo:等 4 個方法中都呼叫了 self.userCache 觸發懶載入,這 4 個方法可能同時被多個執行緒呼叫,很容易出現多執行緒環境下對 _userCache 賦值,導致它提前銷燬。解決辦法是將 _userCache 初始化放在 init 中,保證它只會被賦值一次。

場景小結

這是類場景比上述場景都更加隱蔽,在設計懶載入方法時要考慮觸發懶載入的方法是否會在多執行緒環境中被呼叫。

修復建議:如果懶載入屬性會被多執行緒訪問到,就不要使用懶載入,直接在 init 方法中初始化,保證賦值的程式碼只會被一個執行緒訪問。

6. 總結

產生這類崩潰的原因雖然簡單,但是在大型 App 中很難避免。隨著業務方增多、觸發賦值程式碼的介面增多,呼叫環境會更復雜;而且也存在相似程式碼 copy ,從無問題環境 copy 到有問題環境,很容易出現多執行緒環境下同時給物件賦值,導致舊值被過度釋放。

在分析此類崩潰堆疊時,往往很難注意到是賦值時 ARC 新增的 objc_release 指令使舊值被過度釋放導致的,並且線下也基本無法復現,因此這類野指標問題也容易成為懸案。熟悉原理和常見場景有助於排查問題,更有助於在開發階段就設計穩健的程式碼。

7. 答疑

  1. EXC_BAD_ACCESS 是否都是這種問題導致的?

  2. 不是,訪問非法記憶體地址就會報 EXC_BAD_ACCESS 錯誤。

  3. 但根據經驗來看,非多執行緒導致的問題在開發和測試環境中比較容易復現,在上線前基本都會被修復,上線後才爆發出來的野指標問題 80% 都是這個原因。

  4. 如何分析此類崩潰?

  5. 有業務程式碼堆疊的崩潰,可以通過反彙編推斷出具體崩潰的物件;在工程中檢索對該物件賦值的程式碼是否存在多執行緒呼叫,如果存在就基本可以確認崩潰原因是多執行緒賦值導致。

  6. 純系統堆疊的崩潰,如發生在 objc_autoreleasePoolPop 堆疊的崩潰。通過反彙編只能推斷出是某個物件被 over-release 了,無法推斷出具體是哪個物件。位元組內部的同學可以使用 APM 提供的 Zombie、GWPASan、Coredump 等線上工具 [5]進行排查;如果沒有線上工具,需要找到與該崩潰同一版本/時間段上漲的其它野指標崩潰,它們有可能是同一個原因導致的,從有業務程式碼堆疊的崩潰入手去排查。

8. 加入我們

我們是位元組跳動產品研發和工程架構部-頭條-客戶端基礎技術-iOS 團隊,在效能優化、基礎元件、業務架構、研發體系、安全合規、線下質量基礎設施、線上問題定位歸因平臺等方向深耕,負責保障和提升今日頭條、西瓜視訊和番茄小說的產品質量與開發效率,聚焦於此的同時向外延伸。

如果你對技術充滿熱情,喜歡追求極致,渴望用自己的程式碼改變數億使用者的體驗,歡迎加入我們。目前我們在北京、深圳、廣州均有招聘需求,簡歷投遞郵箱:[email protected];郵件標題:姓名-工作年限-產品研發和工程架構部-頭條-客戶端基礎技術-iOS/Android。

9. 參考文獻

[1] Objective-C Automatic Reference Counting (ARC) — Clang 16.0.0git documentation  (https://clang.llvm.org/docs/AutomaticReferenceCounting.html#semantics)

[2] LLDB Tutorial  (https://opensource.apple.com/source/lldb/lldb-310.2.36/www/tutorial.html)

[3] WWDC22: Improve app size and runtime performance - 掘金  (https://juejin.cn/post/7135344206939160612#heading-5)

[4] ARM-指標認證  (https://www.jianshu.com/p/62bf046b7701)

[5] 位元組跳動如何系統性治理 iOS 穩定性問題 (https://juejin.cn/post/7034418275728097288)

活動預告

圍繞頭條在 iOS 穩定性治理的經驗,未來我們將開展更多技術分享,你可新增下方小助手回覆【iOS】,第一時間獲知活動報名資訊 ⬇️

圖片