新聞中心
ARC 環(huán)境下在多線程中執(zhí)行賦值代碼可能會(huì)產(chǎn)生野指針,導(dǎo)致 EXC_BAD_ACCESS 崩潰。

坡頭ssl適用于網(wǎng)站、小程序/APP、API接口等需要進(jìn)行數(shù)據(jù)傳輸應(yīng)用場(chǎng)景,ssl證書未來(lái)市場(chǎng)廣闊!成為成都創(chuàng)新互聯(lián)的ssl證書銷售渠道,可以享受市場(chǎng)價(jià)格4-6折優(yōu)惠!如果有意向歡迎電話聯(lián)系或者加微信:18980820575(備注:SSL證書合作)期待與您的合作!
這種崩潰發(fā)生的概率很低,在開發(fā)和灰度階段即使執(zhí)行到相應(yīng)代碼也很難崩潰,因此容易遺漏到正式環(huán)境。在上億級(jí)用戶的 App 往往會(huì)成為 Top 問題,對(duì)指標(biāo)造成影響,并且很難排查。
今日頭條在治理 Crash 的過程中徹底解決了數(shù)十個(gè)此類崩潰,發(fā)現(xiàn)其具有一定共性。本文詳細(xì)分析崩潰發(fā)生的過程,以及總結(jié)了容易出現(xiàn)問題的場(chǎng)景,希望在大家遇到此類問題時(shí)能提供一些思路。
一. 原理
Objective-C 對(duì)象的賦值過程包含創(chuàng)建新值、保留舊值、加載新值、釋放舊值四步。相比 MRC,ARC 環(huán)境中編譯器會(huì)自動(dòng)插入保留與釋放舊值的步驟:
NSObject *_instance;
void foo(void) {
_instance = [[NSObject alloc] init];
}
這點(diǎn)在 AutomaticReferenceCounting [1] 文檔中有提到,通過匯編代碼也可以分析:
objc_release 會(huì)減小對(duì)象的引用計(jì)數(shù),減小到 0 時(shí)對(duì)象就會(huì)被銷毀,假如這時(shí)有其它線程正在使用這個(gè)對(duì)象,那么使用對(duì)象的線程就很可能發(fā)生崩潰。
二. 崩潰場(chǎng)景
為了演示僅一行賦值代碼就能造成崩潰,以及清晰地分析崩潰的原因,我設(shè)計(jì)了一個(gè) Demo,在 B 線程中釋放 A 線程創(chuàng)建的對(duì)象使 C 線程崩潰:
復(fù)現(xiàn)過程:
- A、B、C 三個(gè)線程同時(shí)進(jìn)入?
?foo?? 函數(shù) - A 線程先創(chuàng)建初始值 _instance
A 線程執(zhí)行到 _instance = x0, 創(chuàng)建了新值并賦給 _instance;此時(shí) _instance 引用計(jì)數(shù)為 1; - B、C 線程讀取到 A 線程創(chuàng)建的初始值 _instance
B、C 線程分別執(zhí)行到 x1 = _instance 時(shí),從 _instance 中讀到線程 A 創(chuàng)建的對(duì)象,保存到各自的上下文中;_instance 引用計(jì)數(shù)仍為 1; - B 線程釋放 _instance
B 線程執(zhí)行???objc_release(x1)?? 后會(huì)釋放 _instance;_instance 引用計(jì)數(shù)變?yōu)?0,被銷毀; - C 線程訪問 _instance
C 線程執(zhí)行到???objc_release(x1)?? 時(shí)訪問 _instance;由于 _instance 已經(jīng)被銷毀,訪問時(shí)會(huì)發(fā)生崩潰。
使用 lldb 的 thread continue 指令 [2] 來(lái)控制整個(gè)流程,它可以僅讓一個(gè)線程執(zhí)行,其它線程保持掛起。
- 3 個(gè)線程同時(shí)進(jìn)入?
?foo??? 函數(shù)操作步驟:在??foo??? 函數(shù)里面打上斷點(diǎn),可以多次測(cè)試讓 3 個(gè)線程同時(shí)進(jìn)入斷點(diǎn)。如圖,線程 2 3 4 同時(shí)進(jìn)入了??foo?? 函數(shù):
- 線程 2 執(zhí)行到 _instance = x0,創(chuàng)建初始值并賦給 _instance操作步驟:在 Thread 2 中給匯編代碼第 10 行打斷點(diǎn),執(zhí)行 thread continue,使 Thread 2 執(zhí)行完 _instance = x0??梢钥吹?Thread 2 創(chuàng)建的實(shí)例為 0x000000002813e400:
- 線程 3、4 執(zhí)行到 x1 = _instance,讀取到線程 2 創(chuàng)建的 _instance操作步驟 1:刪除所有斷點(diǎn),切換到 Thread 3 ,給第 9 行打斷點(diǎn),執(zhí)行 thread continue操作步驟 2:刪除斷點(diǎn),切換到 Thread 4,給第 9 行打斷點(diǎn),執(zhí)行 thread continue線程 3、4 從 _instance 中讀到了線程 2 創(chuàng)建的 _instance 0x000000002813e400:
- 線程 3 執(zhí)行完 objc_release,_instance 引用計(jì)數(shù)變?yōu)?0,被銷毀操作步驟:刪除斷點(diǎn),切換到 Thread 3,給第 12 行打斷點(diǎn),執(zhí)行 thread continue。執(zhí)行后打印 0x000000002813e400 出現(xiàn)隨機(jī)值,說明 _instance 已經(jīng)被銷毀:
- 線程 4 執(zhí)行 objc_release,訪問被銷毀的 _instance,出現(xiàn)崩潰操作步驟:刪除斷點(diǎn),切換到 Thread 4,給第 12 行打斷點(diǎn),執(zhí)行 thread continue。由于 _instance 已經(jīng)被銷毀,再次訪問它時(shí)發(fā)生 EXC_BAD_ACCESS 崩潰。
三. 崩潰原因
如下圖,為什么會(huì)發(fā)生 EXC_BAD_ACCESS 崩潰?
??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)體及成員變量的偏移
為什么 Class->bits 的地址會(huì)是 ??0x00000007374040e0?? ,這個(gè)非法地址是怎么來(lái)的?
_instance 對(duì)象被銷毀后,內(nèi)存被系統(tǒng)隨機(jī)改寫,通過崩潰截圖中 lldb 打印的日志可知:
- 對(duì)象的 ISA 位置存放的隨機(jī)值是 0x000010d7374040c0
- Class = ISA & ISA_MASK = 0x00000007374040c0
- Class->bits = 0x00000007374040c0 + 0x20 =?
?0x00000007374040e0??
ISA 是隨機(jī)值,那么 Class、Class->bits 也都是隨機(jī)值,很容易是一個(gè)非法的內(nèi)存地址,訪問非法內(nèi)存地址就會(huì)產(chǎn)生 EXC_BAD_ACCESS 異常。
在執(zhí)行 objc_release 函數(shù)之前 _instance 就已經(jīng)銷毀了,為什么執(zhí)行到 ??ldr x17, [x2, #0x20]?? 這一行指令時(shí)才發(fā)生崩潰,之前沒有崩潰?
EXC_BAD_ACCESS 異常發(fā)生在訪問非法內(nèi)存地址時(shí)。在 ??ldr x17, [x2, #0x20]??? 之前僅有 ??ldr x16, [x0]??? 中使用方括號(hào) ??[]?? 訪問了 x0 中存儲(chǔ)的地址。此時(shí) x0 中存儲(chǔ)的是 _instance 的地址,_instance 銷毀后對(duì)象的內(nèi)存被系統(tǒng)隨機(jī)改寫,而 x0 中的地址是之前就存進(jìn)來(lái)的合法地址,訪問合法地址不會(huì)出現(xiàn)異常。
四. 更多崩潰場(chǎng)景
上述崩潰發(fā)生在 objc_release 堆棧中,但實(shí)際可能發(fā)生在任意堆棧,這與 _instance 使用的場(chǎng)景有關(guān)。下面構(gòu)造了一些常見的崩潰堆棧,感興趣的讀者可以參照復(fù)現(xiàn)。
4.1 崩潰在 objc_retain 中
崩潰原因:_instance 作為參數(shù)傳遞到 bar 函數(shù),在函數(shù)開始執(zhí)行時(shí)會(huì)保留參數(shù) ??objc_reatin(_instance)???,結(jié)束執(zhí)行時(shí)會(huì)釋放參數(shù)??objc_release(_instance)??。若保留參數(shù)時(shí) _instance 已被其它線程銷毀,就會(huì)導(dǎo)致崩潰在 objc_reatin 中。
4.2 崩潰在 objc_msgSend 中
崩潰原因:第 7 行代碼向 _instance 發(fā)送了 ??isEqual:??? 消息,在執(zhí)行到崩潰指令 ??ldr x11,[x16, #0x10]??? 時(shí),寄存器 x16 存放的是 _instance 的 Class,??[x16, #0x10]??? 指令想要讀取 Class->cache,進(jìn)而從 cache 中尋找緩存的方法。_instance 銷毀后 ISA、Class、Class->cache 會(huì)成為隨機(jī)值,如果 Class->cache 是非法地址,在執(zhí)行 ??[x16, #0x10]?? 時(shí)就會(huì)崩潰。
4.3 崩潰在 objc_autoreleasePoolPop 中
崩潰原因:若對(duì)象使用非 ??new/alloc/copy/mutableCopy?? 開頭的接口創(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 已在其它線程中銷毀,就會(huì)發(fā)生崩潰。
4.4 EXC_BREAKPOINT 崩潰
除了上面提到的 EXC_BAD_ACCESS 異常,這類問題也能導(dǎo)致其它類型的異常,這里舉一個(gè) EXC_BREAKPOINT 異常的例子。
崩潰原因:??-[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ù)被銷毀后,x16 可能被改寫為非法指針并賦給 x17,??xpacd x17?? 對(duì)非法指針高位清零會(huì)改變 x17,使 x17 不等于 x16,導(dǎo)致 EXC_BREAKPOINT 異常。
五. 典型業(yè)務(wù)場(chǎng)景
業(yè)務(wù)中有三種常見導(dǎo)致崩潰的場(chǎng)景,本文從每個(gè)場(chǎng)景中挑選了兩個(gè)典型案例。
5.1 場(chǎng)景一 對(duì)全局變量賦值
典型案例 1
這段代碼定義了全局變量 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 被提前銷毀產(chǎn)生崩潰。
由于使用了 ??dictionaryWithContestOfFile:?? 接口初始化,geckoSettingDict 會(huì)被添加到自動(dòng)釋放池中,導(dǎo)致崩潰發(fā)生在 objc_autoreleasePoolPop 堆棧里,很難追查。這個(gè)問題困擾頭條半年之久,最終借助字節(jié)內(nèi)部 APM 提供的線上工具定位到原因:
修復(fù)辦法是使用 dispatch_once 保證 geckoSettingDict 只賦值一次:
典型案例 2
在圖片監(jiān)控的組中件, queue 被設(shè)計(jì)為全局變量,在 ??startImageMonitor:??? 中對(duì)它初始化,這是啟動(dòng)監(jiān)控功能的方法,調(diào)用一次就可以了。但使用方在某次改動(dòng)中,無(wú)意間在另一個(gè)線程中多調(diào)用了一次 ??startImageMonitor:?? 方法,使 queue 被同時(shí)賦值了兩次,導(dǎo)致它提前銷毀。
另一線程在使用 ??dispatch_async(queue,^{})?? 接口時(shí),由于 queue 已經(jīng)被銷毀,在 dispatch_async 堆棧中發(fā)生崩潰:
崩潰在 ldr x3, [x16, #0x58] 是因?yàn)?x16 存儲(chǔ)的是 dispatch_async 的參數(shù) queue,queue 被銷毀后,queue + 0x58 可能是一個(gè)非法內(nèi)存地址,從該非法地址讀值會(huì)導(dǎo)致異常。
修復(fù)辦法是業(yè)務(wù)方調(diào)整了調(diào)用邏輯,圖片監(jiān)控組件中也優(yōu)化了代碼,使用 dispatch_once 保證 queue 只能賦值一次。
場(chǎng)景小結(jié)
這類問題常見于開發(fā)者設(shè)計(jì)了全局變量,并在對(duì)外暴露的接口中對(duì)全局變量進(jìn)行賦值,開發(fā)者預(yù)期變量只會(huì)初始化一次,但實(shí)際接口被調(diào)用的環(huán)境不可控。
修復(fù)建議:使用 dispatch_once,保證全局變量只被賦值一次。
5.2 場(chǎng)景二 對(duì)屬性賦值
典型案例 1
某類設(shè)計(jì)了屬性 extraParam 用于保存透?jìng)鲄?shù),并在 ??updateExtraParams:??? 方法中更新該屬性。最初 ??updateExtraParams:?? 也在多線程中被調(diào)用,但沒有造成很大影響,某次需求增大了它被同時(shí)調(diào)用的概率,引發(fā)了大面積的崩潰。
典型案例 2
A 業(yè)務(wù)設(shè)計(jì)了單例類 Configure 并提供了對(duì)外的屬性 autoResolutionParams。B 業(yè)務(wù)對(duì) Configure 的屬性 autoResolutionParams 重新賦值使它被銷毀,導(dǎo)致其它正在使用 autoResolutionParams 的線程崩潰。
場(chǎng)景小結(jié)
這類問題常見于類向外部提供了接口來(lái)更新成員變量,但接口被調(diào)用的環(huán)境不可控。
單例的屬性更容易被外界訪問,更容易在多線程下出現(xiàn)賦值,因此這類問題也最多。
修復(fù)建議:涉及多線程修改的屬性,使用 atomic 修飾。
5.3 場(chǎng)景三 屬性懶加載
典型案例 1
某類在懶加載方法中對(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)致它被提前銷毀,產(chǎn)生崩潰。
修復(fù)辦法是將 _interceptUrls 的初始化放在 init 方法中,保證它只被賦值一次。
典型案例 2
某類在懶加載方法中對(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)致它提前銷毀。解決辦法是將 _userCache 初始化放在 init 中,保證它只會(huì)被賦值一次。
場(chǎng)景小結(jié)
這是類場(chǎng)景比上述場(chǎng)景都更加隱蔽,在設(shè)計(jì)懶加載方法時(shí)要考慮觸發(fā)懶加載的方法是否會(huì)在多線程環(huán)境中被調(diào)用。
修復(fù)建議:如果懶加載屬性會(huì)被多線程訪問到,就不要使用懶加載,直接在 init 方法中初始化,保證賦值的代碼只會(huì)被一個(gè)線程訪問。
六. 總結(jié)
產(chǎn)生這類崩潰的原因雖然簡(jiǎn)單,但是在大型 App 中很難避免。隨著業(yè)務(wù)方增多、觸發(fā)賦值代碼的接口增多,調(diào)用環(huán)境會(huì)更復(fù)雜;而且也存在相似代碼 copy ,從無(wú)問題環(huán)境 copy 到有問題環(huán)境,很容易出現(xiàn)多線程環(huán)境下同時(shí)給對(duì)象賦值,導(dǎo)致舊值被過度釋放。
在分析此類崩潰堆棧時(shí),往往很難注意到是賦值時(shí) ARC 添加的 objc_release 指令使舊值被過度釋放導(dǎo)致的,并且線下也基本無(wú)法復(fù)現(xiàn),因此這類野指針問題也容易成為懸案。熟悉原理和常見場(chǎng)景有助于排查問題,更有助于在開發(fā)階段就設(shè)計(jì)穩(wěn)健的代碼。
七. 答疑
- EXC_BAD_ACCESS 是否都是這種問題導(dǎo)致的?
- 不是,訪問非法內(nèi)存地址就會(huì)報(bào) EXC_BAD_ACCESS 錯(cuò)誤。
- 但根據(jù)經(jīng)驗(yàn)來(lái)看,非多線程導(dǎo)致的問題在開發(fā)和測(cè)試環(huán)境中比較容易復(fù)現(xiàn),在上線前基本都會(huì)被修復(fù),上線后才爆發(fā)出來(lái)的野指針問題 80% 都是這個(gè)原因。
- 如何分析此類崩潰?
- 有業(yè)務(wù)代碼堆棧的崩潰,可以通過反匯編推斷出具體崩潰的對(duì)象;在工程中檢索對(duì)該對(duì)象賦值的代碼是否存在多線程調(diào)用,如果存在就基本可以確認(rèn)崩潰原因是多線程賦值導(dǎo)致。
- 純系統(tǒng)堆棧的崩潰,如發(fā)生在 objc_autoreleasePoolPop 堆棧的崩潰。通過反匯編只能推斷出是某個(gè)對(duì)象被 over-release 了,無(wú)法推斷出具體是哪個(gè)對(duì)象。字節(jié)內(nèi)部的同學(xué)可以使用 APM 提供的 Zombie、GWPASan、Coredump 等線上工具 [5]進(jìn)行排查;如果沒有線上工具,需要找到與該崩潰同一版本/時(shí)間段上漲的其它野指針崩潰,它們有可能是同一個(gè)原因?qū)е碌?,從有業(yè)務(wù)代碼堆棧的崩潰入手去排查。
八. 加入我們
我們是字節(jié)跳動(dòng)產(chǎn)品研發(fā)和工程架構(gòu)部-頭條-客戶端基礎(chǔ)技術(shù)-iOS 團(tuán)隊(duì),在性能優(yōu)化、基礎(chǔ)組件、業(yè)務(wù)架構(gòu)、研發(fā)體系、安全合規(guī)、線下質(zhì)量基礎(chǔ)設(shè)施、線上問題定位歸因平臺(tái)等方向深耕,負(fù)責(zé)保障和提升今日頭條、西瓜視頻和番茄小說的產(chǎn)品質(zhì)量與開發(fā)效率,聚焦于此的同時(shí)向外延伸。
如果你對(duì)技術(shù)充滿熱情,喜歡追求極致,渴望用自己的代碼改變數(shù)億用戶的體驗(yàn),歡迎加入我們。目前我們?cè)诒本?、深圳、廣州均有招聘需求,簡(jiǎn)歷投遞郵箱:chenjun.jonas@bytedance.com;郵件標(biāo)題:姓名-工作年限-產(chǎn)品研發(fā)和工程架構(gòu)部-頭條-客戶端基礎(chǔ)技術(shù)-iOS/Android。
九. 參考文獻(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)證 (https://www.jianshu.com/p/62bf046b7701)
[5] 字節(jié)跳動(dòng)如何系統(tǒng)性治理 iOS 穩(wěn)定性問題 (https://juejin.cn/post/7034418275728097288)
標(biāo)題名稱:頭條穩(wěn)定性治理:ARC環(huán)境中對(duì)Objective-C對(duì)象賦值的Crash隱患
網(wǎng)頁(yè)路徑:http://www.dlmjj.cn/article/cdipcih.html


咨詢
建站咨詢
