這是一篇 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 等信息:
1 |
xcrun dyldinfo -rebase -bind -lazy_bind myapp.app/myapp |
通過這個命令可以查看所有的 Fix-up。rebase,bind,weak_bind,lazy_bind 都存儲在 __LINKEDIT
段中,並可通過LC_DYLD_INFO_ONLY
查看各種信息的偏移量和大小。
建議用 MachOView 查看更加方便直觀。
從 dyld
源碼層面簡要介紹下 Rebasing 和 Binding 的流程。
ImageLoader
是一個用於加載可執行文件的基類,它負責鏈接鏡像,但不關心具體文件格式,因爲這些都交給子類去實現。每個可執行文件都會對應一個 ImageLoader
實例。ImageLoaderMachO
是用於加載 Mach-O 格式文件的 ImageLoader
子類,而 ImageLoaderMachOClassic
和 ImageLoaderMachOCompressed
都繼承於 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
實例不使用預綁定會有四個原因:
- Mach-O Header 中
MH_PREBOUND
標誌位爲0
- 鏡像加載地址有偏移(這個後面會講到)
- 依賴的庫有變化
- 鏡像使用 flat-namespace,預綁定的一部分會被忽略
LinkContext
的環境變量禁止了預綁定
ImageLoaderMachO
中 doRebase()
做的事情大致如下:
- 如果使用預綁定,
fgImagesWithUsedPrebinding
計數加一,並return
;否則進入第二步 - 如果
MH_PREBOUND
標誌位爲1
(也就是可以預綁定但沒使用),且鏡像在共享內存中,重置上下文中所有的 lazy pointer。(如果鏡像在共享內存中,稍後會在 Binding 過程中綁定,所以無需重置) - 如果鏡像加載地址偏移量爲0,則無需 Rebasing,直接
return
;否則進入第四步 - 調用
rebase()
方法,這纔是真正做 Rebasing 工作的方法。如果開啓TEXT_RELOC_SUPPORT
宏,會允許rebase()
方法對__TEXT
段做寫操作來對其進行 Fix-up。所以其實__TEXT
只讀屬性並不是絕對的。
ImageLoaderMachOClassic
和 ImageLoaderMachOCompressed
分別實現了自己的 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
。控制檯輸出的內容如下:
1 |
Total pre-main time: 228.41 milliseconds (100.0%) |
優化啓動時間
可以針對 App 啓動前的每個步驟進行相應的優化工作。
加載 Dylib
之前提到過加載系統的 dylib 很快,因爲有優化。但加載內嵌(embedded)的 dylib 文件很佔時間,所以儘可能把多個內嵌 dylib 合併成一個來加載,或者使用 static archive。使用 dlopen()
來在運行時懶加載是不建議的,這麼做可能會帶來一些問題,並且總的開銷更大。
Rebase/Binding
之前提過 Rebaing 消耗了大量時間在 I/O 上,而在之後的 Binding 就不怎麼需要 I/O 了,而是將時間耗費在計算上。所以這兩個步驟的耗時是混在一起的。
之前說過可以從查看 __DATA
段中需要修正(fix-up)的指針,所以減少指針數量纔會減少這部分工作的耗時。對於 ObjC 來說就是減少 Class
,selector
和 category
這些元數據的數量。從編碼原則和設計模式之類的理論都會鼓勵大家多寫精緻短小的類和方法,並將每部分方法獨立出一個類別,其實這會增加啓動時間。對於
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++ 靜態變量:
- 在調用的地方使用初始化器。
- 只用簡單值類型賦值(POD:Plain Old Data),這樣靜態鏈接器會預先計算
__DATA
中的數據,無需再進行 fix-up 工作。 - 使用編譯器 warning 標誌
-Wglobal-constructors
來發現隱式初始化代碼。 - 使用 Swift 重寫代碼,因爲 Swift 已經預先處理好了,強力推薦。
不要在初始化方法中調用 dlopen()
,對性能有影響。因爲 dyld
在 App 開始前運行,由於此時是單線程運行所以系統會取消加鎖,但 dlopen()
開啓了多線程,系統不得不加鎖,這就嚴重影響了性能,還可能會造成死鎖以及產生未知的後果。所以也不要在初始化器中創建線程。