App 啟動提速實(shí)踐和一些想法

前言

啟動是門面诵棵,好的印象也助于留存率提高疯潭。蘋果也在系統(tǒng)啟動上不斷努力赊堪,提升用戶體驗,最新的 M1 宣傳中還特別強(qiáng)調(diào)了翻蓋秒開 macOS竖哩,可以看出其對極致啟動速度的追求哭廉。這篇文章提到,據(jù) Akamai 調(diào)查相叁,每多1秒等待遵绰,轉(zhuǎn)化率會下降7%,KissMetrics 的一份報告說啟動超5秒增淹,會使19%的用戶放棄等待卸載 App椿访。

高德 App 啟動優(yōu)化專項完成后已經(jīng)有一段時間了,一直保持實(shí)屬不易虑润,我一年前在這篇文章里也做了些總結(jié)成玫。我這里再補(bǔ)充些啟動優(yōu)化用到一些手段和一些想法,希望這些能夠?qū)δ阌袔椭?/p>

通過 Universal Links 和 App Links 優(yōu)化喚端啟動體驗

App 都會存在拉新和導(dǎo)流的訴求拳喻,如何提高這樣場景的用戶體驗?zāi)乜薜保窟@里會用到喚端技術(shù)。包含選擇什么樣的換端協(xié)議冗澈,我們先看看喚端路徑荣病,如下:


喚端的協(xié)議分為自定義協(xié)議和平臺標(biāo)準(zhǔn)協(xié)議,自定義協(xié)議在 iOS 端會有系統(tǒng)提示彈框渗柿,在 Android 端 chrome 25 后自定義協(xié)議失效,需用 Intent 協(xié)議包裝才能打開 App脖岛。如果希望提高體驗最好使用平臺標(biāo)準(zhǔn)協(xié)議朵栖。平臺標(biāo)準(zhǔn)協(xié)議在 iOS 平臺叫 Universal Links,在 iOS 9 開始引入的柴梆,所以 iOS 9 及以上系統(tǒng)都支持陨溅,如果用戶安裝了要跳的 App 就會直接跳到 App,不會有系統(tǒng)彈框提示绍在。相對應(yīng)的 Android 平臺標(biāo)準(zhǔn)協(xié)議叫 App Links门扇,Android 6 以上都支持。

這里需要注意的是 iOS 的 Universal Links 不支持自動喚端偿渡,也就是頁面加載后自動執(zhí)行喚端是不行的臼寄,需要用戶主動點(diǎn)擊進(jìn)行喚端。對于自定義協(xié)議和平臺標(biāo)準(zhǔn)協(xié)議在有些 App 里是遇到屏蔽或者那些 App 自定義彈窗提示溜宽,這就只能通過溝通加白來解決了吉拳。

另外對于啟動時展示 H5 啟動頁,或喚端跳轉(zhuǎn)特定功能頁适揉,可以將攔截判斷置前留攒,判斷出啟動去往功能頁煤惩,優(yōu)先加載功能頁的任務(wù),主圖相關(guān)任務(wù)項延后再加載炼邀,以提升啟動到特定頁面的速度魄揉。

H5啟動頁

現(xiàn)在 App 啟動會在有活動時先彈出活動運(yùn)營 H5 頁面提高活動曝光率。但如果 H5 加載慢勢必非常影響啟動的體驗拭宁。

iOS 的話可以使用 ODR(On-Demand Resources) 在安裝后先下載下來洛退,點(diǎn)擊啟動前實(shí)際上就可以直接加載本地的了。ODR 安裝后立刻下載的模式红淡,下載資源會被清除不狮,所以需要將下載內(nèi)容移動到自定義的地方,同時還需要做自己兜底的下載來保證在 On-Demand Resources 下載失敗時在旱,還能夠再從自己兜底服務(wù)器上拉下資源摇零。

On-Demand Resources 還能夠放很多資源,甚至包括腳本代碼的預(yù)加載桶蝎,可以減少包體積驻仅。由于使用的是蘋果服務(wù)器,還能夠減少 CDN 產(chǎn)生的峰值成本登渣。

如果不使用 On-Demand Resources 也可以對 WKWebView 進(jìn)行預(yù)加載噪服,雖然安裝后第一次還是需要從服務(wù)器上加載一次,不過后面就可以從本地快速讀取了胜茧。

iOS 有三套方案粘优,一套是通過 WKBrowsingContextController 注冊 scheme,使用 URLProtocol 進(jìn)行網(wǎng)絡(luò)攔截呻顽。第二套是基于 WKURLSchemeHandler 自定義 scheme 攔截請求雹顺。第三套是在本地搭建 local server,攔截網(wǎng)絡(luò)請求重定向到本地 server廊遍。第三套搭建本地 server 成本高嬉愧,啟動 server 比較耗時。第二套 WKURLSchemeHandler 使用自定義 scheme喉前,對于 H5 適配成本很高没酣,而且需要 iOS 11 以上系統(tǒng)支持。

第一套方案是使用了 WKBrowsingContextController 的 registerSchemeForCustomProtocol: 這個方法卵迂,這個方法的參數(shù)設(shè)置為 http 或 https 然后執(zhí)行裕便,后面這類 scheme 就能夠被 NSURLProtocol 處理了,具體實(shí)現(xiàn)可以在這里看到狭握。

Android 通過系統(tǒng)提供的資源攔截Api即可實(shí)現(xiàn)加載攔截闪金,攔截后根據(jù)請求的url識別資源類型,命中后設(shè)置對應(yīng)的mimeType、encoding哎垦、fileStream即可囱嫩。

下載速度

App 安裝前的下載速度也直接影響到了用戶從選擇你的 App 到使用的體驗,如果下載大小過大漏设,用戶沒有耐心等待墨闲,可能就放棄了你的 App,4G5G 環(huán)境下超 200MB 會彈窗提示是否繼續(xù)下載郑口,嚴(yán)重影響轉(zhuǎn)化率鸳碧。

因此還對下載大小做了優(yōu)化,將 __TEXT 字段遷移到自定義段犬性,使得 iPhone X 以前機(jī)器的下載大小減少了1/3的大小瞻离,這招之所以對 iPhone X 以前機(jī)器管用的原因是因為先前機(jī)器是按照先加密再壓縮,壓縮率低乒裆,而之后機(jī)器改變了策略因此下載大小就會大幅減少套利。Michael Eisel 這篇博客《One Quick Way to Drastically Reduce your iOS App’s Download Size》 提出了這套方案,你可以立刻應(yīng)用到自己應(yīng)用中鹤耍,提高老機(jī)器下載速度肉迫,效果立竿見影。

Michael Eisel 還用 Swift 包裝了 simdjson 寫了個庫 ZippyJSONDecoder 比系統(tǒng)自帶 JSONDecoder 快三倍稿黄,很符合本篇“要更快”的主題對吧喊衫,人類對速度的追求是沒有止境的,最近 YY 大神 ibireme 也在寫 JSON 庫 YYJSON 速度比 simdjson 還快杆怕。Michael 還寫個了提速構(gòu)建的自制鏈接器 zld族购,項目說明還描述了如何開發(fā)定制自己的鏈接器。還有主線程阻塞(ANR)檢測的 swift 類 ANRChecker陵珍,還有通過hook 方式記錄系統(tǒng)錯誤日志的例子展示如何通過截獲自動布局錯誤联四,函數(shù)是 UIViewAlertForUnsatisfiableConstraints ,malloc 問題替換函數(shù)為 malloc_error_break 即可撑教。Michael 的這些性能問題處理手段非常實(shí)用,真是個寶藏男孩醉拓。

通過每月新增激活量伟姐、瀏覽到新增激活轉(zhuǎn)換率、下載到激活轉(zhuǎn)換率亿卤、轉(zhuǎn)換率受體積因素影響占比愤兵、每個用戶獲取成本,使用公式計算能夠得到每月成本收益排吴,把你們公司對應(yīng)具體參數(shù)數(shù)值套到公式中秆乳,算出來后你會發(fā)現(xiàn)如果降低了50多MB,每月就會有非常大的收益。

對于 Android 來說屹堰,很多功能是可以放在云端按需下載使用肛冶,后面的方向是重云輕端,云端一體扯键,打通云端鏈路睦袖。

下載和安裝完成后,就要分析 App 開始啟動時如何做優(yōu)化了荣刑,我接下來跟你說說 Android 啟動 so 庫加載如何做監(jiān)控和優(yōu)化馅笙。

Android so 庫加載優(yōu)化

編譯階段-靜態(tài)分析優(yōu)化

依托自動化構(gòu)建平臺,通過構(gòu)建配置實(shí)現(xiàn)對源碼模塊的靈活配置厉亏,進(jìn)行定制化編譯董习。

-ffunction-sections -fdata-sections // 實(shí)現(xiàn)按需加載
-fvisibility=hidden -fvisibility-inlines-hidden // 實(shí)現(xiàn)符號隱藏

這樣可以避免無用模塊的引入,效果如下圖:


運(yùn)行階段-hook分析優(yōu)化

Android Linker 調(diào)用流程如下:


注意爱只,find_library 加載成功后返回 soinfo 對象指針皿淋,然后調(diào)用其 call_constructors 來調(diào)用 so 的 init_array。call_constructors 調(diào)用 call_array虱颗,其內(nèi)部循環(huán)調(diào)用 call_funtion 來訪問 init_array 數(shù)組的調(diào)用沥匈。

高德Android小伙伴們基于 frida-gum 的 hook 引擎開發(fā)了線下性能監(jiān)控工具,可以 hook c++ 庫忘渔,支持 macos淫僻、android、ios乳丰,針對 so 的全局構(gòu)造時間和鏈接時間進(jìn)行 hook喉镰,對關(guān)鍵 so 加載的關(guān)鍵節(jié)點(diǎn)耗時進(jìn)行分析。dlopen 相關(guān) hook 監(jiān)控點(diǎn)如下:

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"},
    {"__dl_ZN6soinfo17call_constructorsEv", 0, (void *)my_soinfo_CallConstructors},
    {"__dl_ZL10call_arrayIPFviPPcS1_EEvPKcPT_jbS5_", 0, (void *)my_call_array_28<constructor_func>},
    {"__dl_ZN6soinfo10link_imageERK10LinkListIS"},
    {"__dl_g_argc", 0, 0},
    {"__dl_g_argv", 0, 0},
    {"__dl_g_envp", 0, 0}
};

android 版本不同對應(yīng) hook 方法有所不同宣赔,要注意當(dāng) so 有其他外部鏈接依賴時预麸,針對 dlopen 的監(jiān)控數(shù)據(jù),不只包括自身部分儒将,也包括依賴的 so 部分吏祸。在這種情況下,so 加載順序也會產(chǎn)生很大的影響钩蚊。

JNI_OnLoad 的 hook 監(jiān)控代碼如下:

#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 完成加載,然后調(diào)用 dlsym 來調(diào)用目標(biāo) so 的 JNI_OnLoad砰逻,完成 JNI 涉及的初始化操作鸣驱。

加載 so 需要注意并行出現(xiàn) loadLibrary0 鎖的問題,這樣會讓多線程發(fā)生等鎖現(xiàn)象蝠咆∮欢可以減少并發(fā)加載北滥,但不能簡單把整個加載過程放到串行任務(wù)里,這樣耗時可能會更長闸翅,并且沒法充分利用資源再芋。比較好的做法是,將耗時少的串行起來同時并行耗時長的 so 加載缎脾。

至此完成了 so 的初始化和鏈接的監(jiān)控祝闻。

說完 Android,那么 iOS 的加載是怎樣的遗菠,如何優(yōu)化呢联喘?我接著跟你說。

App 加載

_dyld_start 之前做了什么辙纬,dyld_start 是誰調(diào)用的豁遭,通過查看xnu的源碼可以理出,當(dāng) App 點(diǎn)擊后會通過__mac_execve 函數(shù) fork 進(jìn)程贺拣,加載解析 Mach-O 文件蓖谢,調(diào)用 exec_activate_image() 開始激活 image 的過程。先根據(jù) image 類型來選擇 imgact譬涡,開始 load_machfile闪幽,這個過程會先解析 Mach-O,解析后依據(jù)其中的 LoadCommand 啟動 dyld涡匀。最后使用 activate_exec_state() 處理結(jié)構(gòu)信息盯腌,thread_setentrypoint() 設(shè)置 entry_point App的入口點(diǎn)。

_dyld_start 之后要少些動態(tài)庫陨瘩,因為鏈接耗時腕够;少些 +load、C 的 constructor 函數(shù)和 C++ 靜態(tài)對象舌劳,因為這些會在啟動階段執(zhí)行帚湘,多了就會影響啟動時間。因此甚淡,沒有用的代碼就需要定期清理和線上監(jiān)控大诸。通過元類中flag的方式進(jìn)行監(jiān)控然后定期清理。

iOS 主線程方法調(diào)用時長檢測

+load 方法時間統(tǒng)計贯卦,使用運(yùn)行時 swizzling 的方式底挫,將統(tǒng)計代碼放到鏈接順序的最前面即可。靜態(tài)初始化函數(shù)在 DATA 的 mod_init_func 區(qū)脸侥,先把里面原始函數(shù)地址保存,前后加上自定義函數(shù)記錄時間盈厘。

在 Linux上 有 strace 工具睁枕,還有庫跟蹤工具 ltrace,OSX 有包裝了 dtrace 的 instruments 和 dtruss 工具,不過在某些場景需求下不好用外遇。objc_msgSend 實(shí)際上會通過在類對象中查找選擇器到函數(shù)的映射來重定向執(zhí)行到實(shí)現(xiàn)函數(shù)注簿。一旦它找到了目標(biāo)函數(shù),它就會簡單地跳轉(zhuǎn)到那里跳仿,而不必重新調(diào)整參數(shù)寄存器诡渴。這就是為什么我把它稱為路由機(jī)制,而不是消息傳遞菲语。Objective-C 的一個方法被調(diào)用時妄辩,堆棧和寄存器是為 objc_msgSend 調(diào)用配置的,objc_msgSend 路由執(zhí)行山上。objc_msgSend 會在類對象中查找函數(shù)表對應(yīng)定向到的函數(shù)眼耀,找到目標(biāo)函數(shù)就跳轉(zhuǎn),參數(shù)寄存器不會重新調(diào)整佩憾。

因此可以在這里 hook 住做統(tǒng)一處理哮伟。hook objc_msgSend 還可以獲取啟動方法列表,用于二進(jìn)制重排方案中所需要的 AppOrderFiles妄帘,不過 AppOrderFiles 還可以通過 Clang SanitizerCoverage 獲得楞黄,具體可以看寶藏男孩 Michael Eisel 這個這篇博客《Improving App Performance with Order Files》 的介紹。

objc_msgSend 可以通過 fishhook 指定到你定義的 hook 方法中抡驼,也可以使用創(chuàng)建跳轉(zhuǎn) page 的方式來 hook鬼廓。做法是先用 mmap 分配一個跳轉(zhuǎn)的 page,這個內(nèi)存后面會用來執(zhí)行原函數(shù)婶恼,使用特殊指令集將CPU重定向到內(nèi)存的任意位置桑阶。創(chuàng)建一個內(nèi)聯(lián)匯編函數(shù)用來放置跳轉(zhuǎn)的地址,利用 C 編譯器自動復(fù)制跳轉(zhuǎn) page 的結(jié)構(gòu)勾邦,指向 hook 的函數(shù)蚣录,之前把指令復(fù)制到跳轉(zhuǎn) page 中。ARM64 是一個 RISC 架構(gòu)眷篇,需要根據(jù)指令種類檢查分支指令萎河。可以在 _objc_msgSend 里找到 b 指令的檢查蕉饼。相關(guān)代碼如下:

    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

檢查通過就可以用這個指針讀取偏移量虐杯,并修改指向跳轉(zhuǎn)地址,跳轉(zhuǎn)page完成昧港,hook 函數(shù)就可以被調(diào)用了擎椰。

接下來看下 hook _objc_msgSend 的函數(shù),這個我在以前博客《深入剖析 iOS 性能優(yōu)化》寫過创肥,不過多贅述达舒,只做點(diǎn)補(bǔ)充說明值朋。從這里的源碼可以看實(shí)現(xiàn),其中的attribute((naked)) 表示無參數(shù)準(zhǔn)備和棧初始化巩搏, asm 表示其后面是匯編代碼昨登,volatile 是讓后面的指令避免被編譯優(yōu)化到緩存寄存器中和改變指令順序,volatile 使其修飾變量被訪問時都會在共享內(nèi)存里重新讀取贯底,變量值變化時也能寫到共享內(nèi)存中丰辣,這樣不同線程看到的變量都是一個值。如果你發(fā)現(xiàn)不加 volatile 也沒有問題禽捆,你可以把編譯優(yōu)化選項調(diào)到更優(yōu)試試笙什。stp表示操作兩個寄存器,中括號部分表示壓棧存入sp偏移地址睦擂,!符號表合并了壓棧指令得湘。

save() 的作用是把傳遞參數(shù)寄存器入棧保存,call(b, value)用來跳到指定函數(shù)地址顿仇,call(blr, &before_objc_msgSend) 是調(diào)用原 _objc_msgSend 之前指定執(zhí)行函數(shù)淘正,call(blr, orig_objc_msgSend) 是調(diào)用 objc_msgSend 函數(shù),call(blr, &after_objc_msgSend) 是調(diào)用原 _objc_msgSend 之后指定執(zhí)行函數(shù)臼闻。before_objc_msgSend 和 after_objc_msgSend 分別記錄時間鸿吆,差值就是方法調(diào)用執(zhí)行的時長。

調(diào)用之間通過 save() 保存參數(shù)述呐,通過 load() 來讀取參數(shù)惩淳。call 的第一個參數(shù)是blr,blr 是指跳轉(zhuǎn)到寄存器地址后會返回乓搬,由于 blr 會改變 lr 寄存器X30的值思犁,影響 ret 跳到原方法調(diào)用方地址,崩潰堆棧找方法調(diào)研棧也依賴 lr 在棧上記錄的地址进肯,所以需要在 call() 之前對 lr 進(jìn)行保存激蹲,call() 都調(diào)用完后再進(jìn)行恢復(fù)。跳轉(zhuǎn)到hook函數(shù)江掩,hook函數(shù)可以執(zhí)行我們自定義的事情学辱,完成后恢復(fù)CPU狀態(tài)。

進(jìn)入主圖后的優(yōu)化

進(jìn)入主圖后环形,用戶就可以點(diǎn)擊按鈕進(jìn)入不同功能了策泣,是否能夠快速響應(yīng)按鈕點(diǎn)擊操作也是啟動體驗感知很重要的事情。按鈕點(diǎn)擊的兩個事件 didTouchUp 和 didTouchDown 之間也會有延時抬吟,因此可以在 didTouchDown 時在主線程先 async 初始化下一個 VC萨咕,把初始化提前完成,這樣做可以提高50ms-100ms的速度火本,甚至更多危队,具體收益依賴當(dāng)前主線程繁忙情況和下一個頁面 viewDidLoad 等初始化方法里的耗時蓄喇,啟動階段主線程一定不會閑,即使點(diǎn)擊后主線程阻塞交掏,使用 async 也能保證下一個頁面的初始化不會停。

線程調(diào)度和任務(wù)編排

整體思路

對于任務(wù)編排有種打法刃鳄,就是先把所有任務(wù)滯后盅弛,然后再看哪個是啟動開始必須要加載的。效果立竿見影叔锐,很快就能看到最好的結(jié)果挪鹏,后面就是反復(fù)斟酌,嚴(yán)格把關(guān)誰才是必要的啟動任務(wù)了愉烙。

啟動階段的任務(wù)讨盒,先理出相關(guān)依賴關(guān)系,在框架中進(jìn)行配置步责,有依賴的任務(wù)有序執(zhí)行返顺,無依賴獨(dú)立任務(wù)可以在非密集任務(wù)執(zhí)行期串行分組,組內(nèi)并發(fā)執(zhí)行蔓肯。

這里需要注意的是Android 的 SharedPreferences 文件加載導(dǎo)致的 ContextImpl 鎖競爭遂鹊,一種解法是合并文件,不過后期維護(hù)成本會高蔗包,另一種是使用串行任務(wù)加載秉扑。你可能會疑惑,我沒怎么用鎖调限,那是不是就不會有鎖等待的問題了舟陆。其實(shí)不然,比如在 iOS中耻矮,dispatch_once 里有 dispatch_atomic_barrier 方法秦躯,此方法就有鎖的作用,因此鎖其實(shí)存在各個 API 之下淘钟,如不用工具去做檢查宦赠,有時還真不容易發(fā)現(xiàn)這些問題。

有 IO 操作的任務(wù)除了鎖等待問題米母,還有效率方面也需要特別注意勾扭,比如 iOS 的 Fundation 庫使用的是 NSData writeToFile:atomically: 方法,此方法會調(diào)用系統(tǒng)提供的 fsync 函數(shù)將文件描述符 fd 里修改的數(shù)據(jù)強(qiáng)寫到磁盤里铁瞒,fsync 相比較與 fcntl 效率高但寫入物理磁盤會有等待妙色,可能會在系統(tǒng)異常時出現(xiàn)寫入順序錯亂的情況。系統(tǒng)提供的 write() 和 mmap() 函數(shù)都會用到內(nèi)核頁緩存慧耍,是否寫入磁盤不由調(diào)用返回是否成功決定身辨,另外 c 的標(biāo)準(zhǔn)庫的讀寫 API fread 和 fwrite 還會在系統(tǒng)內(nèi)核頁緩存同步對應(yīng)由保存了緩沖區(qū)基地址的 FILE 結(jié)構(gòu)體的內(nèi)部緩沖區(qū)丐谋。因此啟動階段 IO 操作方法需要綜合做效率、準(zhǔn)確和重要性三方面因素的權(quán)衡考慮煌珊,再進(jìn)行有 IO 操作的任務(wù)編排号俐。

針對初始化耗時的庫,比如埋點(diǎn)庫定庵,可以延后初始化吏饿,先將所需要的數(shù)據(jù)存儲到內(nèi)存中,待到埋點(diǎn)庫初始化時再進(jìn)行記錄蔬浙。對一些主圖上業(yè)務(wù)網(wǎng)絡(luò)可以延后請求猪落,比如閃屏、消息盒子畴博、主圖天氣笨忌、限行控件數(shù)據(jù)請求、開放圖層數(shù)據(jù)俱病、Wi-Fi信息上報請求等官疲。

多線程共享數(shù)據(jù)的問題

并發(fā)任務(wù)編排缺少一個統(tǒng)一的異步編程模型,并發(fā)通信共享數(shù)據(jù)方式的手段庶艾,比如代理和通知會讓處理到處飛袁余,閉包這種匿名函數(shù)排查問題不方便,而且回調(diào)中套回調(diào)前期設(shè)計后期維護(hù)和理解很困難咱揍,調(diào)試颖榜、性能測試也亂。這些通過回調(diào)來處理異步煤裙,不光復(fù)雜難控掩完,還有靜態(tài)條件、依賴關(guān)系硼砰、執(zhí)行順序這樣的額外復(fù)雜度且蓬,為了解決這些額外復(fù)雜度,還需要使用更多的復(fù)雜機(jī)制來保證線程安全题翰,比如使用低效的 mutex恶阴、超高復(fù)雜度的讀寫鎖、雙重檢查鎖定豹障、底層原子操作或信號量的方式來保護(hù)數(shù)據(jù)冯事,需要保證數(shù)據(jù)是正確鎖住的,不然會有內(nèi)存問題血公,鎖粒度要定還要注意避免死鎖昵仅。

并發(fā)線程通信一般都會使用 libdispatch(GCD)這樣的共享數(shù)據(jù)方式來處理,也就異步再回調(diào)的方式。libdispatch 的 async 策略是把任務(wù)的 block 放到隊列鏈表摔笤,使用時會在底層的線程池里找可用線程够滑,有就直接用,沒有就新建一個線程(參看 libdispatch 源碼吕世,監(jiān)控線程池 workqueue.c彰触,隊列調(diào)度 queue.c),使用這樣的策略來減少線程創(chuàng)建命辖。當(dāng)并發(fā)任務(wù)多時渴析,比如啟動期間,即使線程沒爆吮龄,但 CPU 在各個線程切換處理任務(wù)時也是會有時間開銷的,每次切換線程咆疗,CPU 都需要執(zhí)行調(diào)度程序增加調(diào)度成本和增加 CPU 使用率漓帚,并且還容易出現(xiàn)多線程競爭問題。單次線程切換看起來不長午磁,但整個啟動尝抖,切換頻率高的話,整體時間就會增大迅皇。

多線程的問題以及處理方式昧辽,帶來了開發(fā)和排查問題的復(fù)雜性,以及出現(xiàn)問題機(jī)率的提高登颓,資源和功能云化也有類似的問題搅荞,云化和本地的耦合依賴、云化之間的關(guān)系處理框咙、版本兼容問題會帶來更復(fù)雜的開發(fā)以及測試挑戰(zhàn)咕痛,還有問題排查的復(fù)雜度。這些都需要去做權(quán)衡喇嘱,對基礎(chǔ)建設(shè)方案提出了更高的要求茉贡,對容錯回滾的響應(yīng)速度也有更高的要求。

這里有個 book 專門來說并行編程難的者铜,并告訴你該怎么做腔丧。這里有篇文章 列出了蘋果公司 libdispatch 的維護(hù)者 Pierre Habouzit 關(guān)于 libdispatch 的討論郵件。

說了一堆共享數(shù)據(jù)方式的問題作烟,沒有體感愉粤,下面我說個最近碰到的多線程問題,你也看看排查有多費(fèi)勁俗壹。

一個具體多線程問題排查思路

問題是工程引入一個系統(tǒng)庫科汗,暫叫 A 庫,出現(xiàn)的問題現(xiàn)象是 CoreMotion 不回調(diào)绷雏,網(wǎng)絡(luò)請求無法執(zhí)行头滔,除了全局并發(fā)隊列會 pending block 外主線程和其它隊列工作正常怖亭。

第一階段,排查思路看是否跟我們工程相關(guān)坤检,首先看是不是各個系統(tǒng)都有此問題兴猩,發(fā)現(xiàn) iOS14 和 iOS13 都有問題。然后把A庫放到一個純凈 Demo 工程中早歇,發(fā)現(xiàn)沒有出問題了倾芝。基于上面兩種情況箭跳,推測只有將A庫引入我們工程才會出現(xiàn)問題晨另。在純凈 Demo 工程中,A庫使用時 CPU 會占用60%-80%谱姓,集成到我們工程后漲到100%借尿,所以下個階段排查方向就是性能。

第二階段的打法是看是否是由性能引起的問題屉来。先在純凈工程中創(chuàng)建大量線程路翻,直到線程打滿,然后進(jìn)行大量浮點(diǎn)運(yùn)算使 CPU 到100%茄靠,但是沒法復(fù)現(xiàn)茂契,任務(wù)通過 libdispatch 到全局并發(fā)隊列能正常工作。

怎么在 Demo 里看到出線程已爆滿了呢慨绳?

libdispatch 可以使用線程數(shù)是有上限的掉冶,在 libdispatch 的源碼里可以看到 libdispatch 的隊列初始化時使用 pthread 線程池相關(guān)代碼:

#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;
    ... // 省略不相關(guān)代碼
}

如上面代碼所示,dispatch_hw_config 會用 dispatch_source 來監(jiān)控邏輯 CPU脐雪、物理 CPU郭蕉、激活 CPU 的情況計算出線程池最大線程數(shù)量,如果當(dāng)前狀態(tài)是 DISPATCH_PRIORITY_FLAG_OVERCOMMIT喂江,也就是會出現(xiàn) overcommit 隊列時召锈,線程池最大線程數(shù)就按照 DISPATCH_WORKQ_MAX_PTHREAD_COUNT 這個宏定義的數(shù)量來,這個宏對應(yīng)的值是255获询。因此通過查看是否出現(xiàn) overcommit 隊列可以看出線程池是否已滿涨岁。

什么時候 libdispatch 會創(chuàng)建一個新線程?

當(dāng) libdispatch 要執(zhí)行隊列里 block 時會去檢查是否有可用的線程吉嚣,發(fā)現(xiàn)有可用線程時梢薪,在可用線程去執(zhí)行 block,如果沒有尝哆,通過 pthread_create 新建一個線程秉撇,在上面執(zhí)行,函數(shù)關(guān)鍵代碼如下:

static void
_dispatch_root_queue_poke_slow(dispatch_queue_global_t dq, int n, int floor)
{
    ...
    // 如果狀態(tài)是overcommit,那么就繼續(xù)添加到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 里取任務(wù)并執(zhí)行
        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 表示可用線程數(shù)规阀,通過當(dāng)前最大可用線程數(shù)減去已用線程數(shù)獲得,賦給 remaining后瘦麸,用來判斷線程是否滿和控制線程創(chuàng)建谁撼。dispatch_worker_thread 會取任務(wù)并執(zhí)行。

當(dāng) libdispatch 使用的線程池中線程過多滋饲,并且有 pending 標(biāo)記厉碟,當(dāng)?shù)却瑫r,也就是 libdispatch 里 DISPATCH_CONTENTION_USLEEP_MAX 宏定義的時間后屠缭,也會觸發(fā)創(chuàng)建一個新的待處理線程箍鼓。libdispatch 對應(yīng)函數(shù)關(guān)鍵代碼如下:

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標(biāo)記
            (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); // 創(chuàng)建新線程
    }
    return status == DISPATCH_ROOT_QUEUE_DRAIN_READY;
}

如上所示,在創(chuàng)建新的待處理線程后呵曹,會退出當(dāng)前線程袄秩,負(fù)載沒了就會去用新建的線程。

接下來使用 Instruments 進(jìn)行分析 Trace 文件逢并,發(fā)現(xiàn)啟動階段立刻開始使用A庫的話,CPU 會突然上升郭卫,如果使用 A 庫稍晚些砍聊,CPU 使用率就是穩(wěn)定正常的。這說明在第一個階段性能相關(guān)結(jié)論只是偶現(xiàn)情況才會出現(xiàn)贰军,出問題時玻蝌,并沒有出現(xiàn)系統(tǒng)資源緊張的情況,可以得出并不是性能問題的結(jié)論词疼。那么下一個階段只能從A庫的使用和排查我們工程其它功能的問題俯树。

第三個階段的思路是使用功能二分排查法,先排出 A 庫使用問題贰盗,做法是在使用最簡單的 A 庫初始化一個頁面在首屏也會復(fù)現(xiàn)問題许饿。

我們的功能主要分為渲染、引擎舵盈、網(wǎng)絡(luò)庫陋率、基礎(chǔ)功能、業(yè)務(wù)幾個部分秽晚。將渲染瓦糟、引擎、網(wǎng)絡(luò)庫拉出來建個Demo赴蝇,發(fā)現(xiàn)這個 Demo 不會出現(xiàn)問題菩浙。那么有問題的就可能在基礎(chǔ)功能、業(yè)務(wù)上。

先去掉的功能模塊有 CoreMotion劲蜻、網(wǎng)絡(luò)陆淀、日志模塊、定時任務(wù)(埋點(diǎn)上傳)斋竞,依然復(fù)現(xiàn)倔约。接下來去掉隊列里的 libdispatch 任務(wù),隊列里的任務(wù)主要是由 Operation 和 libdispatch 兩種方式放入坝初。其中 Operation 最后是使用 libdispatch 將任務(wù) block 放入隊列浸剩,期間會做優(yōu)先級和并發(fā)數(shù)的判斷。對于 libdispatch 可以 Hook 住可以把任務(wù) block 放到隊列的 libdispatch 方法鳄袍,有 dispatch_async绢要、dispatch_after、dispatch_barrier_async拗小、dispatch_apply 這些方法重罪。任務(wù)直接返回,還是有問題哀九。

推測驗證基礎(chǔ)能力和業(yè)務(wù)對出現(xiàn)問題隊列有影響剿配,instruments 只能分析線程,無法分析隊列阅束,因此需要寫工具分析隊列情況呼胚。

接下來進(jìn)入第四個階段。

先 hook 時截獲任務(wù) block 使用的 libdispatch 方法息裸、執(zhí)行隊列名蝇更、優(yōu)先級、做唯一標(biāo)識的入隊時間呼盆、當(dāng)前隊列的任務(wù)數(shù)年扩、還有執(zhí)行堆棧的信息。通過截獲的內(nèi)容按照時間線看访圃,當(dāng)出現(xiàn)全局并發(fā)隊列 pending block 數(shù)量堆積時厨幻,新的使用 libdispatch 加入的部分任務(wù)可以得到執(zhí)行,也有沒執(zhí)行的腿时,都執(zhí)行了也會有問題克胳。

然后去掉 Operation 的任務(wù):通過日志還能發(fā)現(xiàn) Operation 調(diào)用 libdispatch 的任務(wù)直接 hook libdispatch 的方法是獲取不到的,可能是 Operation 調(diào)用方法有變化圈匆。另外在無法執(zhí)行任務(wù)的線程上新建的 libdispatch 任務(wù)也無法執(zhí)行烦绳,無法執(zhí)行的 Operation 任務(wù)達(dá)到所設(shè)置的 maxConcurrentOperationCount菱属,對應(yīng)的 OperationQueue 就會在 Operation 的隊列里 pending。由此可以推斷出,在局并發(fā)隊列 pending 的 block 包含了直接使用 libdispatch 的和 Operation 的任務(wù),pending 的任務(wù)。因此還需要 hook 住 Operation,過濾掉所有添加到 Operation Queue 的任務(wù),但結(jié)果還是復(fù)現(xiàn)問題肤频。

此時很崩潰,本來做好了一個一個下掉功能的準(zhǔn)備(成本高)算墨,這時宵荒,有同學(xué)發(fā)現(xiàn)前階段兩個不對的結(jié)論。

這個階段定為第五階段净嘀。

第一個不對的結(jié)論是經(jīng) QA 同學(xué)長時間多輪測試报咳,只在14.2及以上系統(tǒng)版本有問題,由于只有這個版本才開始有此問題挖藏,推斷可能是系統(tǒng) bug暑刃;第二個不對的是只有渲染、引擎膜眠、網(wǎng)絡(luò)庫的 Demo 再次檢查岩臣,可復(fù)現(xiàn)問題,因此可以針對這個 Demo 進(jìn)行進(jìn)一步二分排查宵膨。

于是架谎,咱們針對兩個先前錯誤結(jié)論,再次出發(fā)辟躏,同步進(jìn)行驗證谷扣。對 Demo 排除了網(wǎng)絡(luò)庫依然復(fù)現(xiàn),后排除引擎還是復(fù)現(xiàn)鸿脓,同時使用了自己的示例工程在iOS14.2上復(fù)現(xiàn)了問題,和第一階段純凈Demo的區(qū)別是往全局并發(fā)隊列里方式涯曲,官方 Demo 是 Operation野哭,我們的是 libdispatch。

因此得出結(jié)論是蘋果系統(tǒng)升級問題幻件,原因可能在 OperationQueue拨黔,問題重現(xiàn)后,不再運(yùn)行其中的 operation绰沥。14.3beta 版還沒有解決篱蝇。五個階段總結(jié)如下圖所示:


那么看下 Operation 實(shí)現(xiàn),分析下系統(tǒng) bug 原因徽曲。

ApportableFoundation 里有Operation的開源實(shí)現(xiàn) NSOperation.m零截,相比較 GNUstepCocotron 更完善,可以看到 Operation 如何在 _schedulerRun 函數(shù)里通過 libdispatch 的 async 方法將 operation 的任務(wù)放到隊列執(zhí)行秃臣。

swift源碼里的fundation也有實(shí)現(xiàn) Operation涧衙,我們看看 _schedule 函數(shù)的關(guān)鍵代碼:

internal func _schedule() {
    ...
    // 按優(yōu)先級順序執(zhí)行
    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 函數(shù)根據(jù) Operation.QueuePriority.priorities 優(yōu)先級數(shù)組順序,從最高 barrier 開始到 veryHigh弧哎、high雁比、normal、low 到最低的 veryLow撤嫩,根據(jù) operation 屬性設(shè)置決定 libdispatch 的 queue 是什么類型的偎捎,最后通過 async 函數(shù)分配到對應(yīng)的隊列上執(zhí)行。

查看 operation 代碼更新情況序攘,最新 operation 提交修復(fù)了一個問題茴她,commit 在這,根據(jù)修復(fù)問題的描述來看两踏,和 A 庫引入導(dǎo)致隊列不可添加 OperationQueue 的情況非常類似败京。修復(fù)的地方可以看下圖:

如圖所示,在先前 _schedule 函數(shù)里使用 nextOperation 而不用 nextPriorityOperation 會導(dǎo)致主操作列表里的不同優(yōu)先級操作列表交叉連接梦染,可能會在執(zhí)行后面操作時被掛起赡麦,而 A 庫里的 OperationQueue 都是高優(yōu)的,如果有其它優(yōu)先級的 OperationQueue 加進(jìn)來就會出現(xiàn)掛起的問題帕识。

從提交記錄看泛粹,19年6月12日的那次提交變更了很多代碼邏輯,描述上看是為了更接近 objc 的實(shí)現(xiàn)肮疗,changePriority 函數(shù)就是那個時候加進(jìn)去的晶姊。提交的 commit 如下圖所示:


懷疑(只是懷疑,蘋果官方并沒有說)可能是在 iOS14 引入 swift 版的 Operation伪货,因此這個 Operation 針對 objc 調(diào)用做了適配们衙。之所以14.2之前 Operation 重構(gòu)后的 bug 沒有引起問題,可能是當(dāng)時 A 庫的 Queue 優(yōu)先級還沒調(diào)高碱呼,14.2版本A庫的 Queue 優(yōu)先級開始調(diào)高了蒙挑,所以出現(xiàn)了優(yōu)先級交叉掛起的情況。

從這次排查可以發(fā)現(xiàn)愚臀,目前對于并發(fā)的監(jiān)測還是非常復(fù)雜的忆蚀。那么并發(fā)問題在 iOS 的將來會得到解決嗎?

多線程并行計算模型

既然共享數(shù)據(jù)方式問題多姑裂,那還有其它選擇嗎馋袜?

實(shí)際上在服務(wù)端大量使用著 Actor 這樣的并行計算模型,在并行世界里舶斧,一切都是 actor欣鳖,actor 就像一個容器,會有自己的狀態(tài)茴厉、行為观堂、串行隊列的消息郵箱让网。actor 之間使用消息來通信,會把消息發(fā)到接受消息 actor 的消息郵箱里师痕,消息盒子可并行接受消息溃睹,消息的處理是依次進(jìn)行,當(dāng)前處理完才處理下一個胰坟,消息郵箱這套機(jī)制就好像 actor 們的大管家因篇,讓 actor 之間的溝通井然有序。

有誰是在使用 actor 模型呢笔横?

actor 歷史悠久竞滓,ErlangElang設(shè)計論文),AkkaScala 編寫的 Akka actor 系統(tǒng)吹缔,Akka 使用多商佑,相對成熟)、Go(使用的 goroutine厢塘,基于 CSP 構(gòu)建)都是基于 actor 模型實(shí)現(xiàn)數(shù)據(jù)隔離茶没。

Swift并發(fā)路線圖也預(yù)示著 Swift 要加入 actor,Chris Lattner 也希望 Swift 能夠在多核機(jī)器晚碾,還有大型服務(wù)集群能夠得到方便的使用抓半,分布式硬件的發(fā)展趨勢必定是多核,去共享內(nèi)存的硬件的格嘁,因為共享內(nèi)存的編程不光復(fù)雜而且原子性訪問比非原子性要慢近百倍笛求。提案中設(shè)計到 actor 的設(shè)計是把 actor 設(shè)計成一種特殊類,讓這個類有引用語義糕簿,能形成 map探入,可以 weak 或 unowned 引用。actor 類中包含一些只有 actor 才有的方法懂诗,這些方法提供 actor 編程模型所需安全性蜂嗽。但 actor 類不能繼承自非 actor 類,因為這樣 actor 狀態(tài)可能會有機(jī)會以不安全的方式泄露响禽。actor 和它的函數(shù)和屬性之間是靜態(tài)關(guān)系徒爹,這樣可以通過編譯方式避免數(shù)據(jù)競爭荚醒,對數(shù)據(jù)隔離芋类,如果不是安全訪問 actor 屬性的上下文,編譯器可以處理切換到那個上下文中界阁。對于 actor 隔離會借鑒強(qiáng)制執(zhí)行對內(nèi)存的獨(dú)占訪問提案的思想侯繁,比如局部變量、inout參數(shù)泡躯、結(jié)構(gòu)體屬性編譯器可以分析變量的所有訪問贮竟,有沖突就可以報錯丽焊,類屬性和全局變量要在運(yùn)行時可以跟蹤在進(jìn)行的訪問,有沖突報錯咕别。而全局內(nèi)存還是沒法避免數(shù)據(jù)競爭技健,這個需要增加一個全局 actor 保護(hù)。

按 actor 模型對任務(wù)之間通訊重新調(diào)整惰拱,不用回調(diào)代理等手段雌贱,將發(fā)送消息放到消息郵箱里進(jìn)行類似 RxSwift 那樣 next 的方式一個一個串行傳遞。說到 RxSwift偿短,那 RxSwift 和 Combine 這樣的框架能替代 actor 嗎欣孤?

對這些響應(yīng)式框架來說解決線程通信只是其中很小的一部分,其還是會面臨閉包昔逗、調(diào)試和維護(hù)復(fù)雜的問題降传,而且還要使用響應(yīng)式編程范式,顯然還是有些重了勾怒,除非你已經(jīng)習(xí)慣了響應(yīng)式編程婆排。

任務(wù)都按 actor 模型方式來寫,還能夠做到功能之間的解耦控硼,如果是服務(wù)器應(yīng)用泽论,actor 可以布到不同的進(jìn)程甚至是不同機(jī)器上。

actor 中消息郵件在同一時間只能處理一個消息卡乾,這樣等待返回一個值的方式翼悴,需要暫停,內(nèi)部有返回再繼續(xù)執(zhí)行幔妨,這要怎么實(shí)現(xiàn)呢鹦赎?

答案是使用 Coroutine

在 Swift 并發(fā)路線提案里還提到了基于 coroutine 的 async/await 語法,這種語法風(fēng)格已經(jīng)被廣泛采納误堡,比如Python古话、Dart、JavaScript 都有實(shí)現(xiàn)锁施,這樣能夠?qū)懗龊啙嵑镁S護(hù)的并發(fā)代碼陪踩。

上述只是提案,最快也需要兩個版本的等待悉抵,那么語言上的支持還沒有來肩狂,怎么能提前享用 coroutine 呢?


處理暫屠咽危恢復(fù)操作傻谁,可以使用 context 處理函數(shù) setjmp 和 longjmp,但 setjmp 和 longjmp 較難實(shí)現(xiàn)臨時切換到不同的執(zhí)行路徑列粪,然后恢復(fù)到停止執(zhí)行的地方审磁,所以服務(wù)器用一般都會使用 ucontext 來實(shí)現(xiàn)谈飒,gnu 的舉的例子 GNU C Library: Complete Context Control,這個例子在于創(chuàng)建 context 堆棧态蒂,swapcontext 來保存 context杭措,這樣可以在其它地方能執(zhí)行回到原來的地方。創(chuàng)建 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 里一試瓤介,出現(xiàn)錯誤提示如下:

implicit declaration of function 'swapcontext' is invalid in c99

原來最新的 POSXI 標(biāo)準(zhǔn)已經(jīng)沒有這個函數(shù)了,IEEE Std 1003.1-2001 / Cor 2-2004赘那,應(yīng)用了項目XBD/TC2/D6/28刑桑,標(biāo)注 getcontext()、makecontext()募舟、setcontext()和swapcontext() 函數(shù)過時了祠斧。在 POSIX 2004第743頁說明了原因,大概意思就是建議使用 pthread 這種系統(tǒng)編程上拱礁,后來的 Rust 和 Swift coroutine 的提案里都是使用的系統(tǒng)編程來實(shí)現(xiàn) coroutine琢锋,長期看系統(tǒng)編程實(shí)現(xiàn) coroutine 肯定是趨勢。那么在 swift 升級之前還有辦法在 iOS 用 ucontext 這種輕量級的 coroutine 嗎呢灶?

其實(shí)也是有的吴超,可以考慮臨時過渡一下。具體可以看看 ucontext 的匯編實(shí)現(xiàn)鸯乃,重新在自己工程里實(shí)現(xiàn)出來就可以了鲸阻。getcontextsetcontext缨睡、makecontext鸟悴、swapcontext 的在 linux 系統(tǒng)代碼里能看到。ucontext_t 結(jié)構(gòu)體里的 uc_stack 會記錄 context 使用的棧奖年。getcontext() 是把各個寄存器保存到內(nèi)存結(jié)構(gòu)體里细诸,setcontext() 是把來自 makecontext() 和 getcontext() 的各寄存器恢復(fù)到當(dāng)前 context 的寄存器里。switchcontext() 合并了 getcontext() 和 setcontext()陋守。

ucontext_t 的結(jié)構(gòu)體設(shè)計如下:


如上圖所示震贵,ucontext_t 還包含了一個更高層次的 context 封裝 uc_mcontext,uc_mcontext 會保存調(diào)用線程的寄存器水评。上圖中 eax 是函數(shù)入?yún)⒌刂沸上担拇嫫髦等霔2僮鞔a如下:

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 等表示相應(yīng)寄存器在內(nèi)存結(jié)構(gòu)體里的位置之碗。esp 指向返回地址值蝙眶,由 eip 字段記錄季希,代碼如下:

movl    (%esp), %ecx
movl    %ecx, oEIP(%eax)

edx 是 getcontext() 的棧寄存器會記錄 ucontext_t.uc_stack.ss_sp 棧頂?shù)闹低誓牵琽SS_SIZE 是棧大小幽纷,通過指令addl 可以找到棧底。makecontext() 會根據(jù) ecx 里的參數(shù)去設(shè)置棧博敬,setcontext() 是 getcontext 的逆操作友浸,設(shè)置當(dāng)前 context,棧頂在 esp 寄存器偏窝。

輕量級的 coroutine 實(shí)現(xiàn)了收恢,下面咱們可以通過 Swift async/await提案(已加了編號0296,表示核心團(tuán)隊已經(jīng)認(rèn)可祭往,上線可期)看下系統(tǒng)編程的 coroutine 是怎么實(shí)現(xiàn)的伦意。Swift async/await 提案中的思路是讓開發(fā)者編寫異步操作邏輯,編譯器用來轉(zhuǎn)換和生成所需的隱式操作閉包硼补⊥匀猓可以看作是個語法糖,并像其它實(shí)現(xiàn)那樣會改變完成處理程序被調(diào)用的隊列已骇。工作原理類似 try离钝,也不需要捕獲 self 的轉(zhuǎn)義閉包。掛起會中斷原子性褪储,比如一個串行隊列中任務(wù)要掛起卵渴,讓其它任務(wù)在一個串行隊列中交錯運(yùn)行,因此異步函數(shù)最好是不阻塞線程鲤竹。將異步函數(shù)當(dāng)作一般函數(shù)調(diào)用浪读,這樣的調(diào)用會暫時離開線程,等待當(dāng)前線程任務(wù)完成再從它離開的地方恢復(fù)執(zhí)行這個函數(shù)辛藻,并保證是在先前的actor里執(zhí)行完成瑟啃。

啟動性能分析工具

iOS 官方工具

Instruments 中 Time Profiles 中的 Profile 可以方便的分析模塊中每個方法的耗時。Time Profiles 中的 Samples 分析將更加準(zhǔn)確的顯示出 App 啟動后每一個 CPU 核心在一個時間片內(nèi)所執(zhí)行的代碼揩尸。如果在模塊開發(fā)中有以下的需求蛹屿,可以考慮使用 Samples 分析:

  1. 希望更精確的分析某個方法具體執(zhí)行代碼的耗時
  2. 想知道一個方法到另一個方法的耗時情況(跨方法耗時分析)

MetricKit 2.0 開始加強(qiáng)了診斷特性,通過收集調(diào)用棧信息能夠方便我們來進(jìn)行問題的診斷岩榆,通過 didReceive 回調(diào) MXMetricPayload 性能數(shù)據(jù)错负,可包含 MXSignpostMetric 自定義采集數(shù)據(jù),甚至是你捕獲不到的崩潰信號的系統(tǒng)強(qiáng)殺崩潰信息傳到自己服務(wù)器進(jìn)行分析和報警勇边。

如何在 iOS 真機(jī)和模擬器上實(shí)現(xiàn)自動化性能分析

蘋果有個 usbmux 協(xié)議會給自己 macOS 程序和設(shè)備進(jìn)行通信犹撒,場景有備份 iPhone 還有真機(jī)調(diào)試。macOS 對應(yīng)的是/System/Library/PrivateFrameworks/MobileDevice.framework/Versions/A/Resources/ 下的 usbmuxd 程序粒褒,usbmuxd 是 IPC socket 和 TCP socket 用來進(jìn)行進(jìn)程間通信识颊,這里有他的一個開源實(shí)現(xiàn)。對于在手機(jī)端是 lockdown 來起服務(wù)。因此利用 usbmuxd 的協(xié)議祥款,就可以自建和設(shè)備通信的應(yīng)用比如 lookin清笨,實(shí)現(xiàn)方式可以參考這個 demo。使用 usbmux 協(xié)議的 libimobiledevice(相當(dāng)于 Android 的 adb)提供了更多能力刃跛,可以獲取設(shè)備的信息抠艾、搭載 ifuse 訪問設(shè)備文件系統(tǒng)(沒越獄可訪問照片媒體、沙盒桨昙、日志)检号、與調(diào)試服務(wù)器連接遠(yuǎn)程調(diào)試。無侵入的庫還有 gamebench 也用到了 libimobiledevice蛙酪。

instruments 可以導(dǎo)出 .trace 文件齐苛,以前只能用 instruments 打開,Xcode12 提供了 xctrace 命令行工具可以導(dǎo)出可分析的數(shù)據(jù)桂塞。Xcode12 之前的時候是能使用 TraceUtility 這個庫脸狸,TraceUtility 的做法是鏈上 Xcode 里 instruments 用的那些庫,比如 DVTFoundation 和 InstrumentsKit 等藐俺,調(diào)用對應(yīng)的方法去獲取.trace文件炊甲。使用 libimobiledevice 能構(gòu)造操作 instruments 的應(yīng)用,將 instruments 的能力自動化欲芹。

perfdog就是使用了libimobiledevice調(diào)用了instruments的接口(見接口研究卿啡,實(shí)現(xiàn)代碼)來實(shí)現(xiàn)instruments的一些功能,并進(jìn)行了擴(kuò)展定制菱父,無侵入的構(gòu)建本地性能監(jiān)控并集成到自動測試中出數(shù)據(jù)颈娜,減少人工成本。無侵入的另一個好處就是可以方便用同一套標(biāo)準(zhǔn)看到其他APP的表現(xiàn)情況浙宜。

要到具體場景去跑 case 還需要流程自動化官辽。Appium 使用的是 Facebook 開發(fā)的一套基于 W3C 標(biāo)準(zhǔn)交互協(xié)議 WebDriver 的庫 WebDriverAgent,python 版可以看這個粟瞬,不過后來 Facebook 開發(fā)了新的一套命令行工具idb(iOS Development Bridge)同仆,歸檔了 WebDriverAgent。idb 可以對 iOS 模擬器和設(shè)備跑自動化測試裙品,idb 主要有兩個基于 macOS 系統(tǒng)庫 CoreSimulator.framework俗批、MobileDevice.framework,包裝的 FBSimulatorControl 和 FBDeviceControl 庫市怎。FBSimulatorControl 包含了 iOS 模擬器的所有功能岁忘,Xcode 和 simctl 都是用的 CoreSimulator,自動化中輸入事件是逆向了 iOS 模擬器 Indigo 服務(wù)的協(xié)議区匠,Indigo 是模擬器通過 mach IPC 通道 mach_msg_send 接受觸摸等輸入事件的協(xié)議干像。破解后就可以模擬輸入事件了。MobileDevice.framework 也是 macOS 的私有庫,macOS 上的 Finder麻汰、Xcode速客、Photos 這些會使用 iOS 設(shè)備的應(yīng)用都是用了 MobileDevice,文件讀寫用的是包裝了 AMDServiceConnection 協(xié)議的 AFC 文件操作 API什乙,idb 的 instruments 相關(guān)功能是在這里實(shí)現(xiàn)了 DTXConnectionServices 服務(wù)協(xié)議。libmobiledevice 可以看作是重新實(shí)現(xiàn)了 MobileDevice.framework已球。pymobiledevice臣镣、MobileDevice、C 編寫的 SDMMobileDevice智亮,還有Objective-C 編寫的 MobileDeviceAccess忆某,這些庫也是用的 MobileDevice.framework。

總結(jié)如下圖所示:


Android Profiler

Android Profiler 是 Android 中常用的耗時分析工具阔蛉,以各種圖表的形式展示函數(shù)執(zhí)行時間弃舒,幫助開發(fā)者分析耗時問題。

啟動優(yōu)化著實(shí)是牽一發(fā)動全身的事情状原,手段既瑣碎又復(fù)雜聋呢。如何能夠?qū)⒈O(jiān)控體系建設(shè)起來,并融入到整個研發(fā)到上線流程中颠区,是個龐大的工程削锰。下面給你介紹下我們是如何做的吧。

管控流程體系保障平臺建設(shè)

APM自動化管控和流程體系保障平臺毕莱,目標(biāo)是通過穩(wěn)定環(huán)境更自動化的測試器贩,采集到的性能數(shù)據(jù)能夠通過分析檢測,發(fā)現(xiàn)問題能夠更低成本定位分發(fā)告警朋截,同時大盤能夠展示趨勢和詳情蛹稍。平臺設(shè)計如下圖:


開發(fā)過程會 daily 出迭代報告,開發(fā)完成后部服,會有集成卡口唆姐,提前卡住迭代性能問題。

集成后廓八,在集成構(gòu)建平臺能夠構(gòu)建正式包和線下性能包厦酬,進(jìn)行線下測試和線上性能數(shù)據(jù)采集,線下支持錄制回放瘫想、Monkey 等自動化測試手段仗阅,測試期間會有生成版本報告,發(fā)布上線前也會有發(fā)布卡口国夜,及時處理版本問題减噪。

發(fā)布后,通過云控進(jìn)行指標(biāo)配置、閾值配置還有采集比例等筹裕。性能數(shù)據(jù)上傳服務(wù)經(jīng)異常檢測發(fā)現(xiàn)問題會觸發(fā)報警醋闭,自動在 Bug 平臺創(chuàng)建工單進(jìn)行跟蹤,以便及時修復(fù)問題減少用戶體驗損失朝卒。服務(wù)還會做統(tǒng)計证逻、分級、基線對比抗斤、版本關(guān)聯(lián)以及過濾等數(shù)據(jù)分析操作囚企,這些分析后的性能數(shù)據(jù)最終會通過版本、迭代趨勢等統(tǒng)計報表方式在大盤上展示瑞眼,還能展示詳情龙宏,包括對比展示、問題詳情伤疙、場景分類银酗、條件查詢等。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末徒像,一起剝皮案震驚了整個濱河市黍特,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌锯蛀,老刑警劉巖衅澈,帶你破解...
    沈念sama閱讀 206,968評論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異谬墙,居然都是意外死亡今布,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評論 2 382
  • 文/潘曉璐 我一進(jìn)店門拭抬,熙熙樓的掌柜王于貴愁眉苦臉地迎上來部默,“玉大人,你說我怎么就攤上這事造虎「吊澹” “怎么了?”我有些...
    開封第一講書人閱讀 153,220評論 0 344
  • 文/不壞的土叔 我叫張陵算凿,是天一觀的道長份蝴。 經(jīng)常有香客問我,道長氓轰,這世上最難降的妖魔是什么婚夫? 我笑而不...
    開封第一講書人閱讀 55,416評論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮署鸡,結(jié)果婚禮上案糙,老公的妹妹穿的比我還像新娘限嫌。我一直安慰自己,他們只是感情好时捌,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評論 5 374
  • 文/花漫 我一把揭開白布怒医。 她就那樣靜靜地躺著,像睡著了一般奢讨。 火紅的嫁衣襯著肌膚如雪稚叹。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,144評論 1 285
  • 那天拿诸,我揣著相機(jī)與錄音扒袖,去河邊找鬼。 笑死佳镜,一個胖子當(dāng)著我的面吹牛僚稿,可吹牛的內(nèi)容都是我干的凡桥。 我是一名探鬼主播蟀伸,決...
    沈念sama閱讀 38,432評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼缅刽!你這毒婦竟也來了啊掏?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,088評論 0 261
  • 序言:老撾萬榮一對情侶失蹤衰猛,失蹤者是張志新(化名)和其女友劉穎迟蜜,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體啡省,經(jīng)...
    沈念sama閱讀 43,586評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡娜睛,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了卦睹。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片畦戒。...
    茶點(diǎn)故事閱讀 38,137評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖结序,靈堂內(nèi)的尸體忽然破棺而出障斋,到底是詐尸還是另有隱情,我是刑警寧澤徐鹤,帶...
    沈念sama閱讀 33,783評論 4 324
  • 正文 年R本政府宣布垃环,位于F島的核電站,受9級特大地震影響返敬,放射性物質(zhì)發(fā)生泄漏遂庄。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評論 3 307
  • 文/蒙蒙 一劲赠、第九天 我趴在偏房一處隱蔽的房頂上張望涧团。 院中可真熱鬧只磷,春花似錦、人聲如沸泌绣。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,333評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽阿迈。三九已至元媚,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間苗沧,已是汗流浹背刊棕。 一陣腳步聲響...
    開封第一講書人閱讀 31,559評論 1 262
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留待逞,地道東北人甥角。 一個月前我還...
    沈念sama閱讀 45,595評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像识樱,于是被迫代替她去往敵國和親嗤无。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評論 2 345

推薦閱讀更多精彩內(nèi)容