簡介: APP 啓動速度的重要性不言而喻。高德地圖是一個有着上億用戶的超級 APP,本文從喚端技術、H5 啓動頁、下載速度、APP加載、線程調度和任務編排等方面,詳解相關技術原理和實現方案,分享高德在啓動優化上用到的手段和思考,但願對同窗們有所啓發。html
App 都會存在拉新和導流的訴求,如何提升這些場景下的用戶體驗呢?這裏會用到喚端技術。包含選擇什麼樣的換端協議,咱們先看看喚端路徑,以下:python
喚端的協議分爲自定義協議和平臺標準協議,自定義協議在 iOS 端會有系統提示彈框,在 Android 端 chrome 25 後自定義協議失效,需用 Intent 協議包裝才能打開 App。若是但願提升體驗最好使用平臺標準協議。平臺標準協議在 iOS 平臺叫 Universal Links,在 iOS 9 開始引入的,因此 iOS 9 及以上系統都支持,若是用戶安裝了要跳的 App 就會直接跳到 App,不會有系統彈框提示。相對應的 Android 平臺標準協議叫 App Links,Android 6 以上都支持。linux
這裏須要注意的是 iOS 的 Universal Links 不支持自動喚端,也就是頁面加載後自動執行喚端是不行的,須要用戶主動點擊進行喚端。對於自定義協議和平臺標準協議在有些 App 裏是遇到屏蔽或者那些 App 自定義彈窗提示,這就只能經過溝通加白來解決了。android
另外對於啓動時展現 H5 啓動頁,或喚端跳轉特定功能頁,能夠將攔截判斷置前,判斷出啓動去往功能頁,優先加載功能頁的任務,主圖相關任務項延後再加載,以提高啓動到特定頁面的速度。ios
如今 App 啓動會在有活動時先彈出活動運營 H5 頁面提升活動曝光率。但若是 H5 加載慢勢必很是影響啓動的體驗。c++
iOS 的話可使用 ODR(On-Demand Resources) 在安裝後先下載下來,點擊啓動前實際上就能夠直接加載本地的了。ODR 安裝後馬上下載的模式,下載資源會被清除,因此須要將下載內容移動到自定義的地方,同時還須要作本身兜底的下載來保證在 On-Demand Resources 下載失敗時,還可以再從本身兜底服務器上拉下資源。chrome
On-Demand Resources 還可以放不少資源,甚至包括腳本代碼的預加載,能夠減小包體積。因爲使用的是蘋果服務器,還可以減小 CDN 產生的峯值成本。macos
若是不使用 On-Demand Resources 也能夠對 WKWebView 進行預加載,雖然安裝後第一次仍是須要從服務器上加載一次,不事後面就能夠從本地快速讀取了。編程
iOS 有三套方案,一套是經過 WKBrowsingContextController 註冊 scheme,使用 URLProtocol 進行網絡攔截。第二套是基於 WKURLSchemeHandler 自定義 scheme 攔截請求。第三套是在本地搭建 local server,攔截網絡請求重定向到本地 server。第三套搭建本地 server 成本高,啓動 server 比較耗時。第二套 WKURLSchemeHandler 使用自定義 scheme,對於 H5 適配成本很高,並且須要 iOS 11 以上系統支持。json
第一套方案是使用了 WKBrowsingContextController 的 registerSchemeForCustomProtocol: 這個方法,這個方法的參數設置爲 http 或 https 而後執行,後面這類 scheme 就可以被 NSURLProtocol 處理了,具體實現能夠在這裏[1]看到。
Android 經過系統提供的資源攔截Api便可實現加載攔截,攔截後根據請求的url識別資源類型,命中後設置對應的mimeType、encoding、fileStream便可。
App 安裝前的下載速度也直接影響到了用戶從選擇你的 App 到使用的體驗,若是下載大小過大,用戶沒有耐心等待,可能就放棄了你的 App,4G5G 環境下超 200MB 會彈窗提示是否繼續下載,嚴重影響轉化率。
所以還對下載大小作了優化,將 __TEXT 字段遷移到自定義段,使得 iPhone X 之前機器的下載大小減小了50M,幾乎少了1/3的大小,這招之因此對 iPhone X 之前機器管用的緣由是由於先前機器是按照先加密再壓縮,壓縮率低,而以後機器改變了策略所以下載大小就會大幅減小。Michael Eisel 這篇博客《One Quick Way to Drastically Reduce your iOS App’s Download Size》[2] 提出了這套方案,此方案已經線上驗證,你能夠馬上應用到本身應用中,提升老機器下載速度。
Michael Eisel 還用 Swift 包裝了 simdjson[3] 寫了個庫 ZippyJSONDecoder[4] 比系統自帶 JSONDecoder 快三倍。人類對速度的追求是沒有止境的,最近 YY 大神 ibireme 也在寫 JSON 庫 YYJSON[5] 速度比 simdjson 還快。Michael 還寫個了提速構建的自制連接器 zld[6],項目說明還描述瞭如何開發定製本身的連接器。還有主線程阻塞(ANR)檢測的 swift 類 ANRChecker[7],還有經過 hook 方式記錄系統錯誤日誌的例子[8]展現如何經過截獲自動佈局錯誤,函數是 UIViewAlertForUnsatisfiableConstraints ,malloc 問題替換函數爲 malloc_error_break 便可。Michael 的這些性能問題處理手段很是實用,真是個寶藏男孩。
經過每個月新增激活量、瀏覽到新增激活轉換率、下載到激活轉換率、轉換率受體積因素影響佔比、每一個用戶獲取成本,使用公式計算可以獲得每個月成本收益,把大家公司對應具體參數數值套到公式中,算出來後你會發現若是下降了50多MB,每個月就會有很是大的收益。
對於 Android 來講,不少功能是能夠放在雲端按需下載使用,後面的方向是重雲輕端,雲端一體,打通雲端鏈路。
下載和安裝完成後,就要分析 App 開始啓動時如何作優化了,我接下來跟你說說 Android 啓動 so 庫加載如何作監控和優化。
1 編譯階段 - 靜態分析優化
依託自動化構建平臺,經過構建配置實現對源碼模塊的靈活配置,進行定製化編譯。
-ffunction-sections -fdata-sections // 實現按需加載 -fvisibility=hidden -fvisibility-inlines-hidden // 實現符號隱藏
這樣能夠避免無用模塊的引入,效果以下圖:
2 運行階段 - hook分析優化
Android Linker 調用流程以下:
注意,find_library 加載成功後返回 soinfo 對象指針,而後調用其 call_constructors 來調用 so 的 init_array。call_constructors 調用 call_array,其內部循環調用 call_funtion 來訪問 init_array 數組的調用。
高德 Android 小夥伴們基於 frida-gum[9] 的 hook 引擎開發了線下性能監控工具,能夠 hook c++ 庫,支持 macos、android、ios,針對 so 的全局構造時間和連接時間進行 hook,對關鍵 so 加載的關鍵節點耗時進行分析。dlopen 相關 hook 監控點以下:
static target_func_t android_funcs_22[] = { {"__dl_dlopen", 0, (void *)my_dlopen}, {"__dl_ZL12find_libraryPKciPK12android_dlextinfo", 0, (void *)my_find_library}, {"__dl_ZN6soinfo16CallConstructorsEv", 0, (void *)my_soinfo_CallConstructors}, {"__dl_ZN6soinfo9CallArrayEPKcPPFvvEjb", 0, (void *)my_soinfo_CallArray} }; static target_func_t android_funcs_28[] = { {"__dl_Z9do_dlopenPKciPK17android_dlextinfoPKv", 0, (void *)my_do_dlopen_28}, {"__dl_Z14find_librariesP19android_namespace_tP6soinfoPKPKcjPS2_PNSt3__16vectorIS2_NS8_9a"}, {"__dl_ZN6soinfo17call_constructorsEv", 0, (void *)my_soinfo_CallConstructors}, {"__dl_ZL10call_arrayIPFviPPcS1_EEvPKcPT_jbS5_", 0, (void *)my_call_array_28<constructor_func>}, {"__dl_ZN6soinfo10link_imageERK10LinkListIS_19SoinfoListAllocatorES4_PK17android_dlextin"}, {"__dl_g_argc", 0, 0}, {"__dl_g_argv", 0, 0}, {"__dl_g_envp", 0, 0} };
Android 版本不一樣對應 hook 方法有所不一樣,要注意當 so 有其餘外部連接依賴時,針對 dlopen 的監控數據,不僅包括自身部分,也包括依賴的 so 部分。在這種狀況下,so 加載順序也會產生很大的影響。
JNI_OnLoad 的 hook 監控代碼以下:
#ifdef ABTOR_ANDROID jint my_JNI_ONLoad(JavaVM* vm, void* reserved) { asl::HookEngine::HoolContext *ctx = asl::HookEngine::getHookContext(); uint64_t start = PerfUtils::getTickTime(); jint res = asl::CastFuncPtr(my_JNI_OnLoad, ctx->org_func)(vm, reserved); int duration = (int)(PerfUtils::getTickTime() - start); LibLoaderMonitorImpl *monitor = (LibLoaderMonitorImpl*)LibLoaderMonitor::getInstance(); monitor->addOnloadInfo(ctx->user_data, duration); return res; } #endif
如上代碼所示,linker 的 dlopen 完成加載,而後調用 dlsym 來調用目標 so 的 JNI_OnLoad,完成 JNI 涉及的初始化操做。
加載 so 須要注意並行出現 loadLibrary0 鎖的問題,這樣會讓多線程發生等鎖現象。能夠減小併發加載,但不能簡單把整個加載過程放到串行任務裏,這樣耗時可能會更長,而且無法充分利用資源。比較好的作法是,將耗時少的串行起來同時並行耗時長的 so 加載。
至此完成了 so 的初始化和連接的監控。
說完 Android,那麼 iOS 的加載是怎樣的,如何優化呢?我接着跟你說。
dyld_start 以前作了什麼,dyld_start 是誰調用的,經過查看 xnu 的源碼[10]能夠理出,當 App 點擊後會經過_mac_execve 函數 fork 進程,加載解析 Mach-O 文件,調用 exec_activate_image() 開始激活 image 的過程。先根據 image 類型來選擇 imgact,開始 load_machfile,這個過程會先解析 Mach-O,解析後依據其中的 LoadCommand 啓動 dyld。最後使用 activate_exec_state() 處理結構信息,thread_setentrypoint() 設置 entry_point App的入口點。
_dyld_start 以後要少些動態庫,由於連接耗時;少些 +load、C 的 constructor 函數和 C++ 靜態對象,由於這些會在啓動階段執行,多了就會影響啓動時間。所以,沒有用的代碼就須要按期清理和線上監控。經過元類中flag的方式進行監控而後按期清理。
+load 方法時間統計,使用運行時 swizzling 的方式,將統計代碼放到連接順序的最前面便可。靜態初始化函數在 DATA 的 mod_init_func 區,先把裏面原始函數地址保存,先後加上自定義函數記錄時間。
在 Linux上 有 strace 工具,還有庫跟蹤工具 ltrace,OSX 有包裝了 dtrace 的 instruments 和 dtruss 工具,不過在某些場景需求下很差用。objc_msgSend 實際上會經過在類對象中查找選擇器到函數的映射來重定向執行到實現函數。一旦它找到了目標函數,它就會簡單地跳轉到那裏,而沒必要從新調整參數寄存器。這就是爲何我把它稱爲路由機制,而不是消息傳遞。Objective-C 的一個方法被調用時,堆棧和寄存器是爲 objc_msgSend 調用配置的,objc_msgSend 路由執行。objc_msgSend 會在類對象中查找函數表對應定向到的函數,找到目標函數就跳轉,參數寄存器不會從新調整。
所以能夠在這裏 hook 住作統一處理。hook objc_msgSend 還能夠獲取啓動方法列表,用於二進制重排方案中所須要的 AppOrderFiles,不過 AppOrderFiles 還能夠經過 Clang SanitizerCoverage 得到,具體能夠看 Michael Eisel 這個寶藏男孩這篇博客《Improving App Performance with Order Files》[11] 的介紹。
objc_msgSend 能夠經過 fishhook 指定到你定義的 hook 方法中,也可使用建立跳轉 page 的方式來 hook。作法是先用 mmap 分配一個跳轉的 page,這個內存後面會用來執行原函數,使用特殊指令集將CPU重定向到內存的任意位置。建立一個內聯彙編函數用來放置跳轉的地址,利用 C 編譯器自動複製跳轉 page 的結構,指向 hook 的函數,以前把指令複製到跳轉 page 中。ARM64 是一個 RISC 架構,須要根據指令種類檢查分支指令。能夠在 _objc_msgSend[12] 裏找到 b 指令的檢查。相關代碼以下:
ENTRY _objc_msgSend MESSENGER_START cmp x0, #0 // nil check and tagged pointer check b.le LNilOrTagged // (MSB tagged pointer looks negative) ldr x13, [x0] // x13 = isa and x9, x13, #ISA_MASK // x9 = class
檢查經過就能夠用這個指針讀取偏移量,並修改指向跳轉地址,跳轉page完成,hook 函數就能夠被調用了。
接下來看下 hook _objc_msgSend 的函數,這個我在之前博客《深刻剖析 iOS 性能優化》[13] 寫過,不過多贅述,只作點補充說明。從這裏的源碼[14]能夠看實現,其中的attribute((naked)) 表示無參數準備和棧初始化, asm 表示其後面是彙編代碼,volatile 是讓後面的指令避免被編譯優化到緩存寄存器中和改變指令順序,volatile 使其修飾變量被訪問時都會在共享內存裏從新讀取,變量值變化時也能寫到共享內存中,這樣不一樣線程看到的變量都是一個值。若是你發現不加 volatile 也沒有問題,你能夠把編譯優化選項調到更優試試。stp表示操做兩個寄存器,中括號部分表示壓棧存入sp偏移地址,!符號表合併了壓棧指令。
save() 的做用是把傳遞參數寄存器入棧保存,call(b, value)用來跳到指定函數地址,call(blr, &before_objc_msgSend) 是調用原 _objc_msgSend 以前指定執行函數,call(blr, orig_objc_msgSend) 是調用 objc_msgSend 函數,call(blr, &after_objc_msgSend) 是調用原 _objc_msgSend 以後指定執行函數。before_objc_msgSend 和 after_objc_msgSend 分別記錄時間,差值就是方法調用執行的時長。
調用之間經過 save() 保存參數,經過 load() 來讀取參數。call 的第一個參數是blr,blr 是指跳轉到寄存器地址後會返回,因爲 blr 會改變 lr 寄存器X30的值,影響 ret 跳到原方法調用方地址,崩潰堆棧找方法調研棧也依賴 lr 在棧上記錄的地址,因此須要在 call() 以前對 lr 進行保存,call() 都調用完後再進行恢復。跳轉到hook函數,hook函數能夠執行咱們自定義的事情,完成後恢復CPU狀態。
進入主圖後,用戶就能夠點擊按鈕進入不一樣功能了,是否可以快速響應按鈕點擊操做也是啓動體驗感知很重要的事情。按鈕點擊的兩個事件 didTouchUp 和 didTouchDown 之間也會有延時,所以能夠在 didTouchDown 時在主線程先 async 初始化下一個 VC,把初始化提早完成,這樣作能夠提升50ms-100ms的速度,甚至更多,具體收益依賴當前主線程繁忙狀況和下一個頁面 viewDidLoad 等初始化方法裏的耗時,啓動階段主線程必定不會閒,即便點擊後主線程阻塞,使用 async 也能保證下一個頁面的初始化不會停。
1 總體思路
對於任務編排有種打法,就是先把全部任務滯後,而後再看哪一個是啓動開始必需要加載的。效果立竿見影,很快就能看到最好的結果,後面就是反覆斟酌,嚴格把關誰纔是必要的啓動任務了。
啓動階段的任務,先理出相關依賴關係,在框架中進行配置,有依賴的任務有序執行,無依賴獨立任務能夠在非密集任務執行期串行分組,組內併發執行。
這裏須要注意的是Android 的 SharedPreferences 文件加載致使的 ContextImpl 鎖競爭,一種解法是合併文件,不事後期維護成本會高,另外一種是使用串行任務加載。你可能會疑惑,我沒怎麼用鎖,那是否是就不會有鎖等待的問題了。其實否則,好比在 iOS中,dispatch_once 裏有 dispatch_atomic_barrier 方法,此方法就有鎖的做用,所以鎖其實存在各個 API 之下,如不用工具去作檢查,有時還真不容易發現這些問題。
有 IO 操做的任務除了鎖等待問題,還有效率方面也須要特別注意,好比 iOS 的 Fundation 庫使用的是 NSData writeToFile:atomically: 方法,此方法會調用系統提供的 fsync 函數將文件描述符 fd 裏修改的數據強寫到磁盤裏,fsync 相比較與 fcntl 效率高但寫入物理磁盤會有等待,可能會在系統異常時出現寫入順序錯亂的狀況。系統提供的 write() 和 mmap() 函數都會用到內核頁緩存,是否寫入磁盤不禁調用返回是否成功決定,另外 c 的標準庫的讀寫 API fread 和 fwrite 還會在系統內核頁緩存同步對應由保存了緩衝區基地址的 FILE 結構體的內部緩衝區。所以啓動階段 IO 操做方法須要綜合作效率、準確和重要性三方面因素的權衡考慮,再進行有 IO 操做的任務編排。
針對初始化耗時的庫,好比埋點庫,能夠延後初始化,先將所須要的數據存儲到內存中,待到埋點庫初始化時再進行記錄。對一些主圖上業務網絡能夠延後請求,好比閃屏、消息盒子、主圖天氣、限行控件數據請求、開放圖層數據、Wi-Fi信息上報請求等。
2 多線程共享數據的問題
併發任務編排缺乏一個統一的異步編程模型,併發通訊共享數據方式的手段,好比代理和通知會讓處理處處飛,閉包這種匿名函數排查問題不方便,並且回調中套回調前期設計後期維護和理解很困難,調試、性能測試也亂。這些經過回調來處理異步,不光復雜難控,還有靜態條件、依賴關係、執行順序這樣的額外複雜度,爲了解決這些額外複雜度,還須要使用更多的複雜機制來保證線程安全,好比使用低效的 mutex、超高複雜度的讀寫鎖、雙重檢查鎖定、底層原子操做或信號量的方式來保護數據,須要保證數據是正確鎖住的,否則會有內存問題,鎖粒度要定還要注意避免死鎖。
併發線程通訊通常都會使用 libdispatch(GCD)這樣的共享數據方式來處理,也就異步再回調的方式。libdispatch 的 async 策略是把任務的 block 放到隊列鏈表,使用時會在底層的線程池裏找可用線程,有就直接用,沒有就新建一個線程(參看 libdispatch[15] 源碼,監控線程池 workqueue.c,隊列調度 queue.c),使用這樣的策略來減小線程建立。當併發任務多時,好比啓動期間,即便線程沒爆,但 CPU 在各個線程切換處理任務時也是會有時間開銷的,每次切換線程,CPU 都須要執行調度程序增長調度成本和增長 CPU 使用率,而且還容易出現多線程競爭問題。單次線程切換看起來不長,但整個啓動,切換頻率高的話,總體時間就會增大。
多線程的問題以及處理方式,帶來了開發和排查問題的複雜性,以及出現問題機率的提升,資源和功能雲化也有相似的問題,雲化和本地的耦合依賴、雲化之間的關係處理、版本兼容問題會帶來更復雜的開發以及測試挑戰,還有問題排查的複雜度。這些都須要去作權衡,對基礎建設方案提出了更高的要求,對容錯回滾的響應速度也有更高的要求。
這裏有個 book[16] 專門來講並行編程難的,並告訴你該怎麼作。這裏有篇文章[17]列出了蘋果公司 libdispatch 的維護者 Pierre Habouzit 關於 libdispatch 的討論郵件。
說了一堆共享數據方式的問題,沒有體感,下面我說個最近碰到的多線程問題,你也看看排查有多費勁。
3 一個具體多線程問題排查思路
問題是工程引入一個系統庫,暫叫 A 庫,出現的問題現象是 CoreMotion 不回調,網絡請求沒法執行,除了全局併發隊列會 pending block 外主線程和其它隊列工做正常。
第一階段,排查思路看是否跟咱們工程相關,首先看是否是各個系統都有此問題,發現 iOS14 和 iOS13 都有問題。而後把A庫放到一個純淨 Demo 工程中,發現沒有出問題了。基於上面兩種狀況,推測只有將A庫引入咱們工程纔會出現問題。在純淨 Demo 工程中,A庫使用時 CPU 會佔用60%-80%,集成到咱們工程後漲到100%,因此下個階段排查方向就是性能。
第二階段的打法是看是不是由性能引發的問題。先在純淨工程中建立大量線程,直到線程打滿,而後進行大量浮點運算使 CPU 到100%,可是無法復現,任務經過 libdispatch 到全局併發隊列能正常工做。
怎麼在 Demo 裏看到出線程已爆滿了呢?
libdispatch 可使用線程數是有上限的,在 libdispatch 的源碼[18]裏能夠看到 libdispatch 的隊列初始化時使用 pthread 線程池相關代碼:
#if DISPATCH_USE_PTHREAD_POOL static inline void _dispatch_root_queue_init_pthread_pool(dispatch_queue_global_t dq, int pool_size, dispatch_priority_t pri) { dispatch_pthread_root_queue_context_t pqc = dq->do_ctxt; int thread_pool_size = DISPATCH_WORKQ_MAX_PTHREAD_COUNT; if (!(pri & DISPATCH_PRIORITY_FLAG_OVERCOMMIT)) { thread_pool_size = (int32_t)dispatch_hw_config(active_cpus); } if (pool_size && pool_size < thread_pool_size) thread_pool_size = pool_size; ... // 省略不相關代碼 }
如上面代碼所示,dispatch_hw_config 會用 dispatch_source 來監控邏輯 CPU、物理 CPU、激活 CPU 的狀況計算出線程池最大線程數量,若是當前狀態是 DISPATCH_PRIORITY_FLAG_OVERCOMMIT,也就是會出現 overcommit 隊列時,線程池最大線程數就按照 DISPATCH_WORKQ_MAX_PTHREAD_COUNT 這個宏定義的數量來,這個宏對應的值是255。所以經過查看是否出現 overcommit 隊列能夠看出線程池是否已滿。
何時 libdispatch 會建立一個新線程?
當 libdispatch 要執行隊列裏 block 時會去檢查是否有可用的線程,發現有可用線程時,在可用線程去執行 block,若是沒有,經過 pthread_create 新建一個線程,在上面執行,函數關鍵代碼以下:
static void _dispatch_root_queue_poke_slow(dispatch_queue_global_t dq, int n, int floor) { ... // 若是狀態是overcommit,那麼就繼續添加到pending bool overcommit = dq->dq_priority & DISPATCH_PRIORITY_FLAG_OVERCOMMIT; if (overcommit) { os_atomic_add2o(dq, dgq_pending, remaining, relaxed); } else { if (!os_atomic_cmpxchg2o(dq, dgq_pending, 0, remaining, relaxed)) { _dispatch_root_queue_debug("worker thread request still pending for " "global queue: %p", dq); return; } } ... t_count = os_atomic_load2o(dq, dgq_thread_pool_size, ordered); do { can_request = t_count < floor ? 0 : t_count - floor; // 是否有可用 if (remaining > can_request) { _dispatch_root_queue_debug("pthread pool reducing request from %d to %d", remaining, can_request); os_atomic_sub2o(dq, dgq_pending, remaining - can_request, relaxed); remaining = can_request; } // 線程滿 if (remaining == 0) { _dispatch_root_queue_debug("pthread pool is full for root queue: " "%p", dq); return; } } while (!os_atomic_cmpxchgvw2o(dq, dgq_thread_pool_size, t_count, t_count - remaining, &t_count, acquire)); ... do { _dispatch_retain(dq); // 在 _dispatch_worker_thread 裏取任務並執行 while ((r = pthread_create(pthr, attr, _dispatch_worker_thread, dq))) { if (r != EAGAIN) { (void)dispatch_assume_zero(r); } _dispatch_temporary_resource_shortage(); } } while (--remaining); ... }
如上面代碼所示,can_request 表示可用線程數,經過當前最大可用線程數減去已用線程數得到,賦給 remaining後,用來判斷線程是否滿和控制線程建立。dispatch_worker_thread 會取任務並執行。
當 libdispatch 使用的線程池中線程過多,而且有 pending 標記,當等待超時,也就是 libdispatch 裏 DISPATCH_CONTENTION_USLEEP_MAX 宏定義的時間後,也會觸發建立一個新的待處理線程。libdispatch 對應函數關鍵代碼以下:
static bool __DISPATCH_ROOT_QUEUE_CONTENDED_WAIT__(dispatch_queue_global_t dq, int (*predicate)(dispatch_queue_global_t dq)) { ... bool pending = false; do { ... if (!pending) { // 添加pending標記 (void)os_atomic_inc2o(dq, dgq_pending, relaxed); pending = true; } _dispatch_contention_usleep(sleep_time); ... sleep_time *= 2; } while (sleep_time < DISPATCH_CONTENTION_USLEEP_MAX); ... if (pending) { (void)os_atomic_dec2o(dq, dgq_pending, relaxed); } if (status == DISPATCH_ROOT_QUEUE_DRAIN_WAIT) { _dispatch_root_queue_poke(dq, 1, 0); // 建立新線程 } return status == DISPATCH_ROOT_QUEUE_DRAIN_READY; }
如上所示,在建立新的待處理線程後,會退出當前線程,負載沒了就會去用新建的線程。
接下來使用 Instruments 進行分析 Trace 文件,發現啓動階段馬上開始使用A庫的話,CPU 會忽然上升,若是使用 A 庫稍晚些,CPU 使用率就是穩定正常的。這說明在第一個階段性能相關結論只是偶現狀況纔會出現,出問題時,並無出現系統資源緊張的狀況,能夠得出並非性能問題的結論。那麼下一個階段只能從A庫的使用和排查咱們工程其它功能的問題。
第三個階段的思路是使用功能二分排查法,先排出 A 庫使用問題,作法是在使用最簡單的 A 庫初始化一個頁面在首屏也會復現問題。
咱們的功能主要分爲渲染、引擎、網絡庫、基礎功能、業務幾個部分。將渲染、引擎、網絡庫拉出來建個Demo,發現這個 Demo 不會出現問題。那麼有問題的就可能在基礎功能、業務上。
先去掉的功能模塊有 CoreMotion、網絡、日誌模塊、定時任務(埋點上傳),依然復現。接下來去掉隊列裏的 libdispatch 任務,隊列裏的任務主要是由 Operation 和 libdispatch 兩種方式放入。其中 Operation 最後是使用 libdispatch 將任務 block 放入隊列,期間會作優先級和併發數的判斷。對於 libdispatch 能夠 Hook 住能夠把任務 block 放到隊列的 libdispatch 方法,有 dispatch_async、dispatch_after、dispatch_barrier_async、dispatch_apply 這些方法。任務直接返回,仍是有問題。
推測驗證基礎能力和業務對出現問題隊列有影響,instruments 只能分析線程,沒法分析隊列,所以須要寫工具分析隊列狀況。
接下來進入第四個階段。
先 hook 時截獲任務 block 使用的 libdispatch 方法、執行隊列名、優先級、作惟一標識的入隊時間、當前隊列的任務數、還有執行堆棧的信息。經過截獲的內容按照時間線看,當出現全局併發隊列 pending block 數量堆積時,新的使用 libdispatch 加入的部分任務能夠獲得執行,也有沒執行的,都執行了也會有問題。
而後去掉 Operation 的任務:經過日誌還能發現 Operation 調用 libdispatch 的任務直接 hook libdispatch 的方法是獲取不到的,多是 Operation 調用方法有變化。另外在沒法執行任務的線程上新建的 libdispatch 任務也沒法執行,沒法執行的 Operation 任務達到所設置的 maxConcurrentOperationCount,對應的 OperationQueue 就會在 Operation 的隊列裏 pending。由此能夠推斷出,在局併發隊列 pending 的 block 包含了直接使用 libdispatch 的和 Operation 的任務,pending 的任務。所以還須要 hook 住 Operation,過濾掉全部添加到 Operation Queue 的任務,但結果仍是復現問題。
此時很崩潰,原本作好了一個一個下掉功能的準備(成本高),這時,有同窗發現前階段兩個不對的結論。
這個階段定爲第五階段。
第一個不對的結論是經 QA 同窗長時間多輪測試,只在14.2及以上系統版本有問題,因爲只有這個版本纔開始有此問題,推斷多是系統 bug;第二個不對的是隻有渲染、引擎、網絡庫的 Demo 再次檢查,可復現問題,所以能夠針對這個 Demo 進行進一步二分排查。
因而,我們針對兩個先前錯誤結論,再次出發,同步進行驗證。對 Demo 排除了網絡庫依然復現,後排除引擎仍是復現,同時使用了本身的示例工程在iOS14.2上覆現了問題,和第一階段純淨Demo的區別是往全局併發隊列裏方式,官方 Demo 是 Operation,咱們的是 libdispatch。
所以得出結論是蘋果系統升級問題,緣由可能在 OperationQueue,問題重現後,再也不運行其中的 operation。14.3beta 版尚未解決。五個階段總結以下圖所示:
那麼看下 Operation 實現,分析下系統 bug 緣由。
ApportableFoundation[19] 裏有Operation 的開源實現 NSOperation.m[20],相比較 GNUstep[21] 和 Cocotron[22] 更完善,能夠看到 Operation 如何在 _schedulerRun 函數裏經過 libdispatch 的 async 方法將 operation 的任務放到隊列執行。
Swift 源碼[23]裏的fundation也有實現 Operation[24],咱們看看 _schedule 函數的關鍵代碼:
internal func _schedule() { ... // 按優先級順序執行 for prio in Operation.QueuePriority.priorities { ... while let operation = op?.takeUnretainedValue() { ... let next = operation.__nextPriorityOperation ... if Operation.__NSOperationState.enqueued == operation._state && operation._fetchCachedIsReady(&retest) { if let previous = prev?.takeUnretainedValue() { previous.__nextPriorityOperation = next } else { _setFirstPriorityOperation(prio, next) } ... if __mainQ { queue = DispatchQueue.main } else { queue = __dispatch_queue ?? _synthesizeBackingQueue() } if let schedule = operation.__schedule { if operation is _BarrierOperation { queue.async(flags: .barrier, execute: { schedule.perform() }) } else { queue.async(execute: schedule) } } op = next } else { ... // 添加 } } } ... }
上述代碼可見,能夠看到 _schedule 函數根據 Operation.QueuePriority.priorities 優先級數組順序,從最高 barrier 開始到 veryHigh、high、normal、low 到最低的 veryLow,根據 operation 屬性設置決定 libdispatch 的 queue 是什麼類型的,最後經過 async 函數分配到對應的隊列上執行。
查看 operation 代碼更新狀況,最新 operation 提交修復了一個問題,commit 在這[25],根據修復問題的描述來看,和 A 庫引入致使隊列不可添加 OperationQueue 的狀況很是相似。修復的地方能夠看下圖:
如圖所示,在先前 _schedule 函數裏使用 nextOperation 而不用 nextPriorityOperation 會致使主操做列表裏的不一樣優先級操做列表交叉鏈接,可能會在執行後面操做時被掛起,而 A 庫裏的 OperationQueue 都是高優的,若是有其它優先級的 OperationQueue 加進來就會出現掛起的問題。
從提交記錄看,19年6月12日的那次提交變動了不少代碼邏輯,描述上看是爲了更接近 objc 的實現,changePriority 函數就是那個時候加進去的。提交的 commit 以下圖所示:
懷疑(只是懷疑,蘋果官方並無說)多是在 iOS14 引入 swift 版的 Operation,所以這個 Operation 針對 objc 調用作了適配。之因此14.2以前 Operation 重構後的 bug 沒有引發問題,多是當時 A 庫的 Queue 優先級還沒調高,14.2版本A庫的 Queue 優先級開始調高了,因此出現了優先級交叉掛起的狀況。
從此次排查能夠發現,目前對於併發的監測仍是很是複雜的。那麼併發問題在 iOS 的未來會獲得解決嗎?
4 多線程並行計算模型
既然共享數據方式問題多,那還有其它選擇嗎?
實際上在服務端大量使用着 Actor 這樣的並行計算模型,在並行世界裏,一切都是 actor,actor 就像一個容器,會有本身的狀態、行爲、串行隊列的消息郵箱。actor 之間使用消息來通訊,會把消息發到接受消息 actor 的消息郵箱裏,消息盒子可並行接受消息,消息的處理是依次進行,當前處理完才處理下一個,消息郵箱這套機制就好像 actor 們的大管家,讓 actor 之間的溝通井井有理。
有誰是在使用 actor 模型呢?
actor 歷史悠久,Erlang[26](Elang設計論文),Akka[27](Scala[28] 編寫的 Akka actor[29] 系統,Akka 使用多,相對成熟)、Go(使用的 goroutine,基於 CSP[30] 構建)都是基於 actor 模型實現數據隔離。
Swift 併發路線圖[31]也預示着 Swift 要加入 actor,Chris Lattner 也但願 Swift 可以在多核機器,還有大型服務集羣可以獲得方便的使用,分佈式硬件的發展趨勢一定是多核,去共享內存的硬件的,由於共享內存的編程不光復雜並且原子性訪問比非原子性要慢近百倍。提案中設計到 actor 的設計是把 actor 設計成一種特殊類,讓這個類有引用語義,能造成 map,能夠 weak 或 unowned 引用。actor 類中包含一些只有 actor 纔有的方法,這些方法提供 actor 編程模型所需安全性。但 actor 類不能繼承自非 actor 類,由於這樣 actor 狀態可能會有機會以不安全的方式泄露。actor 和它的函數和屬性之間是靜態關係,這樣能夠經過編譯方式避免數據競爭,對數據隔離,若是不是安全訪問 actor 屬性的上下文,編譯器能夠處理切換到那個上下文中。對於 actor 隔離會借鑑強制執行對內存的獨佔訪問[32]提案的思想,好比局部變量、inout參數、結構體屬性編譯器能夠分析變量的全部訪問,有衝突就能夠報錯,類屬性和全局變量要在運行時能夠跟蹤在進行的訪問,有衝突報錯。而全局內存仍是無法避免數據競爭,這個須要增長一個全局 actor 保護。
按 actor 模型對任務之間通信從新調整,不用回調代理等手段,將發送消息放到消息郵箱裏進行相似 RxSwift 那樣 next 的方式一個一個串行傳遞。說到 RxSwift,那 RxSwift 和 Combine 這樣的框架能替代 actor 嗎?
對這些響應式框架來講解決線程通訊只是其中很小的一部分,其仍是會面臨閉包、調試和維護複雜的問題,並且還要使用響應式編程範式,顯然仍是有些重了,除非你已經習慣了響應式編程。
任務都按 actor 模型方式來寫,還可以作到功能之間的解耦,若是是服務器應用,actor 能夠布到不一樣的進程甚至是不一樣機器上。
actor 中消息郵件在同一時間只能處理一個消息,這樣等待返回一個值的方式,須要暫停,內部有返回再繼續執行,這要怎麼實現呢?
答案是使用 Coroutine。
在 Swift 併發路線提案裏還提到了基於 coroutine 的 async/await 語法,這種語法風格已經被普遍採納,好比Python、Dart、JavaScript 都有實現,這樣可以寫出簡潔好維護的併發代碼。
上述只是提案,最快也須要兩個版本的等待,那麼語言上的支持尚未來,怎麼能提早享用 coroutine 呢?
處理暫停恢復操做,可使用 context 處理函數 setjmp 和 longjmp,但 setjmp 和 longjmp 較難實現臨時切換到不一樣的執行路徑,而後恢復到中止執行的地方,因此服務器用通常都會使用 ucontext 來實現,gnu 的舉的例子 GNU C Library: Complete Context Control[33],這個例子在於建立 context 堆棧,swapcontext 來保存 context,這樣能夠在其它地方能執行回到原來的地方。建立 context 堆棧代碼以下:
uc[1].uc_link = &uc[0]; uc[1].uc_stack.ss_sp = st1; uc[1].uc_stack.ss_size = sizeof st1; makecontext (&uc[1], (void (*) (void)) f, 1, 1);
上面代碼中 uc_link 表示的是主 context。保存 context 的代碼以下:
swapcontext (&uc[n], &uc[3 - n]);
可是在 Xcode 裏一試,出現錯誤提示以下:
implicit declaration of function 'swapcontext' is invalid in c99
原來最新的 POSXI 標準已經沒有這個函數了,IEEE Std 1003.1-2001 / Cor 2-2004,應用了項目XBD/TC2/D6/28,標註 getcontext()、makecontext()、setcontext()和swapcontext() 函數過期了。在 POSIX 2004第743頁說明了緣由,大概意思就是建議使用 pthread 這種系統編程上,後來的 Rust 和 Swift coroutine 的提案裏都是使用的系統編程來實現 coroutine,長期看系統編程實現 coroutine 確定是趨勢。那麼在 swift 升級以前還有辦法在 iOS 用 ucontext 這種輕量級的 coroutine 嗎?
其實也是有的,能夠考慮臨時過渡一下。具體能夠看看 ucontext 的彙編實現,從新在本身工程裏實現出來就能夠了。getcontext[34]、setcontext[35]、makecontext[36]、swapcontext[37] 的在 linux 系統代碼裏能看到。ucontext_t 結構體裏的 uc_stack 會記錄 context 使用的棧。getcontext() 是把各個寄存器保存到內存結構體裏,setcontext() 是把來自 makecontext() 和 getcontext() 的各寄存器恢復到當前 context 的寄存器裏。switchcontext() 合併了 getcontext() 和 setcontext()。
ucontext_t 的結構體設計以下:
如上圖所示,ucontext_t 還包含了一個更高層次的 context 封裝 uc_mcontext,uc_mcontext 會保存調用線程的寄存器。上圖中 eax 是函數入參地址,寄存器值入棧操做代碼以下:
movl $0, oEAX(%eax) movl %ecx, oECX(%eax) movl %edx, oEDX(%eax) movl %edi, oEDI(%eax) movl %esi, oESI(%eax) movl %ebp, oEBP(%eax)
以上代碼中 oECX、oEDX 等表示相應寄存器在內存結構體裏的位置。esp 指向返回地址值,由 eip 字段記錄,代碼以下:
movl (%esp), %ecx movl %ecx, oEIP(%eax)
edx 是 getcontext() 的棧寄存器會記錄 ucontext_t.uc_stack.ss_sp 棧頂的值,oSS_SIZE 是棧大小,經過指令addl 能夠找到棧底。makecontext() 會根據 ecx 裏的參數去設置棧,setcontext() 是 getcontext 的逆操做,設置當前 context,棧頂在 esp 寄存器。
輕量級的 coroutine 實現了,下面我們能夠經過 Swift async/await提案[38](已加了編號0296,表示核心團隊已經承認,上線可期)看下系統編程的 coroutine 是怎麼實現的。Swift async/await 提案中的思路是讓開發者編寫異步操做邏輯,編譯器用來轉換和生成所需的隱式操做閉包。能夠看做是個語法糖,並像其它實現那樣會改變完成處理程序被調用的隊列。工做原理相似 try,也不須要捕獲 self 的轉義閉包。掛起會中斷原子性,好比一個串行隊列中任務要掛起,讓其它任務在一個串行隊列中交錯運行,所以異步函數最好是不阻塞線程。將異步函數看成通常函數調用,這樣的調用會暫時離開線程,等待當前線程任務完成再從它離開的地方恢復執行這個函數,並保證是在先前的actor裏執行完成。
1 iOS 官方工具
Instruments 中 Time Profiles 中的 Profile 能夠方便的分析模塊中每一個方法的耗時。Time Profiles 中的 Samples 分析將更加準確的顯示出 App 啓動後每個 CPU 核心在一個時間片內所執行的代碼。若是在模塊開發中有如下的需求,能夠考慮使用 Samples 分析:
但願更精確的分析某個方法具體執行代碼的耗時。
想知道一個方法到另外一個方法的耗時狀況(跨方法耗時分析)。
MetricKit 2.0 開始增強了診斷特性,經過收集調用棧信息可以方便咱們來進行問題的診斷,經過 didReceive 回調 MXMetricPayload 性能數據,可包含 MXSignpostMetric 自定義採集數據,甚至是你捕獲不到的崩潰信號的系統強殺崩潰信息傳到本身服務器進行分析和報警。
2 如何在 iOS 真機和模擬器上實現自動化性能分析
蘋果有個 usbmux 協議會給本身 macOS 程序和設備進行通訊,場景有備份 iPhone 還有真機調試。macOS 對應的是/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/Resources/ 下的 usbmuxd 程序,usbmuxd 是 IPC socket 和 TCP socket 用來進行進程間通訊,這裏[39]有他的一個開源實現。對於在手機端是 lockdown 來起服務。所以利用 usbmuxd 的協議,就能夠自建和設備通訊的應用好比 lookin,實現方式能夠參考這個 demo[40]。使用 usbmux 協議的 libimobiledevice[41](至關於 Android 的 adb)提供了更多能力,能夠獲取設備的信息、搭載 ifuse[42] 訪問設備文件系統(沒越獄可訪問照片媒體、沙盒、日誌)、與調試服務器鏈接遠程調試。無侵入的庫還有 gamebench[43] 也用到了 libimobiledevice。
instruments 能夠導出 .trace 文件,之前只能用 instruments 打開,Xcode12 提供了 xctrace 命令行工具能夠導出可分析的數據。Xcode12 以前的時候是能使用 TraceUtility 這個庫,TraceUtility 的作法是鏈上 Xcode 裏 instruments 用的那些庫,好比 DVTFoundation 和 InstrumentsKit 等,調用對應的方法去獲取.trace文件。使用 libimobiledevice 能構造操做 instruments 的應用,將 instruments 的能力自動化。
perfdog 就是使用了libimobiledevice調用了instruments的接口(見接口研究,實現代碼)來實現instruments的一些功能,並進行了擴展定製,無侵入的構建本地性能監控並集成到自動測試中出數據,減小人工成本。無侵入的另外一個好處就是能夠方便用同一套標準看到其餘APP的表現狀況。
要到具體場景去跑 case 還須要流程自動化。Appium 使用的是 Facebook 開發的一套基於 W3C 標準交互協議 WebDriver[44] 的庫 WebDriverAgent[45],python 版能夠看這個,不事後來 Facebook 開發了新的一套命令行工具idb(iOS Development Bridge[46]),歸檔了 WebDriverAgent。idb 能夠對 iOS 模擬器和設備跑自動化測試,idb 主要有兩個基於 macOS 系統庫 CoreSimulator.framework、MobileDevice.framework,包裝的 FBSimulatorControl 和 FBDeviceControl 庫。FBSimulatorControl 包含了 iOS 模擬器的全部功能,Xcode 和 simctl 都是用的 CoreSimulator,自動化中輸入事件是逆向了 iOS 模擬器 Indigo 服務的協議,Indigo 是模擬器經過 mach IPC 通道 mach_msg_send 接受觸摸等輸入事件的協議。破解後就能夠模擬輸入事件了。MobileDevice.framework 也是 macOS 的私有庫,macOS 上的 Finder、Xcode、Photos 這些會使用 iOS 設備的應用都是用了 MobileDevice,文件讀寫用的是包裝了 AMDServiceConnection 協議的 AFC 文件操做 API,idb 的 instruments 相關功能是在這裏[47]實現了 DTXConnectionServices 服務協議。libmobiledevice 能夠看做是從新實現了 MobileDevice.framework。pymobiledevice、MobileDevice、C 編寫的 SDMMobileDevice,還有Objective-C 編寫的 MobileDeviceAccess,這些庫也是用的 MobileDevice.framework。
總結以下圖所示:
3 Android Profiler
Android Profiler 是 Android 中經常使用的耗時分析工具,以各類圖表的形式展現函數執行時間,幫助開發者分析耗時問題。
啓動優化着實是牽一髮動全身的事情,手段既瑣碎又複雜。如何可以將監控體系建設起來,並融入到整個研發到上線流程中,是個龐大的工程。下面給你介紹下咱們是如何作的吧。
APM自動化管控和流程體系保障平臺,目標是經過穩定環境更自動化的測試,採集到的性能數據可以經過分析檢測,發現問題可以更低成本定位分發告警,同時大盤可以展現趨勢和詳情。平臺設計以下圖:
如圖所示,開發過程會 daily 出迭代報告,開發完成後,會有集成卡口,提早卡住迭代性能問題。
集成後,在集成構建平臺可以構建正式包和線下性能包,進行線下測試和線上性能數據採集,線下支持錄製回放、Monkey 等自動化測試手段,測試期間會有生成版本報告,發佈上線前也會有發佈卡口,及時處理版本問題。
發佈後,經過雲控進行指標配置、閾值配置還有采集比例等。性能數據上傳服務經異常檢測發現問題會觸發報警,自動在 Bug 平臺建立工單進行跟蹤,以便及時修復問題減小用戶體驗損失。服務還會作統計、分級、基線對比、版本關聯以及過濾等數據分析操做,這些分析後的性能數據最終會經過版本、迭代趨勢等統計報表方式在大盤上展現,還能展現詳情,包括對比展現、問題詳情、場景分類、條件查詢等。