標籤:而在 模式 objc 通過 glob hand support 自己 結束
這是一篇 WWDC 2016 Session 406 的學習筆記,從原理到實踐講述了如何最佳化 App 的啟動時間。
App 運行理論
main() 執行前發生的事
Mach-O 格式
虛擬記憶體基礎
Mach-O 二進位的載入
理論速成Mach-O 術語
Mach-O 是針對不同運行時可執行檔的檔案類型。
檔案類型:
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 等資訊:
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 也不例外,分別對應著 recursiveBind() 和 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 。控制台輸出的內容如下:
Total pre-main time:228.41 milliseconds (100.0%)dylib loading time:82.35 milliseconds (36.0%)
rebase/binding time:6.12 milliseconds (2.6%)
ObjC setup time:7.82 milliseconds (3.4%)initializer time:132.02 milliseconds (57.8%)slowest intializers :libSystem.B.dylib:122.07 milliseconds (53.4%)
CoreFoundation:5.59 milliseconds (2.4%)
最佳化啟動時間
可以針對 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顯式初始化
隱式初始化
對於帶有 複雜(non-trivial)構造器 的 C++ 靜態變數:
在調用的地方使用初始化器。
只用簡單實值型別賦值(POD:Plain Old Data),這樣靜態連結器會預先計算__DATA 中的資料,無需再進行 fix-up 工作。
使用編譯器 warning 標誌 -Wglobal-constructors 來發現隱式初始化代碼。
使用 Swift 重寫代碼,因為 Swift 已經預先處理好了,強力推薦。
不要在初始化方法中調用 dlopen() ,對效能有影響。因為 dyld 在 App 開始前運行,由於此時是單線程運行所以系統會取消加鎖,但 dlopen() 開啟了多線程,系統不得不加鎖,這就嚴重影響了效能,還可能會造成死結以及產生未知的後果。所以也不要在初始化器中建立線程。
最佳化 App 的啟動時間