優化 App 的啟動時間

發表於2016-11-03
這是一篇 WWDC 2016 Session 406 的學習筆記,從原理到實踐講述瞭如何優化 App 的啟動時間。

App 執行理論

  • main() 執行前發生的事
  • Mach-O 格式
  • 虛擬記憶體基礎
  • Mach-O 二進位制的載入

理論速成

Mach-O 術語

Mach-O 是針對不同執行時可執行檔案的檔案型別。

檔案型別:

  • Executable: 應用的主要二進位制
  • Dylib: 動態連結庫(又稱 DSO 或 DLL)
  • Bundle: 不能被連結的 Dylib,只能在執行時使用 dlopen() 載入,可當做 macOS 的外掛。

Image: executable,dylib 或 bundle
Framework: 包含 Dylib 以及資原始檔和標頭檔案的資料夾

Mach-O 映象檔案

Mach-O 被劃分成一些 segement,每個 segement 又被劃分成一些 section。

segment 的名字都是大寫的,且空間大小為頁的整數。頁的大小跟硬體有關,在 arm64 架構一頁是 16KB,其餘為 4KB。

section 雖然沒有整數倍頁大小的限制,但是 section 之間不會有重疊。

幾乎所有 Mach-O 都包含這三個段(segment): __TEXT,__DATA__LINKEDIT

  • __TEXT 包含 Mach header,被執行的程式碼和只讀常量(如C 字串)。只讀可執行(r-x)。
  • __DATA 包含全域性變數,靜態變數等。可讀寫(rw-)。
  • __LINKEDIT 包含了載入程式的『後設資料』,比如函式的名稱和地址。只讀(r–)。

Mach-O Universal 檔案

FAT 二進位制檔案,將多種架構的 Mach-O 檔案合併而成。它通過 Fat Header 來記錄不同架構在檔案中的偏移量,Fat Header 佔一頁的空間。

按分頁來儲存這些 segement 和 header 會浪費空間,但這有利於虛擬記憶體的實現。

虛擬記憶體

虛擬記憶體就是一層間接定址(indirection)。軟體工程中有句格言就是任何問題都能通過新增一個間接層來解決。虛擬記憶體解決的是管理所有程式使用物理 RAM 的問題。通過新增間接層來讓每個程式使用邏輯地址空間,它可以對映到 RAM 上的某個物理頁上。這種對映不是一對一的,邏輯地址可能對映不到 RAM 上,也可能有多個邏輯地址對映到同一個物理 RAM 上。針對第一種情況,當程式要儲存邏輯地址內容時會觸發 page fault;第二種情況就是多程式共享記憶體。

對於檔案可以不用一次性讀入整個檔案,可以使用分頁對映(mmap())的方式讀取。也就是把檔案某個片段對映到程式邏輯記憶體的某個頁上。當某個想要讀取的頁沒有在記憶體中,就會觸發 page fault,核心只會讀入那一頁,實現檔案的懶載入。

也就是說 Mach-O 檔案中的 __TEXT 段可以對映到多個程式,並可以懶載入,且程式之間共享記憶體。__DATA 段是可讀寫的。這裡使用到了 Copy-On-Write 技術,簡稱 COW。也就是多個程式共享一頁記憶體空間時,一旦有程式要做寫操作,它會先將這頁記憶體內容複製一份出來,然後重新對映邏輯地址到新的 RAM 頁上。也就是這個程式自己擁有了那頁記憶體的拷貝。這就涉及到了 clean/dirty page 的概念。dirty page 含有程式自己的資訊,而 clean page 可以被核心重新生成(重新讀磁碟)。所以 dirty page 的代價大於 clean page。

Mach-O 映象 載入

所以在多個程式載入 Mach-O 映象時 __TEXT__LINKEDIT 因為只讀,都是可以共享記憶體的。而 __DATA 因為可讀寫,就會產生 dirty page。當 dyld 執行結束後,__LINKEDIT 就沒用了,對應的記憶體頁會被回收。

安全

ASLR(Address Space Layout Randomization):地址空間佈局隨機化,映象會在隨機的地址上載入。這其實是一二十年前的舊技術了。

程式碼簽名:可能我們認為 Xcode 會把整個檔案都做加密 hash 並用做數字簽名。其實為了在執行時驗證 Mach-O 檔案的簽名,並不是每次重複讀入整個檔案,而是把每頁內容都生成一個單獨的加密雜湊值,並儲存在 __LINKEDIT 中。這使得檔案每頁的內容都能及時被校驗確並保不被篡改。

exec()main()

exec() 是一個系統呼叫。系統核心把應用對映到新的地址空間,且每次起始位置都是隨機的(因為使用 ASLR)。並將起始位置到 0x000000 這段範圍的程式許可權都標記為不可讀寫不可執行。如果是 32 位程式,這個範圍至少是 4KB;對於 64 位程式則至少是 4GB。NULL 指標引用和指標截斷誤差都是會被它捕獲。

dyld 載入 dylib 檔案

Unix 的前二十年很安逸,因為那時還沒有發明動態連結庫。有了動態連結庫後,一個用於載入連結庫的幫助程式被建立。在蘋果的平臺裡是 dyld,其他 Unix 系統也有 ld.so。 當核心完成對映程式的工作後會將名字為 dyld 的Mach-O 檔案對映到程式中的隨機地址,它將 PC 暫存器設為 dyld 的地址並執行。dyld 在應用程式中執行的工作是載入應用依賴的所有動態連結庫,準備好執行所需的一切,它擁有的許可權跟應用一樣。

下面的步驟構成了 dyld 的時間線:

Load dylibs -> Rebase -> Bind -> ObjC -> Initializers

載入 Dylib

從主執行檔案的 header 獲取到需要載入的所依賴動態庫列表,而 header 早就被核心對映過。然後它需要找到每個 dylib,然後開啟檔案讀取檔案起始位置,確保它是 Mach-O 檔案。接著會找到程式碼簽名並將其註冊到核心。然後在 dylib 檔案的每個 segment 上呼叫 mmap()。應用所依賴的 dylib 檔案可能會再依賴其他 dylib,所以 dyld 所需要載入的是動態庫列表一個遞迴依賴的集合。一般應用會載入 100 到 400 個 dylib 檔案,但大部分都是系統 dylib,它們會被預先計算和快取起來,載入速度很快。

Fix-ups

在載入所有的動態連結庫之後,它們只是處在相互獨立的狀態,需要將它們繫結起來,這就是 Fix-ups。程式碼簽名使得我們不能修改指令,那樣就不能讓一個 dylib 的呼叫另一個 dylib。這時需要加很多間接層。

現代 code-gen 被叫做動態 PIC(Position Independent Code),意味著程式碼可以被載入到間接的地址上。當呼叫發生時,code-gen 實際上會在 __DATA 段中建立一個指向被呼叫者的指標,然後載入指標並跳轉過去。

所以 dyld 做的事情就是修正(fix-up)指標和資料。Fix-up 有兩種型別,rebasing 和 binding。

Rebasing 和 Binding

Rebasing:在映象內部調整指標的指向
Binding:將指標指向映象外部的內容

可以通過命令列檢視 rebase 和 bind 等資訊:

通過這個命令可以檢視所有的 Fix-up。rebase,bind,weak_bind,lazy_bind 都儲存在 __LINKEDIT 段中,並可通過 LC_DYLD_INFO_ONLY 檢視各種資訊的偏移量和大小。

建議用 MachOView 檢視更加方便直觀。

dyld 原始碼層面簡要介紹下 Rebasing 和 Binding 的流程。

ImageLoader 是一個用於載入可執行檔案的基類,它負責連結映象,但不關心具體檔案格式,因為這些都交給子類去實現。每個可執行檔案都會對應一個 ImageLoader 例項。ImageLoaderMachO 是用於載入 Mach-O 格式檔案的 ImageLoader 子類,而 ImageLoaderMachOClassicImageLoaderMachOCompressed 都繼承於 ImageLoaderMachO,分別用於載入那些 __LINKEDIT 段為傳統格式和壓縮格式的 Mach-O 檔案。

因為 dylib 之間有依賴關係,所以 ImageLoader 中的好多操作都是沿著依賴鏈遞迴操作的,Rebasing 和 Binding 也不例外,分別對應著 recursiveRebase()recursiveBind() 這兩個方法。因為是遞迴,所以會自底向上地分別呼叫 doRebase()doBind() 方法,這樣被依賴的 dylib 總是先於依賴它的 dylib 執行 Rebasing 和 Binding。傳入 doRebase()doBind() 的引數包含一個 LinkContext 上下文,儲存了可執行檔案的一堆狀態和相關的函式。

在 Rebasing 和 Binding 前會判斷是否已經 Prebinding。如果已經進行過預繫結(Prebinding),那就不需要 Rebasing 和 Binding 這些 Fix-up 流程了,因為已經在預先繫結的地址載入好了。

ImageLoaderMachO 例項不使用預綁定會有四個原因:

  1. Mach-O Header 中 MH_PREBOUND 標誌位為 0
  2. 映象載入地址有偏移(這個後面會講到)
  3. 依賴的庫有變化
  4. 映象使用 flat-namespace,預繫結的一部分會被忽略
  5. LinkContext 的環境變數禁止了預繫結

ImageLoaderMachOdoRebase() 做的事情大致如下:

  1. 如果使用預繫結,fgImagesWithUsedPrebinding 計數加一,並 return;否則進入第二步
  2. 如果 MH_PREBOUND 標誌位為 1(也就是可以預繫結但沒使用),且映象在共享記憶體中,重置上下文中所有的 lazy pointer。(如果映象在共享記憶體中,稍後會在 Binding 過程中繫結,所以無需重置)
  3. 如果映象載入地址偏移量為0,則無需 Rebasing,直接 return;否則進入第四步
  4. 呼叫 rebase() 方法,這才是真正做 Rebasing 工作的方法。如果開啟 TEXT_RELOC_SUPPORT 巨集,會允許 rebase() 方法對 __TEXT 段做寫操作來對其進行 Fix-up。所以其實 __TEXT 只讀屬性並不是絕對的。

ImageLoaderMachOClassicImageLoaderMachOCompressed 分別實現了自己的 doRebase() 方法。實現邏輯大同小異,同樣會判斷是否使用預繫結,並在真正的 Binding 工作時判斷 TEXT_RELOC_SUPPORT 巨集來決定是否對 __TEXT 段做寫操作。最後都會呼叫 setupLazyPointerHandler 在映象中設定 dyld 的 entry point,放在最後呼叫是為了讓主可執行檔案設定好 __dyld__program_vars

Rebasing

在過去,會把 dylib 載入到指定地址,所有指標和資料對於程式碼來說都是對的,dyld 就無需做任何 fix-up 了。如今用了 ASLR 後悔將 dylib 載入到新的隨機地址(actual_address),這個隨機的地址跟程式碼和資料指向的舊地址(preferred_address)會有偏差,dyld 需要修正這個偏差(slide),做法就是將 dylib 內部的指標地址都加上這個偏移量,偏移量的計算方法如下:

Slide = actual_address – preferred_address

然後就是重複不斷地對 __DATA 段中需要 rebase 的指標加上這個偏移量。這就又涉及到 page fault 和 COW。這可能會產生 I/O 瓶頸,但因為 rebase 的順序是按地址排列的,所以從核心的角度來看這是個有次序的任務,它會預先讀入資料,減少 I/O 消耗。

Binding

Binding 是處理那些指向 dylib 外部的指標,它們實際上被符號(symbol)名稱繫結,也就是個字串。之前提到 __LINKEDIT 段中也儲存了需要 bind 的指標,以及指標需要指向的符號。dyld 需要找到 symbol 對應的實現,這需要很多計算,去符號表裡查詢。找到後會將內容儲存到 __DATA 段中的那個指標中。Binding 看起來計算量比 Rebasing 更大,但其實需要的 I/O 操作很少,因為之前 Rebasing 已經替 Binding 做過了。

ObjC Runtime

Objective-C 中有很多資料結構都是靠 Rebasing 和 Binding 來修正(fix-up)的,比如 Class 中指向超類的指標和指向方法的指標。

ObjC 是個動態語言,可以用類的名字來例項化一個類的物件。這意味著 ObjC Runtime 需要維護一張對映類名與類的全域性表。當載入一個 dylib 時,其定義的所有的類都需要被註冊到這個全域性表中。

C++ 中有個問題叫做易碎的基類(fragile base class)。ObjC 就沒有這個問題,因為會在載入時通過 fix-up 動態類中改變例項變數的偏移量。

在 ObjC 中可以通過定義類別(Category)的方式改變一個類的方法。有時你想要新增方法的類在另一個 dylib 中,而不在你的映象中(也就是對系統或別人的類動刀),這時也需要做些 fix-up。

ObjC 中的 selector 必須是唯一的。

Initializers

C++ 會為靜態建立的物件生成初始化器。而在 ObjC 中有個叫 +load 的方法,然而它被廢棄了,現在建議使用 +initialize。對比詳見:http://stackoverflow.com/questions/13326435/nsobject-load-and-initialize-what-do-they-do

現在有了主執行檔案,一堆 dylib,其依賴關係構成了一張巨大的有向圖,那麼執行初始化器的順序是什麼?自頂向上!按照依賴關係,先載入葉子節點,然後逐步向上載入中間節點,直至最後載入根節點。這種載入順序確保了安全性,載入某個 dylib 前,其所依賴的其餘 dylib 檔案肯定已經被預先載入。

最後 dyld 會呼叫 main() 函式。main() 會呼叫 UIApplicationMain()

改善啟動時間

從點選 App 圖示到載入 App 閃屏之間會有個動畫,我們希望 App 啟動速度比這個動畫更快。雖然不同裝置上 App 啟動速度不一樣,但啟動時間最好控制在 400ms。需要注意的是啟動時間一旦超過 20s,系統會認為發生了死迴圈並殺掉 App 程式。當然啟動時間最好以 App 所支援的最低配置裝置為準。直到 applicationWillFinishLaunching 被調動,App 才啟動結束。

測量啟動時間

Warm launch: App 和資料已經在記憶體中
Cold launch: App 不在核心緩衝儲存器中

冷啟動(Cold launch)耗時才是我們需要測量的重要資料,為了準確測量冷啟動耗時,測量前需要重啟裝置。在 main() 方法執行前測量是很難的,好在 dyld 提供了內建的測量方法:在 Xcode 中 Edit scheme -> Run -> Auguments 將環境變數 DYLD_PRINT_STATISTICS 設為 1。控制檯輸出的內容如下:

優化啟動時間

可以針對 App 啟動前的每個步驟進行相應的優化工作。

載入 Dylib

之前提到過載入系統的 dylib 很快,因為有優化。但載入內嵌(embedded)的 dylib 檔案很佔時間,所以儘可能把多個內嵌 dylib 合併成一個來載入,或者使用 static archive。使用 dlopen() 來在執行時懶載入是不建議的,這麼做可能會帶來一些問題,並且總的開銷更大。

Rebase/Binding

之前提過 Rebaing 消耗了大量時間在 I/O 上,而在之後的 Binding 就不怎麼需要 I/O 了,而是將時間耗費在計算上。所以這兩個步驟的耗時是混在一起的。

之前說過可以從檢視 __DATA 段中需要修正(fix-up)的指標,所以減少指標數量才會減少這部分工作的耗時。對於 ObjC 來說就是減少 Class,selectorcategory 這些後設資料的數量。從編碼原則和設計模式之類的理論都會鼓勵大家多寫精緻短小的類和方法,並將每部分方法獨立出一個類別,其實這會增加啟動時間。對於 C++ 來說需要減少虛方法,因為虛方法會建立 vtable,這也會在 __DATA 段中建立結構。雖然 C++ 虛方法對啟動耗時的增加要比 ObjC 後設資料要少,但依然不可忽視。最後推薦使用 Swift 結構體,它需要 fix-up 的內容較少。

ObjC Setup

針對這步所能事情很少,幾乎都靠 Rebasing 和 Binding 步驟中減少所需 fix-up 內容。因為前面的工作也會使得這步耗時減少。

Initializer

顯式初始化

  • 使用 +initialize 來替代 +load
  • 不要使用 __atribute__((constructor)) 將方法顯式標記為初始化器,而是讓初始化方法呼叫時才執行。比如使用 dispatch_once(),pthread_once()std::once()。也就是在第一次使用時才初始化,推遲了一部分工作耗時。

隱式初始化

對於帶有複雜(non-trivial)構造器的 C++ 靜態變數:

  1. 在呼叫的地方使用初始化器。
  2. 只用簡單值型別賦值(POD:Plain Old Data),這樣靜態連結器會預先計算 __DATA 中的資料,無需再進行 fix-up 工作。
  3. 使用編譯器 warning 標誌 -Wglobal-constructors 來發現隱式初始化程式碼。
  4. 使用 Swift 重寫程式碼,因為 Swift 已經預先處理好了,強力推薦。

不要在初始化方法中呼叫 dlopen(),對效能有影響。因為 dyld 在 App 開始前執行,由於此時是單執行緒執行所以系統會取消加鎖,但 dlopen() 開啟了多執行緒,系統不得不加鎖,這就嚴重影響了效能,還可能會造成死鎖以及產生未知的後果。所以也不要在初始化器中建立執行緒。

Reference

https://developer.apple.com/videos/play/wwdc2016/406/

相關文章