iOS 多線程崩潰

源自:字節(jié)跳動團隊

感謝字節(jié)跳動團隊的分享, 本文意在分享此類開發(fā)崩潰知識,如有涉及侵權,請聯系我刪除

ARC 環(huán)境下在多線程中執(zhí)行賦值代碼可能會產生野指針,導致 EXC_BAD_ACCESS 崩潰。
這種崩潰發(fā)生的概率很低,在開發(fā)和灰度階段即使執(zhí)行到相應代碼也很難崩潰,因此容易遺漏到正式環(huán)境。在上億級用戶的 App 往往會成為 Top 問題,對指標造成影響,并且很難排查。
今日頭條在治理 Crash 的過程中徹底解決了數十個此類崩潰,發(fā)現其具有一定共性。本文詳細分析崩潰發(fā)生的過程,以及總結了容易出現問題的場景,希望在大家遇到此類問題時能提供一些思路。

1. 原理

Objective-C 對象的賦值過程包含創(chuàng)建新值、保留舊值、加載新值、釋放舊值四步。相比 MRC,ARC 環(huán)境中編譯器會自動插入保留與釋放舊值的步驟:

    • image.png
    • image.png
    • image.png
    • image.png

objc_release 會減小對象的引用計數,減小到 0 時對象就會被銷毀,假如這時有其它線程正在使用這個對象,那么使用對象的線程就很可能發(fā)生崩潰。

2. 崩潰場景

Demo設計在 B 線程中釋放 A 線程創(chuàng)建的對象使 C 線程崩潰:

    • image.png

復現過程:

    • image.png
  1. A 線程先創(chuàng)建初始值 _instance
  • A 線程執(zhí)行到 _instance = x0, 創(chuàng)建了新值并賦給 _instance;此時 _instance 引用計數為 1;
  1. B、C 線程讀取到 A 線程創(chuàng)建的初始值 _instance
  • B、C 線程分別執(zhí)行到 x1 = _instance 時,從 _instance 中讀到線程 A 創(chuàng)建的對象,保存到各自的上下文中;_instance 引用計數仍為 1;
  1. B 線程釋放 _instance
  • B 線程執(zhí)行 objc_release(x1) 后會釋放 _instance;_instance 引用計數變?yōu)?0,被銷毀;
  1. C 線程訪問 _instance
  • C 線程執(zhí)行到 objc_release(x1) 時訪問 _instance;由于 _instance 已經被銷毀,訪問時會發(fā)生崩潰。

3. 崩潰原因

如下圖,為什么會發(fā)生 EXC_BAD_ACCESS 崩潰?

    • image.png

ldr x17, [x2, #0x20] 指令認為寄存器 x2 中存放的是地址,將該地址和 0x20 相加獲得一個新地址,再從新地址中讀取 8 字節(jié)存放到 x17 中。
本例中可以分析出寄存器 x2 存放的是 Class 的地址,x2+0x20 是 Class 的成員變量 bits 的地址,這個地址是 0x00000007374040e0。從這個地址中讀值時操作系統(tǒng)發(fā)現它是非法內存地址,從而產生 EXC_BAD_ACCESS 異常并報出這個錯誤地址。
附:Class 的結構體及成員變量的偏移

    • image.png

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

_instance 對象被銷毀后,內存被系統(tǒng)隨機改寫,通過崩潰截圖中 lldb 打印的日志可知:

對象的 ISA 位置存放的隨機值是 0x000010d7374040c0

  • Class = ISA & ISA_MASK = 0x00000007374040c0
  • Class->bits = 0x00000007374040c0 + 0x20 = 0x00000007374040e0
  • ISA 是隨機值,那么 Class、Class->bits 也都是隨機值,很容易是一個非法的內存地址,訪問非法內存地址就會產生 EXC_BAD_ACCESS 異常。

在執(zhí)行 objc_release 函數之前 _instance 就已經銷毀了,為什么執(zhí)行到 ldr x17, [x2, #0x20] 這一行指令時才發(fā)生崩潰,之前沒有崩潰?

EXC_BAD_ACCESS 異常發(fā)生在訪問非法內存地址時。在 ldr x17, [x2, #0x20] 之前僅有 ldr x16, [x0] 中使用方括號 [] 訪問了 x0 中存儲的地址。此時 x0 中存儲的是 _instance 的地址,_instance 銷毀后對象的內存被系統(tǒng)隨機改寫,而 x0 中的地址是之前就存進來的合法地址,訪問合法地址不會出現異常。

4.更多崩潰場景

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

4.1崩潰在 objc_retain 中
    • image.png
    • image.png

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

4.2 崩潰在 objc_msgSend 中
    • image.png
    • image.png

崩潰原因:第 7 行代碼向 _instance 發(fā)送了 isEqual: 消息,在執(zhí)行到崩潰指令 ldr x11,[x16, #0x10] 時,寄存器 x16 存放的是 _instance 的 Class,[x16, #0x10] 指令想要讀取 Class->cache,進而從 cache 中尋找緩存的方法。_instance 銷毀后 ISA、Class、Class->cache 會成為隨機值,如果 Class->cache 是非法地址,在執(zhí)行 [x16, #0x10] 時就會崩潰。

4.3 崩潰在 objc_autoreleasePoolPop 中
    • image.png
    • image.png

崩潰原因:若對象使用非 new/alloc/copy/mutableCopy 開頭的接口創(chuàng)建,并且不滿足 Autorelease elision [3] 策略,會被添加到自動釋放池中。本例創(chuàng)建的 _instance 被添加到子線程的自動釋放池中,子線程任務執(zhí)行完成后會對池中的對象 pop,依次調用 objc_release 進行釋放,若次此時 _instance 已在其它線程中銷毀,就會發(fā)生崩潰。

4.4 EXC_BREAKPOINT 崩潰
    • image.png
    • image.png

崩潰原因:-[NSString stringWithFormat:@"%@",_instance] 會調用 objc_opt_respondsToSelector 函數并將 _instance 作為參數傳入。在 objc_opt_respondsToSelector 函數發(fā)生崩潰前,x16 存儲的是參數 _instance 的 Class。

指針認證 [4] 相關的指令會使 x16 寄存器與 x17 寄存器相等,然后用 xpacd x17 對 x17 寄存器中高位清零,再比較 x16 與 x17,不相等則執(zhí)行 brk 指令觸發(fā) EXC_BREAKPOINT 異常。xpacd 對合法指針清零不會改變指針的值,不會執(zhí)行 brk 指令產生異常。當參數被銷毀后,x16 可能被改寫為非法指針并賦給 x17,xpacd x17 對非法指針高位清零會改變 x17,使 x17 不等于 x16,導致 EXC_BREAKPOINT 異常。

5. 常見典型業(yè)務場景

5.1 場景一 對全局變量賦值
    • image.png

這段代碼定義了全局變量 geckoSettingDict,并在在一個懶加載方法中對它初始化。最初這段代碼正常運行在于 A 業(yè)務中,后面被 B 業(yè)務拷貝走,B 業(yè)務存在多線程調用的場景,在 geckoSettingDict 未初始化時,多個線程可以同時進入 if (geckoSettingDict == nil) 對 geckoSettingDict 賦值,導致 geckoSettingDict 被提前銷毀產生崩潰。

由于使用了 dictionaryWithContestOfFile: 接口初始化,geckoSettingDict 會被添加到自動釋放池中,導致崩潰發(fā)生在 objc_autoreleasePoolPop 堆棧里,很難追查。這個問題困擾頭條半年之久,最終借助字節(jié)內部 APM 提供的線上工具定位到原因:

    • image.png

小結:

  • 這類問題常見于開發(fā)者設計了全局變量,并在對外暴露的接口中對全局變量進行賦值,開發(fā)者預期變量只會初始化一次,但實際接口被調用的環(huán)境不可控
  • 修復建議:使用 dispatch_once,保證全局變量只被賦值一次。
5.2 場景二 對屬性賦值
    • image.png

某類設計了屬性 extraParam 用于保存透傳參數,并在 updateExtraParams: 方法中更新該屬性。最初 updateExtraParams: 也在多線程中被調用,但沒有造成很大影響,某次需求增大了它被同時調用的概率,引發(fā)了大面積的崩潰。

小結:

  • 這類問題常見于類向外部提供了接口來更新成員變量,但接口被調用的環(huán)境不可控。
  • 單例的屬性更容易被外界訪問,更容易在多線程下出現賦值,因此這類問題也最多。
  • 修復建議:涉及多線程修改的屬性,使用 atomic 修飾。
5.3 場景三 屬性懶加載
    • image.png

某類在懶加載方法中對 _interceptUrls 賦值,在 addADparamsToRequest 方法中調用 self.interceptUrls 觸發(fā)懶加載。由于業(yè)務環(huán)境復雜,addADparamsToRequest 在主線程、網絡回調線程、通知線程等多個場景中被調用,多線程下同時對 _interceptUrls 賦值導致它被提前銷毀,產生崩潰。

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

    • image.png

案例2

image.png

某類在懶加載方法中對 _userCache 賦值,在 cacheUserInfo:、removeCachedUserInfo:等 4 個方法中都調用了 self.userCache 觸發(fā)懶加載,這 4 個方法可能同時被多個線程調用,很容易出現多線程環(huán)境下對 _userCache 賦值,導致它提前銷毀。解決辦法是將 _userCache 初始化放在 init 中,保證它只會被賦值一次。

小結:

  • 這是類場景比上述場景都更加隱蔽,在設計懶加載方法時要考慮觸發(fā)懶加載的方法是否會在多線程環(huán)境中被調用。
  • 修復建議:如果懶加載屬性會被多線程訪問到,就不要使用懶加載,直接在 init 方法中初始化,保證賦值的代碼只會被一個線程訪問。

6.如何分析此類崩潰?

  • 有業(yè)務代碼堆棧的崩潰,可以通過反匯編推斷出具體崩潰的對象;在工程中檢索對該對象賦值的代碼是否存在多線程調用,如果存在就基本可以確認崩潰原因是多線程賦值導致。
  • 純系統(tǒng)堆棧的崩潰,如發(fā)生在 objc_autoreleasePoolPop 堆棧的崩潰。通過反匯編只能推斷出是某個對象被 over-release 了,無法推斷出具體是哪個對象。字節(jié)內部的同學可以使用 APM 提供的 Zombie、GWPASan、Coredump 等線上工具 [5]進行排查;如果沒有線上工具,需要找到與該崩潰同一版本/時間段上漲的其它野指針崩潰,它們有可能是同一個原因導致的,從有業(yè)務代碼堆棧的崩潰入手去排查。

7. 參考:文獻

[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-指針認證 (http://www.itdecent.cn/p/62bf046b7701)

[5] 字節(jié)跳動如何系統(tǒng)性治理 iOS 穩(wěn)定性問題 (https://juejin.cn/post/7034418275728097288)

?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容