不改一行業務程式碼,飛書 iOS 低端機啟動優化實踐
引言
在啟動優化時,我們常常通過增加併發的方式來減輕主執行緒的耗時。而在 iOS 中,GCD 是併發程式設計最常用的框架。增加併發是否是啟動優化的良策?開發者適合選用哪個優先順序的 GCD 佇列?本文將結合飛書啟動優化,給出選取 GCD 佇列的最佳實踐,也提供針對低端機的啟動優化思路。
應用此思路,我們在未修改飛書業務邏輯的情況下,在飛書低端機上,取得了不錯的使用者體驗收益:首屏展示時間優化 100ms,訊息列表首刷時間優化 1500ms。
低端機的特性
通過 Instruments 的 App Launch 功能,我們能看到 App 啟動時的執行緒狀態、Time Profiler 等資訊。其中,我們發現不同裝置在啟動時的表現有很大差異。
以 iPhone 7p(低端)和 iPhone 12(高階)舉例,它們的裝置引數分別為:
| 裝置 | CPU 引數 | 實際核數ProcessInfo.processInfo.activeProcessorCount | 跑滿的 CPU 佔比(Xcode 測試) | | --------- | -------------------------------------- | ------------------------------------------------ | -------------------- | | iPhone 7p | A10 晶片[1],2 高效能 + 2 低功耗,但是隻有 2 核能同時工作 | 2 | 200% | | iPhone 12 | A14 晶片[2],2 高效能 + 4 低功耗 | 6 | 600% |
啟動飛書時,我們通過 Instruments 觀察兩個裝置的執行緒狀態,經過統計發現,iPhone 7p 上,主執行緒 Preempted 和 Runnable 狀態的佔比高達 21%。Instruments 的圖中能看到主執行緒大片被搶佔。
一個典型的區域性,能看到主執行緒是 preempted 狀態,CPU0 在執行其他程序,CPU1 在執行 GCD 執行緒。
而 iPhone 12,主執行緒 Preempted 和 Runnable 狀態佔比則只佔 1%從這裡我們能發現:對低端機來說,CPU 已經成為了啟動的瓶頸,“增大併發”已不是一個萬能的啟動優化措施,而想辦法減少其他執行緒對主執行緒的搶佔,可能會是優化思路。
GCD queue 對主執行緒的搶佔評測
為了評估“減少其他執行緒對主執行緒的搶佔”是否是一個可行的優化思路,我們首先需要弄明白,主執行緒被搶佔的程度會有多大?
我們可以使用 Demo 製造一些極端場景,瞭解極端場景下,主執行緒有多少比例會被其他執行緒搶佔,因此有了如下 Demo 實驗:
實驗組1:
- 非同步執行緒 QoS:DispatchQoS.userInteractive
- 程式碼:
js
for _ in 1...100 {
let queue = DispatchQueue.init(label: "serialQueue", qos: .userInteractive)
queue.async {
while true {
}
}
}
while true {
}
- qos_class_self 數值:33
- 主執行緒 Preempted + Runnable 佔比:74%
實驗組2:
- 非同步執行緒 QoS:不指定 QoS 或 DispatchQoS.userInitiated
- 程式碼:
js
for _ in 1...100 {
let queue = DispatchQueue.init(label: "serialQueue")
queue.async {
while true {
}
}
}
while true {
}
- qos_class_self 數值:25
- 主執行緒 Preempted + Runnable 佔比:73%
實驗組3:
- 非同步執行緒 QoS:DispatchQoS.utility
- 程式碼:
js
for _ in 1...100 {
let queue = DispatchQueue.init(label: "serialQueue", qos: .utility)
queue.async {
while true {
}
}
}
while true {
}
- qos_class_self 數值:17
- 主執行緒 Preempted + Runnable 佔比:1.3%
實驗組4:
- 非同步執行緒 QoS:DispatchQoS.background
- 程式碼:
js
for _ in 1...100 {
let queue = DispatchQueue.init(label: "serialQueue", qos: .background)
queue.async {
while true {
}
}
}
while true {
}
- qos_class_self 數值:9
- 主執行緒 Preempted + Runnable 佔比:1.3%
⬇️ 不指定 QoS 下,一個極端 Demo,啟動期間主執行緒長時間處於 preempted 狀態,一直無法得到 running 的機會
從中我們能看到幾個結論:
- 不指定 QoS 時,自行建立的 GCD queue 的 QoS 是 User-Initiated
- User-Initiated 及以上優先順序,對主執行緒會有嚴重搶佔現象;而 Utility 和 Background 則幾乎不會搶佔主執行緒。
另外,我們也做測試驗證了,pthread_create 建立的執行緒,也有類似的搶佔現象。
QoS 和 Priority
看到 iPhone 7p 上主執行緒被其他執行緒搶佔,我們可能會有疑問:主執行緒不應該是優先順序最高的麼?怎麼還會被其他執行緒搶佔?
這裡,我們需要理解一下 QoS 和執行緒 priority 兩個概念。
QoS(quality of service)意指服務質量,它影響執行緒優先順序(priority),也影響 I/O 吞吐、 CPU 吞吐等指標[3]。開發者可以用 qos_class_self() 介面獲得當前執行緒 / 佇列的 QoS。
蘋果對於每個任務應該選用哪個 QoS,也有一些指導意見[4]:
QoS 和 priority 確實有對應關係,參考 xnu 原始碼和實驗結果,對應關係為:
| QoS | Priority | | ---------------- | --------------- | | User-Interactive | 46,對於 UI 執行緒是 47 | | User-Initiated | 37 | | Utility | 20 | | Background | 4 |
同時,執行緒的 priority 會隨著執行動態調整。測試中我們會發現,主執行緒的 priority 在執行開始時是 QoS User-Interactive 對應的 47,但隨著執行會出現下降的情況。
官方文件[5]中解釋了執行緒 priority 變化的原因,priority 由 Mach scheduler 控制,為了防止計算密集的執行緒壟斷資源,各個執行緒的 priority 會實時調整。
All of these mechanisms are operating continually in the Mach scheduler. This means that threads are frequently moving up or down in priority based upon their behavior and the behavior of other threads in the system.
進一步閱讀 xnu 核心的原始碼[6],我們發現,執行緒 priority 的變化,是由各個 Mach scheduler 實現的 compute_timeshare_priority 介面控制的。在 iOS 使用的 Mach scheduler 中,compute_timeshare_priority 為同一個實現 sched_compute_timeshare_priority。執行緒排程時的 priority,會線上程固有 priority 的基礎上,結合當前執行緒的 CPU 佔用情況和當前裝置的整體負載進行調整。
在這個實現中,我們能看到 Mach scheduler 對 priority 的調整會有一個極限:對於原先 priority = 47 的執行緒來說,向下調整的極限是 47 - ((BASEPRI_FOREGROUND - BASEPRI_DEFAULT) + 2) = 29。這和我們用多個裝置測試到的結果吻合:主執行緒執行時,priority 的最低值是 29,依然高於 Utility 對應的 priority 20。
這也解釋了,為什麼 Demo 中當非同步執行緒的 QoS 是 Utility 時,就幾乎無法對主執行緒造成搶佔。
優化落地
通過 Demo 實驗,一個啟動優化思路產生了:在飛書中,大量非同步佇列的 QoS 是 User-Initiated,儘管這一 QoS 低於主執行緒的 User-Interactive,但依然可能對主執行緒造成搶佔;那麼,如果將非同步佇列的 QoS 調低到 Utility,是不是就可以優先保障主執行緒執行,讓首屏更早展現出來?
經過一些粗暴的實驗,我們證實了飛書在這個思路上存在優化空間。但另一個問題隨之而來:如何兼顧首屏、訊息列表首刷等多個指標?
考慮訊息列表首刷的場景:獲取到最新的訊息,不僅僅需要主執行緒構建 UI,還需要依賴資料庫讀取、網路請求等非同步操作。如果我們粗暴地將所有非同步佇列的 QoS 調低,首屏確實能更快展現,但訊息列表的首刷則隨著非同步操作的變慢更劣化了。這對使用者體驗反而帶來了負向影響。
梳理出哪些非同步操作是首刷依賴的,確保這些佇列的 QoS ,是優化中非常重要的一環。我們首先通過不斷用 Instruments 測試、閱讀程式碼梳理出了首版白名單佇列,並在線下和線上驗證了首屏、首刷等關鍵指標的優化收益。在後來的迭代中,我們又開發了線下工具,通過線上下 hook dispatch_async 等函式,記錄下首刷等時機依賴的 GCD 佇列,達成了白名單佇列自動生成的能力。
效果分析
這一優化在線上產生了不錯的體驗優化效果:
-
啟動首屏展現時間優化 100ms
通過調整非同步執行緒的 QoS,啟動期間主執行緒 CPU 搶佔現象有明顯降低。更多計算資源集中到主執行緒,使得首屏展示速度明顯加快。
-
訊息列表首刷時間優化 1500ms
通過對訊息列表首刷依賴的任務的分析,我們調低了無關執行緒的 QoS,這也讓首刷依賴的資料庫讀取、網路請求等任務得到了更多資源,加速了它們的執行。
總結
“增加併發”在一定範圍內可以作為啟動優化的方案,但在低端機上,CPU 已經成為瓶頸,併發時非同步執行緒對主執行緒的搶佔也需要引起重視。
GCD 提供了四種 QoS 給開發者使用,官方也為這四種 QoS 提供了最佳實踐建議。
經過評測和原始碼推理,User-Interactive 和 User-Initiated 對主執行緒有明顯搶佔,Utility 和 Background 對主執行緒的搶佔極少。開發者建立的 GCD 佇列,預設的 QoS 實際為 User-Initiated。因此在啟動期間(或者任何耗時敏感期間),與啟動無直接關係的 queue,應該主動設定為 Utility 或 Background,減少對主執行緒的搶佔。
通過飛書上落地優化,我們能得出結論:對執行緒或 GCD queue 調整 QoS,能在不改變啟動業務邏輯的情況下取得顯著收益。
當然,比事後優化更好的操作,是在編碼時就充分了解不同 QoS 的行為特性,選用最適合的 QoS。
參考文獻
[1] Apple A10
https://en.wikipedia.org/wiki/Apple_A10
[2] Apple A14
https://en.wikipedia.org/wiki/Apple_A14
[3] 《*OS Internals》Chapter 6
[4] Prioritize Work with Quality of Service https://developer.apple.com/library/archive/documentation/Performance/Conceptual/EnergyGuide-iOS/PrioritizeWorkWithQoS.html
[5]Why Did My Thread Priority Change?
https://developer.apple.com/library/archive/documentation/Darwin/Conceptual/KernelProgramming/scheduler/scheduler.html
[6] xnu 原始碼 sched_compute_timeshare_priority
https://github.com/apple-oss-distributions/xnu/blob/e7776783b89a353188416a9a346c6cdb4928faad/osfmk/kern/priority.c#L558
加入我們
位元組跳動 APM 中臺目前致力於提升整個集團內全系產品的效能和穩定性表現,技術棧覆蓋 iOS/Android/Server/Web/Hybrid/PC/遊戲/小程式等,工作內容包括但不限於效能穩定性監控,問題排查,深度優化,防劣化等。長期期望為業界輸出更多更有建設性的問題發現和深度優化手段。歡迎對位元組APM 團隊職位感興趣的同學投遞簡歷到郵箱[email protected]。
- 解鎖抖音世界盃的畫質優化實踐
- Kafka 架構、核心機制和場景解讀
- 頭條穩定性治理:ARC 環境中對 Objective-C 物件賦值的 Crash 隱患
- 位元組跳動模型大規模部署實戰
- 「飛書績效」寬表SQL自動生成邏輯淺析
- Mybatis原始碼主流程分析
- 推薦系統的Bias
- 抖音 Android 基礎技術大揭祕!| 位元組跳動技術沙龍第十期
- 基於序列標註模型的主動學習實踐
- 加密技術科普
- 二維碼掃描優化
- 前端監控系列4 | SDK 體積與效能優化實踐
- 特效側使用者體驗優化實戰 —— 包體積篇
- 深入理解 Android Studio Sync 流程
- 選擇 Go 還是 Rust?CloudWeGo-Volo 基於 Rust 語言的探索實踐
- 初探自然語言預訓練技術演進之路
- 高效能 RPC 框架 CloudWeGo-Kitex 內外統一的開源實踐
- 開源 1 週年突破 1w Star - CloudWeGo 開源社群實踐分享
- Go 語言官方依賴注入工具 Wire 使用指北
- prompt 綜述