優化總結:有哪些APP啓動提速方法?

一 通過 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 以上都支持。

這裏需要注意的是 iOS 的 Universal Links 不支持自動喚端,也就是頁面加載後自動執行喚端是不行的,需要用戶主動點擊進行喚端。對於自定義協議和平臺標準協議在有些 App 裏是遇到屏蔽或者那些 App 自定義彈窗提示,這就只能通過溝通加白來解決了。

另外對於啓動時展示 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_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 的加載是怎樣的,如何優化呢?我接着跟你說。

五 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 使用率,並且還容易出現多線程競爭問題。單次線程切換看起來不長,但整個啓動,切換頻率高的話,整體時間就會增大。

多線程的問題以及處理方式,帶來了開發和排查問題的複雜性,以及出現問題機率的提高,資源和功能雲化也有類似的問題,雲化和本地的耦合依賴、雲化之間的關係處理、版本兼容問題會帶來更復雜的開發以及測試挑戰,還有問題排查的複雜度。這些都需要去做權衡,對基礎建設方案提出了更高的要求,對容錯回滾的響應速度也有更高的要求。

這裏有個 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 平臺創建工單進行跟蹤,以便及時修復問題減少用戶體驗損失。服務還會做統計、分級、基線對比、版本關聯以及過濾等數據分析操作,這些分析後的性能數據最終會通過版本、迭代趨勢等統計報表方式在大盤上展示,還能展示詳情,包括對比展示、問題詳情、場景分類、條件查詢等。

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章