為啥要做線上探測
iOS的常規(guī)崩潰數(shù)量已經(jīng)不多猾编,剩余的崩潰往往是不能穩(wěn)定復(fù)現(xiàn)或缺乏有效信息佳吞。經(jīng)過線上統(tǒng)計(jì)后我發(fā)現(xiàn)目前剩余的無法定位和解決的崩潰有60%+都是由于野指針引起舀患。各種各樣的堆棧千奇百怪忆矛,比較典型的堆棧如下:
當(dāng)然還有其他類型的堆棧卵迂,但是這些堆棧都有一個(gè)特征:都是在release
或者retain
時(shí)發(fā)生了崩潰裕便。
如果你的APP是首次監(jiān)控的野指針崩潰,那么建議你首先進(jìn)行線下的模擬和復(fù)現(xiàn)见咒,Xcode
提供了很多優(yōu)秀的工具:Address Sanitizer
闪金、SCribble
、Zombie
等论颅。
但是在實(shí)踐過程中很難依靠上述工具復(fù)現(xiàn)問題哎垦,這跟APP的性質(zhì)有很大的關(guān)系。如果APP是一個(gè)聚合平臺恃疯,整合了非常多的業(yè)務(wù)漏设,并且這些業(yè)務(wù)是由各自獨(dú)立的團(tuán)隊(duì)開發(fā),有各自的入口和觸發(fā)邏輯今妄,甚至有獨(dú)立的灰度策略郑口,那我們可能連測試入口都無法找到。面對這種情況盾鳞,只能依賴線上手段去收集和排查問題犬性。
做了一些技術(shù)調(diào)研
參考的文章我列舉到了參考文獻(xiàn)中,感興趣的可以閱讀腾仅。我整理了下大體的思路可以分為2類:
-
hook free
在
free
時(shí)乒裆,并不釋放內(nèi)存,保留內(nèi)存推励,判斷是否為objc
對象鹤耍,如果是objc
對象則將對象setclass
為自定義類,借助消息轉(zhuǎn)發(fā)得到堆棧和類信息验辞。 -
hook dealloc
在
dealloc
時(shí)判斷是否需要開啟野指針探測稿黄,如果不需要則直接釋放,否則將對象修改isa
后保留并加入到內(nèi)存池中跌造,再次調(diào)用對象時(shí)會觸發(fā)消息轉(zhuǎn)發(fā)攔截到堆棧及對象類名信息杆怕。
我查看了很多業(yè)界的方案,針對OC的方案基本上大同小異壳贪,思路都是保存對象陵珍,等再次調(diào)用時(shí)觸發(fā)消息轉(zhuǎn)發(fā)攔截到堆棧及類型信息。但是如何控制內(nèi)存增長撑碴、到底對哪些類做監(jiān)控等實(shí)際問題都沒有做太多的介紹撑教。上述列舉的方案在debug階段或者灰度階段使用尚可,如果真的在線上使用醉拓,可能造成不小的性能問題伟姐。因此野指針探測如果上線收苏,核心問題是如何在保證有效覆蓋率的前提下控制性能損耗。
探索與實(shí)踐
在剛開始時(shí)愤兵,我嘗試使用通過從文章中獲取到的技術(shù)方案直接落實(shí)到項(xiàng)目中鹿霸,并且監(jiān)控了全部的OC類。調(diào)試時(shí)我發(fā)現(xiàn)APP光啟動階段就已經(jīng)耗費(fèi)了很長時(shí)間秆乳。這是由于由于監(jiān)控的類型過多懦鼠,有很多非常頻繁釋放的類型也被我們監(jiān)控和處理,導(dǎo)致性能下滑非常嚴(yán)重屹堰。因此在這里我做了多次優(yōu)化肛冶,主要手段如下:
屏蔽一些無關(guān)緊要的類型
在dealloc中,盡量不要做耗性能操作
用線程池將大量任務(wù)分發(fā)到子線程
如何屏蔽一些無關(guān)緊要的類型扯键?
首先來回答第一個(gè)問題睦袖,如何屏蔽一些無關(guān)緊要的類型監(jiān)控。在開發(fā)階段我發(fā)現(xiàn)很多底層xpc
類以及很多不曾見過的類型都被我們監(jiān)控荣刑,這顯然有些扯淡馅笙。因此為了優(yōu)化監(jiān)控的類型范圍,我做了2次改進(jìn)厉亏。首先說下第一次改進(jìn):
基于動態(tài)庫的優(yōu)化方案
眾所周知董习,我們APP的主執(zhí)行文件依賴了很多的系統(tǒng)庫,這些系統(tǒng)庫是我們所使用到的明確聲明鏈接到程序上的爱只。但是這些動態(tài)庫也會依賴其他的系統(tǒng)庫皿淋,在這里我將這些系統(tǒng)庫稱為:二級動態(tài)庫。顯然這些系統(tǒng)庫的類不是我們所關(guān)心的虱颗。
因此需要在運(yùn)行階段排除來自二級系統(tǒng)庫的這些類沥匈。
那如何確定一個(gè)類來自哪個(gè)庫呢蔗喂?
我最先想到的是dladdr
忘渔,dladdr
可以在運(yùn)行階段告訴我們這個(gè)地址的詳細(xì)信息,其中就包括鏡像文件信息缰儿。那我們就能直接知道這個(gè)類是否需要監(jiān)控畦粮。但是,如果你這樣做的話就會發(fā)現(xiàn)一個(gè)非常明顯的問題乖阵,那就是:這個(gè)函數(shù)運(yùn)行簡直~太宣赔!慢!了5山!,根本無法支撐大量且頻繁的調(diào)用竞端。因此我的優(yōu)化方案是,根據(jù)先獲取類名地址贡翘,根據(jù)類名地址的區(qū)間判斷在哪個(gè)庫中。
具體實(shí)現(xiàn)為:在啟動時(shí)讀取所有的image
以及每個(gè)image
的TEXT
段地址范圍砰逻,然后存儲到unordermap
中鸣驱。當(dāng)然這里只是列舉了大體的思路,具體實(shí)現(xiàn)時(shí)還需要處理對段遷移方案的適配蝠咆。經(jīng)過上述優(yōu)化后踊东,實(shí)際效率大幅提升。
當(dāng)然刚操,這并不是一個(gè)很完美的方案闸翅,因?yàn)樵陂_發(fā)階段我發(fā)現(xiàn)我為野指針開辟的緩存池很快就被耗盡,啟動階段菊霜,30MB的緩存池竟然6~11秒就耗盡缎脾,這個(gè)消耗速度有點(diǎn)太快,按我個(gè)人理解占卧,在常規(guī)使用下遗菠,一個(gè)對象能在緩存池中存在30秒才算及格。為此华蜒,我將每個(gè)對象的類型及每個(gè)對象的大小寫入文件辙纬,查看后發(fā)現(xiàn)盡管我們做了動態(tài)庫的過濾,但是依舊有很多我們沒有見過的類型也被納入到了監(jiān)控范圍叭喜。這個(gè)很好理解贺拣,UIKitCore
、libobjc
等我們常見的動態(tài)庫中依舊有很多大量的我們沒用過的類型捂蕴。因此我們需要轉(zhuǎn)換思路譬涡,采用更精細(xì)的監(jiān)控:只監(jiān)控我們用到的系統(tǒng)類。
基于Bind信息的優(yōu)化方案
監(jiān)控我們用到的系統(tǒng)類的難點(diǎn)在:如何獲取到項(xiàng)目中用到的所有系統(tǒng)類啥辨?這一步可以參考我的另一篇文章:從野指針探測到對iOS 15 bind 的探索 (文章比較長涡匀,耐著性子讀一下應(yīng)該會有所收獲),在這里不再重復(fù)溉知。
dealloc中千萬不要做的事情
方案的整體思路是hook dealloc
陨瘩,因此我們不可避免地要在dealloc
階段注入我們的代碼,這里有幾個(gè)小的注意點(diǎn):
不要調(diào)用任何OC代碼
不要使用
objc_setAssociatedObject
不要直接上來一頓操作级乍,先判斷下
isTaggedPointer
第一點(diǎn)很容易理解舌劳,因?yàn)槟阏{(diào)用了任何OC代碼都可能導(dǎo)致在dealloc中繼續(xù)引起額外的對象釋放,而這些對象釋放有可能又被你納入到監(jiān)控范圍玫荣。
第二點(diǎn)可能很多同學(xué)想象不到甚淡,不使用objc_setAssociatedObject
是因?yàn)?code>objc_setAssociatedObject有不小的內(nèi)存消耗(約96B)有在大量暴力使用時(shí)才能發(fā)現(xiàn)。
第三點(diǎn)是可能很多方案沒有提到的捅厂,TaggedPointer
我們沒有必要做更進(jìn)一步的監(jiān)控浪費(fèi)緩存池贯卦。關(guān)于TaggedPointer
的介紹可以參考字節(jié)APM的文章:Tagged Pointer對象安全氣墊為何會失效
多線程與內(nèi)存優(yōu)化
多線程處理
開發(fā)階段底挫,由于各項(xiàng)性能指標(biāo)都不理想,因此將批量釋放對象以及對象入池包裝為Task
加入到任務(wù)隊(duì)列中脸侥,多線程處理Task
建邓。我已經(jīng)忘記了當(dāng)時(shí)這么處理是因?yàn)槟膲K性能問題了??,印象中好像是不這么處理掉幀明顯睁枕,最近好奇把對象入池同步處理也沒發(fā)現(xiàn)有明顯掉幀現(xiàn)象官边,比較尷尬??。
內(nèi)存優(yōu)化
內(nèi)存上的優(yōu)化主要在捕捉堆棧上外遇。野指針探測實(shí)踐就會發(fā)現(xiàn)注簿,單單知道類名以及野指針發(fā)生時(shí)的堆棧是不夠的。作為開發(fā)者跳仿,我還想知道這個(gè)對象到底是在什么時(shí)候釋放的诡渴。因此野指針探測我加了記錄釋放堆棧的功能,當(dāng)然這個(gè)功能不會全量開放菲语,僅針對配置的指定類型進(jìn)行記錄妄辩。這里有2個(gè)比較有意思的問題:
堆棧的大小是否能優(yōu)化?
抓取堆棧時(shí)能否用
memcpy
替代vm_read_overwrite
山上?
所謂的堆棧眼耀,在符號化之前其實(shí)就是一堆UInt64的數(shù)據(jù),假設(shè)我們的堆棧一共有10行信息佩憾,那么實(shí)際上就是10個(gè)UInt64數(shù)據(jù)哮伟,共640字節(jié)。但是實(shí)際上iOS中一個(gè)指針UInt64根本用不上全部的64bit信息妄帘。因此在這里可以做個(gè)優(yōu)化楞黄,用堆棧距離(UInt64)&_mh_execute_header
的偏移來替代堆棧,這樣既可用32bit信息來表示64bit信息抡驼。記錄堆棧所消耗的內(nèi)存減少接近一半鬼廓。
另外,還有個(gè)有趣的問題婶恼。大家看到的很多關(guān)于抓取堆棧的代碼中桑阶,保存棧幀的函數(shù)都是vm_read_overwrite
,為什么不用memcpy或者像這樣直接用指針去操作呢勾邦?vm_read_overwrite
非常慢,在dealloc
中即使是灰度少量地使用割择,也絕對會卡爆你眷篇。那在dealloc
中我們到底能不能用memcpy
呢?
從這個(gè)問題我發(fā)現(xiàn)我對內(nèi)存機(jī)制不了解荔泳,感覺內(nèi)存這塊很有趣蕉饼。
簡單來說vm_read_overwrite
是安全讀取地址的虐杯,具有探測機(jī)制,即使是個(gè)非法地址也能保證程序正常運(yùn)行昧港。但是memcpy
則是簡單直接但是不安全擎椰。至于為什么大多數(shù)抓取堆棧都是使用vm_read_overwrite
則是看中了vm_read_overwrite
的安全能力,因?yàn)榭缇€程回溯堆棧并且沒有掛起所有線程创肥,可能會造成讀取非法地址的情況达舒。因此只要我們能保證當(dāng)前線程堆棧不會被破壞就可以用memcpy
替代vm_read_overwrite
,顯然我們這里是在當(dāng)前dealloc
線程同步回溯叹侄,不會出現(xiàn)問題巩搏,因此可以用memcpy
優(yōu)化調(diào)用。
線上效果
說了那么多趾代,這東西到底敢不敢上線使用贯底?使用后到底能不能發(fā)現(xiàn)問題?目前代碼已經(jīng)上線一段時(shí)間了撒强,線上放量30w用戶禽捆,總的來說還是能收集到一些問題的。例如下面的問題飘哨,根據(jù)捕捉到的信息排查后發(fā)現(xiàn)睦擂,是多線程使用不當(dāng)引起過度釋放。
抽象總結(jié)起來就是:
self.dic = [NSDictionary new];
for (int i = 0 ; i < 3000 ; i++) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
self.dic = @{@"name":@(i)};
});
}
不足與改進(jìn)
一個(gè)技術(shù)方案不能只說優(yōu)點(diǎn)杖玲,還應(yīng)該給大家展示下相應(yīng)的缺點(diǎn)及不足顿仇。總的來說從現(xiàn)階段來說我感覺有3點(diǎn)不太滿意:
內(nèi)存摆马、內(nèi)存臼闻、內(nèi)存!目前實(shí)際消耗的內(nèi)存要比我們記錄的消耗要偏大囤采。這是由于還有些我們調(diào)用的函數(shù)內(nèi)部會消耗一些內(nèi)存述呐,還沒有被發(fā)現(xiàn),這會導(dǎo)致極端情況下實(shí)際內(nèi)存在一直增長而內(nèi)存緩存遲遲得不到釋放蕉毯。典型的例子就是
objc_setAssociatedObject
乓搬,這個(gè)函數(shù)內(nèi)部維護(hù)了一個(gè)unordermap
。缺少相應(yīng)的控制平臺代虾。目前灰度都是服務(wù)端寫死進(jìn)行控制进肯,想要靈活控制非常不方便。我的想法是可以根據(jù)機(jī)型棉磨、系統(tǒng)江掩、版本等按百分比進(jìn)行控制灰度,并且可以靈活配置針對設(shè)備進(jìn)行分布式探測,例如總共8000個(gè)類环形,按設(shè)備占比分布到8種設(shè)備上策泣,這樣每種設(shè)備只承擔(dān)了1000個(gè)監(jiān)控任務(wù),壓力極大減少抬吟。這一步正在規(guī)劃中萨咕,要放假了,年后再說~
缺少通用符號化平臺火本。目前我們上報(bào)的堆棧還沒有被符號化危队,需要本地進(jìn)行符號化,這對開發(fā)者有一定的要求发侵,使用不便交掏。規(guī)劃、放假刃鳄、年后說~
參考文獻(xiàn)
1盅弛、大白健康系統(tǒng)--iOS APP運(yùn)行時(shí)Crash自動修復(fù)系統(tǒng)
3叔锐、iOS 野指針定位:野指針嗅探器
4挪鹏、iOS野指針定位總結(jié)
6愉烙、xiejunyi'Blog