優化 App 的啟動時間

vidv3893 8年前發布 | 6K 次閱讀 iOS開發 移動開發 Objective-C

這是一篇 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 等信息:

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 實例 不使用預綁 定會有四個原因:

  1. Mach-O Header 中 MH_PREBOUND 標志位為 0
  2. 鏡像加載地址有偏移(這個后面會講到)
  3. 依賴的庫有變化
  4. 鏡像使用 flat-namespace,預綁定的一部分會被忽略
  5. LinkContext 的環境變量禁止了預綁定

ImageLoaderMachO 中 doRebase() 做的事情大致如下:

  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 只讀屬性并不是絕對的。

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

顯式初始化

  • 使用 +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() 開啟了多線程,系統不得不加鎖,這就嚴重影響了性能,還可能會造成死鎖以及產生未知的后果。所以也不要在初始化器中創建線程。

 

來自:http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time/

 

 本文由用戶 vidv3893 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!