前言
App中大多數的性能指標都和時間相關,如啓動速度,列表滑動FPS,頁面打開耗時等等。爲了優化這些指標,我們需要了解時間都消耗在哪裏。
通常我們會打開Time Profiler,通過聚合Call Stack來分析和優化代碼耗時。偶爾會出現優化後Time Profiler已經沒有什麼高耗時的Call Stack,但列表滑動仍然掉幀,這時候應該怎麼辦呢?
不妨試試System Trace~
一個實際例子
用dyld提供的C接口來註冊image加載的回調是一種常見做法,第一次註冊回調的時候會立刻把當前已經加載的image回調,所以如果回調函數裏有耗時操作,我們一般會在子線程註冊。
模擬啓動的時候註冊dyld回調:
接着在真機上運行這段代碼,發現在啓動頁面會卡很久:
爲什麼這裏會卡很久呢?
試試Time Profiler?
就不具體講解Time Profiler的用法了,相關資料網上很多,通過分析我們可以拿到如下的調用棧:
嗯~??不對啊,明明耗時好幾秒,怎麼統計出來的只有堆棧142ms。可以看到Time Profiler提供的信息有限,以下兩條算是比較有用的:
- 區間選中有3.17s,但是統計到的調用棧卻只有142ms
- iPhone 6一共只有兩個Core,兩個Core在這段時間內,大部分時候都是空閒的。
似乎線程在等待什麼?
System Trace
System Trace一個比較大的優勢是可以看到線程的狀態,分析上面的代碼(如下圖),發現主線程有一段3s左右的時間被block住了,通過右側的調用棧能看到主線程在didFinishLaunching中調用了imageNamed
,後者觸發了dlopen,dlopen裏在等待一個互斥鎖。
切換到Events:Thread State,可以看到線程切換的每一個事件,和狀態切換的發生的原因,切換後再選中主線程被block的這段時間,觀察這個事件的下一個事件,因爲下個事件通常是鎖被釋放,線程重新進入可執行的狀態。
我們發現,主線程因爲線程0x9e1f釋放鎖才進入了可執行的狀態。這時候把線程0x9e1f也pin一下,觀察到主線程被block這段時間,這個線程在不停的sleep 10ms左右然後繼續執行代碼,通過右側的調用棧會發現,sleep的源頭是在_dyld_register_func_for_add_image
,到這裏原因找到了:子線程卡住了主線程。
細心的朋友會發現,右側的調用棧裏並沒有最上面C函數:
add_image_callback
,這是因爲add_image_callback
內部只調用了usleep
,這一層調用完全沒有存在的必要,所以release模式下編譯器做了優化。
小結
通過System Trace分析,我們很容易就找到了這次啓動耗時長的原因:主線程的imageNamed內部觸發了dlopen,子線程在執行執行_dyld_register_func_for_add_image
的回調,二者都需要先獲取同一個互斥鎖,所以子線程卡住了主線程。
這個例子讓我們見識到了System Trace的威力,那麼System Trace一般可以用來分析哪些問題呢?
- 鎖的互斥,主要是主線程等子線程釋放鎖
- 線程優先級,搶佔和高優線程超過CPU核心數量
- 虛擬內存,Page Fault的代價其實不小
- 系統調用,瞭解性能瓶系統正在做什麼
Tips: Runtime, dyld存在着很多隱藏的全局互斥鎖,很容易踩到雷。
Time Profiler的原理
這部分回答一個問題:爲什麼剛剛的代碼裏,Time Profiler看不出端倪?
正常Time Profiler會1ms採樣一次,默認只採集所有在運行線程的調用棧,最後以統計學的方式彙總。比如下圖中的5次採樣中,method3都沒有采樣到,所以最後聚合到的棧裏就看不到method3。
Time Profiler中的看到的時間,並不是代碼實際執行的時間,而是棧在採樣統計中出現的時間。另外,Time Profiler還有一些配置是容易忽略的,依次點擊File -> Recording Options:
- High Frequency,降低採樣的時間間隔
- Record Kernel Callstacks,記錄內核的調用棧
- Record Waiting Thread,記錄被block的線程
第三個選項比較有用,比如剛剛的代碼開啓Record Waiting Thread,再運行Time Profiler就會發現主線程很長一段時間在等待一個互斥鎖:
System Trace
用好System Trace的前提是對操作系統的一些核心基礎知識有基本的瞭解,所以這一小節會講解下System Trace的各個模塊的使用方式以及涉及到的基本原理。
Point of Interest
有時候我們只關心某一段小段時間的性能,如何把時間段和System Trace對應起來呢?
可以通過kdebug_signpost
相關的接口相關的接口來打一些點,這些點會在Point of Interest區域中顯示,比如剛剛的代碼我們想標記出來didFinishLaunch這個方法,就可以在方法的收尾添加kdebug_interval:
kdebug_signpost_start(10, 0, 0, 0, 0);
kdebug_signpost_end(10, 0, 0, 0, 0);
然後重新運行System Trace,即可在Point of Interest觀察到該方法的執行區間
但顯示Code 10明顯是不友好的,可以在File -> Recording Options裏做Code映射,變成可讀的字符串。Color Using Last Argument可以把最後一個code映射到不同顏色,進一步提高辨識度。
> Tips: > - kdebug_signpost_start的幾個參數是用來做多級區分的,但整型本身不夠友好,顯示區間可以也可以用`os_signpost`相關的API > - signpost配合Objective C的Swizzling往往會發揮意想不到的效果Thread State Trace
System Trace一個比較很重要的特性就是能看到線程不同的狀態,以及狀態之間切換的原因,通常我們會選擇一個時間段,然後彙總觀察結果:
幾個線程狀態說明:
- Running,線程在CPU上運行
- Blocked,線程被掛起,原因有很多,比如等待鎖,sleep,File Backed Page In等等。
- Runnable,線程處於可執行狀態,但此時等CPU有資源的時候,就可以運行
- Interrupted,被打斷,通常是因爲一些系統事件,一般不需要關注
- Preempted,被搶佔,優先級更高的線程進入了Runnable狀態
Blocked和Preempted是優化的時候需要比較關注的兩個狀態,分析的時候通常需要知道切換到這兩個狀態的原因,這時候要切換到Events: Thread State模式,然後查看狀態切換的前一個和後一個事件,往往能找到狀態切換的原因。
Tips:在線程上右鍵,可以快速把線程設置爲filter或者pin,方便分析,尤其是大型App的線程非常多的情況下。
除了Thread State Event比較有用,另外一個比較有用的事Narrative,這裏會把所有的事件,包括下文的虛擬內存等按照時間軸的方式彙總:
Virtual Memory Trace
內存分爲物理內存和虛擬內存,二者按照Page的方式進行映射。
可執行文件,也就是Mach-O本質上是通過mmap相關API映射到虛擬內存中的,這時候只分配了虛擬內存,並沒有分配物理內存。如果訪問一個虛擬內存地址,而物理內存中不存在的時候,會怎麼樣呢?會觸發一個File Backed Page In,分配物理內存,並把文件中的內容拷貝到物理內存裏,如果在操作系統的物理內存裏有緩存,則會觸發一個Page Cache Hit,後者是比較快的,這也是熱啓動比冷啓動快的原因之一。
這種剛剛讀入沒有被修改的頁都是Clean Page,是可以在多個進程之間共享的。所以像__TEXT段這種只讀的段,映射的都是Clearn Page。
_DATA
段是可讀寫的,當_DATA
段中的頁沒有被修改的時候,同樣也可以在兩個進程共享。但一個進程要寫入,就會觸發一次Copy On Write,把頁複製一份,重新分配物理內存。這樣被寫入的頁稱爲Dirty Page,無法在進程之間共享。像全局變量這種初始值都是零的,對應的頁在讀入後會觸發一次內存寫入零的操作,稱作Zero Fill。
iOS不支持內存Swapping out即把內存交換到磁盤,但卻支持內存壓縮(Compress memory),對應被壓縮的內存訪問的時候就需要解壓縮(Decompress memory),所以在Virtial Memroy Trace裏偶爾能看到內存解壓縮的耗時。
System Load
以10ms爲緯度,統計活躍的高優線程數量和CPU核心數對比,如果高於核心數量會顯示成黃色,小於等於核心數量會是綠色。這個工具是用來幫助調試線程的優先級的:
線程的優先級可以通過QoS來指定,比如GCD在創建Queue的時候指定,NSOperationQueue通過屬性指定:
//GCD
dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_UTILITY, -1);
dispatch_queue_t queue = dispatch_queue_create("com.custom.utility.queue", attr);
//NSOperationQueue
operationQueue.qualityOfService = NSQualityOfServiceUtility
選擇合適的優先級,避免優先級反轉,影響線程的執行效率,尤其是別讓後臺線程搶佔主線程的時間。延伸閱讀:libdispatch efficiency tips
System Call & Context Switch
操作系統爲了安全考慮,把文件讀寫(open/close/write/read),鎖(ulock_wait/ulock_wake)等核心操作封裝到了內核裏,用戶態必須調用內核提供的接口才能完成對應的操作,這樣的調用稱作系統調用System Call。System Trace裏提供了系統調用相關的Event:
線程/進程需要輪流到CPU上執行,在切換的時候,必須把線程/進程狀態保存下來,之後才能恢復,這種保存/恢復的過程稱作上下文切換Context Switch,在System Trace裏通常會關注下主線程是否在頻繁的上下文切換:
Thermal State
一個大家不怎麼關注,但其實挺重要的性能指標是發熱狀態,因爲發熱後系統會限制CPU/GPU/IO等使用。System Trace也提供了對應的分析工具
iOS 11之後,可以通過NSProcesssInfo的相關API來獲取當前發熱狀態:
NSProcessInfo.processInfo.thermalState
一共有四種狀態,正常的狀態是Nominal,後面逐級嚴重:
- Nominal
- Fair
- Serious
- Critical
Xcode也提供了工具來模擬發熱狀態,讓開發者可以測量不同發熱情況下App的體驗,在Xcode中,依次選擇Window -> Device And Simulator,然後按照下圖的方式開啓:
Tips:非遊戲類App一般不會引起發熱,除非有一些邏輯上Bug引起死循環,或者不停發送網絡請求。
總結
當遇到性能瓶頸的時候,優先還是建議Time Profiler,因爲簡單直接,排查效率的問題高。如果遇到Time Profiler裏採樣到的時間和實際消耗的時間差距比較大的時候,或者像優化FPS這種需要精細化的瞭解16ms線程都在做什麼的時候,可以試試System Trace,也許會有意外的收穫。