在之前的文章中提到過,現(xiàn)有市面上的 iPhone 老設(shè)備(特指 iPhone 6s 之前的設(shè)備)占有率高達(dá) 40%,iOS app 卡頓的發(fā)生率發(fā)生概率也很高??D里有一類卡頓又尤其嚴(yán)重:主線程長(zhǎng)期不響應(yīng)而導(dǎo)致的系統(tǒng) Watchdog 強(qiáng)殺。
現(xiàn)在很多 iOS 線上 App 都集成了卡頓檢測(cè)工具,原理多是基于 runloop 的各個(gè)事件回調(diào),這類工具可以檢測(cè)主線程是否在某個(gè) threshold(比如 2 秒) 內(nèi)是否處于可反應(yīng)狀態(tài),比如重復(fù)進(jìn)入某個(gè) runloop 即可認(rèn)為主線程沒有被卡住。我們姑且稱這類工具為 AppWatchdog,但對(duì)于嚴(yán)重的主線程卡頓,比如超過 10 秒會(huì)被系統(tǒng)強(qiáng)殺,我們稱這種系統(tǒng)機(jī)制為 iOSWatchdog。為了方便表述,我們進(jìn)一步將 AppWatchdog 偵測(cè)到的卡頓我們稱為 stall,iOSWatchdog 強(qiáng)殺的卡頓我們稱為 hard stall。
AppWatchdog 偵測(cè)到的 stall 很好分析,我們只需要在發(fā)生 stall 時(shí),在另外一個(gè)線程將主線程的 call stack 記錄下來上報(bào)即可,而后對(duì)癥下藥在下個(gè)版本修復(fù)。但對(duì)于 hard stall,準(zhǔn)確檢測(cè)很難,原因是系統(tǒng)強(qiáng)殺時(shí)并沒有任何信號(hào)提醒,也不會(huì)像 crash 一樣生成一個(gè) report,現(xiàn)在主流 App 是怎么做的呢?
我們先從 Facebook 曾經(jīng)公開發(fā)布過的一篇相關(guān)文章說起:Reducing FOOMs in the Facebook iOS app。
這篇文章系統(tǒng)化的提出了 iOS App 冷啟動(dòng)分析方法,比如 App 升級(jí),用戶強(qiáng)殺,App crash,系統(tǒng)升級(jí),后臺(tái)內(nèi)存不夠(BOOM),前臺(tái)內(nèi)存不夠(FOOM)等,類似一個(gè) pipeline,一個(gè)個(gè)分析下來,最后剩下的就是 FOOM,即 app 在前臺(tái)由于消耗過多內(nèi)存而被系統(tǒng)強(qiáng)殺。
近兩年大家開始意識(shí)到這個(gè) pipeline 漏分析了一個(gè)重要的冷啟動(dòng)原因:hard stall,而且事實(shí)證明 hard stall 出現(xiàn)的概率還不低??赡艽蠹乙矝]意識(shí)到 hard stall 會(huì)這么容易出現(xiàn)在線上 App 中。
在繼續(xù)分析之前,我們?cè)龠M(jìn)一步明確下 hard stall 的定義,一般我們指系統(tǒng)強(qiáng)殺為 hard stall,但一般用戶會(huì)在 App 卡頓長(zhǎng)達(dá) 10 秒之久時(shí)依舊等待嗎?我比較懷疑,我認(rèn)為相當(dāng)一部分用戶會(huì)提前手動(dòng)殺掉 App,還有一小部分用戶會(huì)直接放棄當(dāng)前 App 進(jìn)入后臺(tái)。我覺得我們可以將這類導(dǎo)致用戶中斷當(dāng)前任務(wù)的卡頓都稱為 hard stall,雖然不是系統(tǒng)強(qiáng)殺,但在嚴(yán)重性質(zhì)上相差無幾。
微信客戶端團(tuán)隊(duì)曾經(jīng)分享過一篇類似主題的文章:iOS微信內(nèi)存監(jiān)控 ,其中有一段相關(guān)表述:
前臺(tái)卡死引起系統(tǒng)watchdog強(qiáng)殺
也就是常見的0x8badf00d,通常原因是前臺(tái)線程過多,死鎖,或CPU使用率持續(xù)過高等,這類強(qiáng)殺無法被App捕獲。為此我們結(jié)合了已有卡頓系統(tǒng),當(dāng)前臺(tái)運(yùn)行最后一刻有捕獲到卡頓,我們認(rèn)為這次啟動(dòng)是被watchdog強(qiáng)殺。同時(shí)我們從FOOM劃分出新的重啟原因叫“APP前臺(tái)卡死導(dǎo)致重啟”,列入重點(diǎn)關(guān)注。
所以微信客戶端的做法是檢查 stall 的時(shí)間戳和冷啟動(dòng)的時(shí)間戳,如果二者比較接近,則認(rèn)為是 hard stall。這種做法應(yīng)該比較簡(jiǎn)單有效,但無法檢測(cè)用戶放棄當(dāng)前任務(wù)的 hard stall 場(chǎng)景,而”‘相近“的 threshold 取多少秒也難以把握。FB 內(nèi)部也有一套機(jī)制來區(qū)分 stall 與 hard stall,但我個(gè)人感覺也不是十分準(zhǔn)確,最近在思考如何改進(jìn)這一檢測(cè)機(jī)制,現(xiàn)在大致有個(gè)思路和大家分享,等下半年抽空實(shí)踐下。
如何檢測(cè)
用一句話來概括這個(gè)思路就是:如果 stall 導(dǎo)致 runloop 無法從當(dāng)前任務(wù)中恢復(fù),則認(rèn)之為 hard stall。
我們知道,主線程的 runloop 用來處理各種任務(wù),每一次 loop 會(huì)觸發(fā)幾種不同類型的回調(diào):
kCFRunLoopBeforeTimers
kCFRunLoopBeforeSources
kCFRunLoopBeforeWaiting
kCFRunLoopAfterWaiting
某個(gè)回調(diào)可能會(huì)觸發(fā)我們客戶端里的某個(gè)耗時(shí)任務(wù),一般一個(gè) loop 里會(huì)觸發(fā)多個(gè)任務(wù),比如 job1,job2,job3,執(zhí)行順序及耗時(shí)如下:
job1(10ms) --> job2(1500ms)-->job3(15000ms) --> 一小時(shí)之后冷啟動(dòng)
很明顯,job1 是安全的,job2 會(huì)觸發(fā) stall,并被我們的 AppWatchdog 工具檢測(cè)上報(bào)(假設(shè) threshold 為 2s),job3 會(huì)首先被 AppWatchdog 檢測(cè),而后由于長(zhǎng)期阻塞主線程,被系統(tǒng) watchdog 強(qiáng)殺,是真正的 hard stall。那么當(dāng)我們?cè)诤笈_(tái)看到 job2 和 job3 的上報(bào)日志時(shí),怎么判斷那個(gè)才是 hard stall 呢?顯然我們無法記錄每一個(gè)任務(wù)的執(zhí)行時(shí)間,所以并不知道 job2 和 job3 哪個(gè)更嚴(yán)重,或者說 job3 是不是足夠嚴(yán)重。
回到上面對(duì)于思路的概括,我們就看 runloop 能否從 stall 當(dāng)中恢復(fù)出來,我們可以在 runloop 每次進(jìn)入不同事件的時(shí)候,如果上次發(fā)生過 stall,我們就記錄一個(gè)新的時(shí)間戳 activeTs,來表示 runloop 在未來某個(gè)時(shí)間點(diǎn)從 stall 中恢復(fù)了,然后再在下次冷啟動(dòng)的時(shí)候做如下判斷:
if (report.stallTs < activeTs) {
report.isHardStall = true;
} else {
report.isHardStall = false;
}
所以上面的時(shí)間序列會(huì)變成:
job1(10ms) --> job2(1500ms)-->activeTs-->job3(15000ms) --> 一小時(shí)之后冷啟動(dòng)
很顯然,job2 執(zhí)行之后,我們記錄一個(gè) runloop 活躍的時(shí)間戳,那么表明 job2 是常規(guī) stall,而 job3 由于耗時(shí)過長(zhǎng)導(dǎo)致系統(tǒng)強(qiáng)殺,runloop 沒有機(jī)會(huì)進(jìn)入下一次 loop,則沒有記錄下最新的 activeTs,所以,依據(jù)上面的條件判斷,可以輕易的檢測(cè)出 job2 為常規(guī) stall,而 job3 為 hard stall。
這種判斷機(jī)制更有針對(duì)性,所以即使 hard stall 之后用戶放棄當(dāng)前任務(wù),過很長(zhǎng)時(shí)間之后再次啟動(dòng) App,我們依然能夠判斷出哪個(gè) stall(call stack)是真正的 hard stall。
一些細(xì)節(jié)
說完大致思路,看著好像并不怎么復(fù)雜,但魔鬼藏在細(xì)節(jié)里,有哪些需要注意的細(xì)節(jié)呢?暫時(shí)想到的有:
- stall 檢測(cè)工具是為了檢測(cè) stall,所以本身應(yīng)該輕量,盡量避免費(fèi)時(shí)的任務(wù)或者是 disk I/O,而記錄 activeTs 必然會(huì)有一次額外的磁盤寫操作,我們應(yīng)該做到一次 App 啟動(dòng)最多只記錄一次 activeTs 到磁盤里,而且只發(fā)生在有 stall 的情況下。
- 為了效率起見,在下次冷啟動(dòng)的時(shí)候,我們應(yīng)該只處理上次啟動(dòng)留下的 stall reports,也能夠避免記錄過多的 activeTs。為此,我們需要引入一個(gè) Session 的概念,即每一次啟動(dòng)對(duì)應(yīng)一次 Session,每個(gè) Session 只處理上一個(gè) Session 的 stall reports,我們可以將一個(gè)連續(xù)自增長(zhǎng)的 Session ID 寫入 stall report 里,這樣就知道每一個(gè) stall 對(duì)應(yīng)那一次啟動(dòng),甚至可以將 Session ID 寫入 stall report 的文件名,方便過濾,既高效有簡(jiǎn)潔。
- 如果 runloop 在執(zhí)行完某個(gè)任務(wù)進(jìn)入休眠,獲得 kCFRunLoopBeforeWaiting 回調(diào),此時(shí)我們也需要記錄 activeTs,因?yàn)槟軌蜻M(jìn)入休眠表明非 hard stall。
最后用圖再描述下具體思路:

等待真正去實(shí)現(xiàn)的時(shí)候,估計(jì)還有不少細(xì)節(jié)需要處理,后面做好了再更新一篇文章。
全文完。