一 透過 Universal Links 和 App Links 最佳化喚端啟動體驗
App 都會存在拉新和導流的訴求,如何提高這些場景下的使用者體驗呢?這裡會用到喚端技術。包含選擇什麼樣的換端協議,我們先看看喚端路徑,如下:
喚端的協議分為自定義協議和平臺標準協議,自定義協議在 iOS 端會有系統提示彈框,在 Android 端 chrome 25 後自定義協議失效,需用 Intent 協議包裝才能開啟 App。如果希望提高體驗最好使用平臺標準協議。平臺標準協議在 iOS 平臺叫 Universal Links,在 iOS 9 開始引入的,所以 iOS 9 及以上系統都支援,如果使用者安裝了要跳的 App 就會直接跳到 App,不會有系統彈框提示。相對應的 Android 平臺標準協議叫 App Links,Android 6 以上都支援。
另外對於啟動時展示 H5 啟動頁,或喚端跳轉特定功能頁,可以將攔截判斷置前,判斷出啟動去往功能頁,優先載入功能頁的任務,主圖相關任務項延後再載入,以提升啟動到特定頁面的速度。
二 H5啟動頁現在 App 啟動會在有活動時先彈出活動運營 H5 頁面提高活動曝光率。但如果 H5 載入慢勢必非常影響啟動的體驗。
iOS 的話可以使用 ODR(On-Demand Resources) 在安裝後先下載下來,點選啟動前實際上就可以直接載入本地的了。ODR 安裝後立刻下載的模式,下載資源會被清除,所以需要將下載內容移動到自定義的地方,同時還需要做自己兜底的下載來保證在 On-Demand Resources 下載失敗時,還能夠再從自己兜底伺服器上拉下資源。
On-Demand Resources 還能夠放很多資源,甚至包括指令碼程式碼的預載入,可以減少包體積。由於使用的是蘋果伺服器,還能夠減少 CDN 產生的峰值成本。
如果不使用 On-Demand Resources 也可以對 WKWebView 進行預載入,雖然安裝後第一次還是需要從伺服器上載入一次,不過後面就可以從本地快速讀取了。
iOS 有三套方案,一套是透過 WKBrowsingContextController 註冊 scheme,使用 URLProtocol 進行網路攔截。第二套是基於 WKURLSchemeHandler 自定義 scheme 攔截請求。第三套是在本地搭建 local server,攔截網路請求重定向到本地 server。第三套搭建本地 server 成本高,啟動 server 比較耗時。第二套 WKURLSchemeHandler 使用自定義 scheme,對於 H5 適配成本很高,而且需要 iOS 11 以上系統支援。
第一套方案是使用了 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 庫載入如何做監控和最佳化。
四 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_ANDROIDjint 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 的載入是怎樣的,如何最佳化呢?我接著跟你說。
五 App 載入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的方式進行監控然後定期清理。
六 iOS 主執行緒方法呼叫時長檢測+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 使用率,並且還容易出現多執行緒競爭問題。單次執行緒切換看起來不長,但整個啟動,切換頻率高的話,整體時間就會增大。
多執行緒的問題以及處理方式,帶來了開發和排查問題的複雜性,以及出現問題機率的提高,資源和功能雲化也有類似的問題,雲化和本地的耦合依賴、雲化之間的關係處理、版本相容問題會帶來更復雜的開發以及測試挑戰,還有問題排查的複雜度。這些都需要去做權衡,對基礎建設方案提出了更高的要求,對容錯回滾的響應速度也有更高的要求。
說了一堆共享資料方式的問題,沒有體感,下面我說個最近碰到的多執行緒問題,你也看看排查有多費勁。
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_POOLstatic 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), %ecxmovl %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 平臺建立工單進行跟蹤,以便及時修復問題減少使用者體驗損失。服務還會做統計、分級、基線對比、版本關聯以及過濾等資料分析操作,這些分析後的效能資料最終會透過版本、迭代趨勢等統計報表方式在大盤上展示,還能展示詳情,包括對比展示、問題詳情、場景分類、條件查詢等。