iOS 多線程崩潰

源自:字節(jié)跳動(dòng)團(tuán)隊(duì)

感謝字節(jié)跳動(dòng)團(tuán)隊(duì)的分享虫埂, 本文意在分享此類(lèi)開(kāi)發(fā)崩潰知識(shí),如有涉及侵權(quán)时迫,請(qǐng)聯(lián)系我刪除

ARC 環(huán)境下在多線程中執(zhí)行賦值代碼可能會(huì)產(chǎn)生野指針淹遵,導(dǎo)致 EXC_BAD_ACCESS 崩潰瘫絮。
這種崩潰發(fā)生的概率很低,在開(kāi)發(fā)和灰度階段即使執(zhí)行到相應(yīng)代碼也很難崩潰劳闹,因此容易遺漏到正式環(huán)境济瓢。在上億級(jí)用戶的 App 往往會(huì)成為 Top 問(wèn)題,對(duì)指標(biāo)造成影響棵帽,并且很難排查。
今日頭條在治理 Crash 的過(guò)程中徹底解決了數(shù)十個(gè)此類(lèi)崩潰渣玲,發(fā)現(xiàn)其具有一定共性逗概。本文詳細(xì)分析崩潰發(fā)生的過(guò)程,以及總結(jié)了容易出現(xiàn)問(wèn)題的場(chǎng)景忘衍,希望在大家遇到此類(lèi)問(wèn)題時(shí)能提供一些思路逾苫。

1. 原理

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

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

objc_release 會(huì)減小對(duì)象的引用計(jì)數(shù)星掰,減小到 0 時(shí)對(duì)象就會(huì)被銷(xiāo)毀多望,假如這時(shí)有其它線程正在使用這個(gè)對(duì)象,那么使用對(duì)象的線程就很可能發(fā)生崩潰氢烘。

2. 崩潰場(chǎng)景

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

    • image.png

復(fù)現(xiàn)過(guò)程:

    • image.png
  1. A 線程先創(chuàng)建初始值 _instance
  • A 線程執(zhí)行到 _instance = x0怀偷, 創(chuàng)建了新值并賦給 _instance;此時(shí) _instance 引用計(jì)數(shù)為 1播玖;
  1. B椎工、C 線程讀取到 A 線程創(chuàng)建的初始值 _instance
  • B、C 線程分別執(zhí)行到 x1 = _instance 時(shí)蜀踏,從 _instance 中讀到線程 A 創(chuàng)建的對(duì)象维蒙,保存到各自的上下文中;_instance 引用計(jì)數(shù)仍為 1果覆;
  1. B 線程釋放 _instance
  • B 線程執(zhí)行 objc_release(x1) 后會(huì)釋放 _instance颅痊;_instance 引用計(jì)數(shù)變?yōu)?0,被銷(xiāo)毀随静;
  1. C 線程訪問(wèn) _instance
  • C 線程執(zhí)行到 objc_release(x1) 時(shí)訪問(wèn) _instance八千;由于 _instance 已經(jīng)被銷(xiāo)毀,訪問(wèn)時(shí)會(huì)發(fā)生崩潰燎猛。

3. 崩潰原因

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

    • image.png

ldr x17, [x2, #0x20] 指令認(rèn)為寄存器 x2 中存放的是地址重绷,將該地址和 0x20 相加獲得一個(gè)新地址沸停,再?gòu)男碌刂分凶x取 8 字節(jié)存放到 x17 中。
本例中可以分析出寄存器 x2 存放的是 Class 的地址昭卓,x2+0x20 是 Class 的成員變量 bits 的地址愤钾,這個(gè)地址是 0x00000007374040e0。從這個(gè)地址中讀值時(shí)操作系統(tǒng)發(fā)現(xiàn)它是非法內(nèi)存地址候醒,從而產(chǎn)生 EXC_BAD_ACCESS 異常并報(bào)出這個(gè)錯(cuò)誤地址能颁。
附:Class 的結(jié)構(gòu)體及成員變量的偏移

    • image.png

為什么 Class->bits 的地址會(huì)是 0x00000007374040e0 ,這個(gè)非法地址是怎么來(lái)的倒淫?

_instance 對(duì)象被銷(xiāo)毀后伙菊,內(nèi)存被系統(tǒng)隨機(jī)改寫(xiě),通過(guò)崩潰截圖中 lldb 打印的日志可知:

對(duì)象的 ISA 位置存放的隨機(jī)值是 0x000010d7374040c0

  • Class = ISA & ISA_MASK = 0x00000007374040c0
  • Class->bits = 0x00000007374040c0 + 0x20 = 0x00000007374040e0
  • ISA 是隨機(jī)值,那么 Class镜硕、Class->bits 也都是隨機(jī)值运翼,很容易是一個(gè)非法的內(nèi)存地址,訪問(wèn)非法內(nèi)存地址就會(huì)產(chǎn)生 EXC_BAD_ACCESS 異常兴枯。

在執(zhí)行 objc_release 函數(shù)之前 _instance 就已經(jīng)銷(xiāo)毀了血淌,為什么執(zhí)行到 ldr x17, [x2, #0x20] 這一行指令時(shí)才發(fā)生崩潰,之前沒(méi)有崩潰财剖?

EXC_BAD_ACCESS 異常發(fā)生在訪問(wèn)非法內(nèi)存地址時(shí)悠夯。在 ldr x17, [x2, #0x20] 之前僅有 ldr x16, [x0] 中使用方括號(hào) [] 訪問(wèn)了 x0 中存儲(chǔ)的地址。此時(shí) x0 中存儲(chǔ)的是 _instance 的地址峰伙,_instance 銷(xiāo)毀后對(duì)象的內(nèi)存被系統(tǒng)隨機(jī)改寫(xiě)疗疟,而 x0 中的地址是之前就存進(jìn)來(lái)的合法地址,訪問(wèn)合法地址不會(huì)出現(xiàn)異常瞳氓。

4.更多崩潰場(chǎng)景

上述崩潰發(fā)生在 objc_release 堆棧中策彤,但實(shí)際可能發(fā)生在任意堆棧,這與 _instance 使用的場(chǎng)景有關(guān)匣摘。下面構(gòu)造了一些常見(jiàn)的崩潰堆棧店诗,感興趣的讀者可以參照復(fù)現(xiàn)。

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

崩潰原因:_instance 作為參數(shù)傳遞到 bar 函數(shù)音榜,在函數(shù)開(kāi)始執(zhí)行時(shí)會(huì)保留參數(shù) objc_reatin(_instance)庞瘸,結(jié)束執(zhí)行時(shí)會(huì)釋放參數(shù)objc_release(_instance)。若保留參數(shù)時(shí) _instance 已被其它線程銷(xiāo)毀赠叼,就會(huì)導(dǎo)致崩潰在 objc_reatin 中擦囊。

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

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

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

崩潰原因:若對(duì)象使用非 new/alloc/copy/mutableCopy 開(kāi)頭的接口創(chuàng)建批旺,并且不滿足 Autorelease elision [3] 策略幌陕,會(huì)被添加到自動(dòng)釋放池中。本例創(chuàng)建的 _instance 被添加到子線程的自動(dòng)釋放池中汽煮,子線程任務(wù)執(zhí)行完成后會(huì)對(duì)池中的對(duì)象 pop搏熄,依次調(diào)用 objc_release 進(jìn)行釋放茅诱,若次此時(shí) _instance 已在其它線程中銷(xiāo)毀,就會(huì)發(fā)生崩潰搬卒。

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

崩潰原因:-[NSString stringWithFormat:@"%@",_instance] 會(huì)調(diào)用 objc_opt_respondsToSelector 函數(shù)并將 _instance 作為參數(shù)傳入荔烧。在 objc_opt_respondsToSelector 函數(shù)發(fā)生崩潰前枢析,x16 存儲(chǔ)的是參數(shù) _instance 的 Class。

指針認(rèn)證 [4] 相關(guān)的指令會(huì)使 x16 寄存器與 x17 寄存器相等趁曼,然后用 xpacd x17 對(duì) x17 寄存器中高位清零失暴,再比較 x16 與 x17坯门,不相等則執(zhí)行 brk 指令觸發(fā) EXC_BREAKPOINT 異常。xpacd 對(duì)合法指針清零不會(huì)改變指針的值逗扒,不會(huì)執(zhí)行 brk 指令產(chǎn)生異常古戴。當(dāng)參數(shù)被銷(xiāo)毀后,x16 可能被改寫(xiě)為非法指針并賦給 x17矩肩,xpacd x17 對(duì)非法指針高位清零會(huì)改變 x17现恼,使 x17 不等于 x16,導(dǎo)致 EXC_BREAKPOINT 異常黍檩。

5. 常見(jiàn)典型業(yè)務(wù)場(chǎng)景

5.1 場(chǎng)景一 對(duì)全局變量賦值
    • image.png

這段代碼定義了全局變量 geckoSettingDict叉袍,并在在一個(gè)懶加載方法中對(duì)它初始化。最初這段代碼正常運(yùn)行在于 A 業(yè)務(wù)中刽酱,后面被 B 業(yè)務(wù)拷貝走喳逛,B 業(yè)務(wù)存在多線程調(diào)用的場(chǎng)景,在 geckoSettingDict 未初始化時(shí)棵里,多個(gè)線程可以同時(shí)進(jìn)入 if (geckoSettingDict == nil) 對(duì) geckoSettingDict 賦值润文,導(dǎo)致 geckoSettingDict 被提前銷(xiāo)毀產(chǎn)生崩潰。

由于使用了 dictionaryWithContestOfFile: 接口初始化殿怜,geckoSettingDict 會(huì)被添加到自動(dòng)釋放池中典蝌,導(dǎo)致崩潰發(fā)生在 objc_autoreleasePoolPop 堆棧里,很難追查稳捆。這個(gè)問(wèn)題困擾頭條半年之久赠法,最終借助字節(jié)內(nèi)部 APM 提供的線上工具定位到原因:

    • image.png

小結(jié):

  • 這類(lèi)問(wèn)題常見(jiàn)于開(kāi)發(fā)者設(shè)計(jì)了全局變量,并在對(duì)外暴露的接口中對(duì)全局變量進(jìn)行賦值乔夯,開(kāi)發(fā)者預(yù)期變量只會(huì)初始化一次砖织,但實(shí)際接口被調(diào)用的環(huán)境不可控
  • 修復(fù)建議:使用 dispatch_once,保證全局變量只被賦值一次末荐。
5.2 場(chǎng)景二 對(duì)屬性賦值
    • image.png

某類(lèi)設(shè)計(jì)了屬性 extraParam 用于保存透?jìng)鲄?shù)侧纯,并在 updateExtraParams: 方法中更新該屬性。最初 updateExtraParams: 也在多線程中被調(diào)用甲脏,但沒(méi)有造成很大影響眶熬,某次需求增大了它被同時(shí)調(diào)用的概率妹笆,引發(fā)了大面積的崩潰。

小結(jié):

  • 這類(lèi)問(wèn)題常見(jiàn)于類(lèi)向外部提供了接口來(lái)更新成員變量娜氏,但接口被調(diào)用的環(huán)境不可控拳缠。
  • 單例的屬性更容易被外界訪問(wèn),更容易在多線程下出現(xiàn)賦值贸弥,因此這類(lèi)問(wèn)題也最多窟坐。
  • 修復(fù)建議:涉及多線程修改的屬性,使用 atomic 修飾绵疲。
5.3 場(chǎng)景三 屬性懶加載
    • image.png

某類(lèi)在懶加載方法中對(duì) _interceptUrls 賦值哲鸳,在 addADparamsToRequest 方法中調(diào)用 self.interceptUrls 觸發(fā)懶加載。由于業(yè)務(wù)環(huán)境復(fù)雜盔憨,addADparamsToRequest 在主線程徙菠、網(wǎng)絡(luò)回調(diào)線程、通知線程等多個(gè)場(chǎng)景中被調(diào)用郁岩,多線程下同時(shí)對(duì) _interceptUrls 賦值導(dǎo)致它被提前銷(xiāo)毀婿奔,產(chǎn)生崩潰。

修復(fù)辦法是將 _interceptUrls 的初始化放在 init 方法中问慎,保證它只被賦值一次脸秽。

    • image.png

案例2

image.png

某類(lèi)在懶加載方法中對(duì) _userCache 賦值,在 cacheUserInfo:蝴乔、removeCachedUserInfo:等 4 個(gè)方法中都調(diào)用了 self.userCache 觸發(fā)懶加載记餐,這 4 個(gè)方法可能同時(shí)被多個(gè)線程調(diào)用,很容易出現(xiàn)多線程環(huán)境下對(duì) _userCache 賦值薇正,導(dǎo)致它提前銷(xiāo)毀片酝。解決辦法是將 _userCache 初始化放在 init 中,保證它只會(huì)被賦值一次挖腰。

小結(jié):

  • 這是類(lèi)場(chǎng)景比上述場(chǎng)景都更加隱蔽雕沿,在設(shè)計(jì)懶加載方法時(shí)要考慮觸發(fā)懶加載的方法是否會(huì)在多線程環(huán)境中被調(diào)用。
  • 修復(fù)建議:如果懶加載屬性會(huì)被多線程訪問(wèn)到猴仑,就不要使用懶加載审轮,直接在 init 方法中初始化,保證賦值的代碼只會(huì)被一個(gè)線程訪問(wèn)辽俗。

6.如何分析此類(lèi)崩潰疾渣?

  • 有業(yè)務(wù)代碼堆棧的崩潰,可以通過(guò)反匯編推斷出具體崩潰的對(duì)象崖飘;在工程中檢索對(duì)該對(duì)象賦值的代碼是否存在多線程調(diào)用榴捡,如果存在就基本可以確認(rèn)崩潰原因是多線程賦值導(dǎo)致。
  • 純系統(tǒng)堆棧的崩潰朱浴,如發(fā)生在 objc_autoreleasePoolPop 堆棧的崩潰吊圾。通過(guò)反匯編只能推斷出是某個(gè)對(duì)象被 over-release 了达椰,無(wú)法推斷出具體是哪個(gè)對(duì)象。字節(jié)內(nèi)部的同學(xué)可以使用 APM 提供的 Zombie项乒、GWPASan啰劲、Coredump 等線上工具 [5]進(jìn)行排查;如果沒(méi)有線上工具檀何,需要找到與該崩潰同一版本/時(shí)間段上漲的其它野指針崩潰呈枉,它們有可能是同一個(gè)原因?qū)е碌模瑥挠袠I(yè)務(wù)代碼堆棧的崩潰入手去排查埃碱。

7. 參考:文獻(xiàn)

[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-指針認(rèn)證 (http://www.reibang.com/p/62bf046b7701)

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市酥泞,隨后出現(xiàn)的幾起案子砚殿,更是在濱河造成了極大的恐慌,老刑警劉巖芝囤,帶你破解...
    沈念sama閱讀 210,978評(píng)論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件似炎,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡悯姊,警方通過(guò)查閱死者的電腦和手機(jī)羡藐,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,954評(píng)論 2 384
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)悯许,“玉大人仆嗦,你說(shuō)我怎么就攤上這事∠群荆” “怎么了瘩扼?”我有些...
    開(kāi)封第一講書(shū)人閱讀 156,623評(píng)論 0 345
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)垃僚。 經(jīng)常有香客問(wèn)我集绰,道長(zhǎng),這世上最難降的妖魔是什么谆棺? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 56,324評(píng)論 1 282
  • 正文 為了忘掉前任栽燕,我火速辦了婚禮,結(jié)果婚禮上改淑,老公的妹妹穿的比我還像新娘碍岔。我一直安慰自己,他們只是感情好朵夏,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,390評(píng)論 5 384
  • 文/花漫 我一把揭開(kāi)白布付秕。 她就那樣靜靜地躺著,像睡著了一般侍郭。 火紅的嫁衣襯著肌膚如雪询吴。 梳的紋絲不亂的頭發(fā)上掠河,一...
    開(kāi)封第一講書(shū)人閱讀 49,741評(píng)論 1 289
  • 那天,我揣著相機(jī)與錄音猛计,去河邊找鬼唠摹。 笑死,一個(gè)胖子當(dāng)著我的面吹牛奉瘤,可吹牛的內(nèi)容都是我干的勾拉。 我是一名探鬼主播,決...
    沈念sama閱讀 38,892評(píng)論 3 405
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼盗温,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼藕赞!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起卖局,我...
    開(kāi)封第一講書(shū)人閱讀 37,655評(píng)論 0 266
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤斧蜕,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后砚偶,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體批销,經(jīng)...
    沈念sama閱讀 44,104評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,451評(píng)論 2 325
  • 正文 我和宋清朗相戀三年染坯,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了均芽。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,569評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡单鹿,死狀恐怖掀宋,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情仲锄,我是刑警寧澤布朦,帶...
    沈念sama閱讀 34,254評(píng)論 4 328
  • 正文 年R本政府宣布,位于F島的核電站昼窗,受9級(jí)特大地震影響是趴,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜澄惊,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,834評(píng)論 3 312
  • 文/蒙蒙 一唆途、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧掸驱,春花似錦肛搬、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,725評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至鬼癣,卻和暖如春陶贼,著一層夾襖步出監(jiān)牢的瞬間啤贩,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,950評(píng)論 1 264
  • 我被黑心中介騙來(lái)泰國(guó)打工拜秧, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留痹屹,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 46,260評(píng)論 2 360
  • 正文 我出身青樓枉氮,卻偏偏與公主長(zhǎng)得像志衍,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子聊替,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,446評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容