這是一篇 WWDC 2016 Session 406 的學(xué)習(xí)筆記泻帮,從原理到實(shí)踐講述了如何優(yōu)化 App 的啟動(dòng)時(shí)間玉罐。
App 運(yùn)行理論
main()執(zhí)行前發(fā)生的事
Mach-O 格式
虛擬內(nèi)存基礎(chǔ)
Mach-O 二進(jìn)制的加載
理論速成
Mach-O 術(shù)語
Mach-O 是針對(duì)不同運(yùn)行時(shí)可執(zhí)行文件的文件類型举塔。
文件類型:
Executable: 應(yīng)用的主要二進(jìn)制
Dylib: 動(dòng)態(tài)鏈接庫(又稱 DSO 或 DLL)
Bundle: 不能被鏈接的 Dylib,只能在運(yùn)行時(shí)使用dlopen()加載,可當(dāng)做 macOS 的插件。
Image: executable名挥,dylib 或 bundle
Framework: 包含 Dylib 以及資源文件和頭文件的文件夾
Mach-O 鏡像文件
Mach-O 被劃分成一些 segement,每個(gè) segement 又被劃分成一些 section主守。
segment 的名字都是大寫的禀倔,且空間大小為頁的整數(shù)。頁的大小跟硬件有關(guān)参淫,在 arm64 架構(gòu)一頁是 16KB救湖,其余為 4KB。
section 雖然沒有整數(shù)倍頁大小的限制涎才,但是 section 之間不會(huì)有重疊鞋既。
幾乎所有 Mach-O 都包含這三個(gè)段(segment):
__TEXT,__DATA和__LINKEDIT.
__TEXT包含 Mach header,被執(zhí)行的代碼和只讀常量(如C 字符串)耍铜。只讀可執(zhí)行(r-x)邑闺。
__DATA包含全局變量,靜態(tài)變量等棕兼《妇耍可讀寫(rw-)。
__LINKEDIT包含了加載程序的『元數(shù)據(jù)』程储,比如函數(shù)的名稱和地址。只讀(r–)臂寝。
Mach-O Universal 文件
FAT 二進(jìn)制文件章鲤,將多種架構(gòu)的 Mach-O 文件合并而成。它通過 Fat Header 來記錄不同架構(gòu)在文件中的偏移量咆贬,F(xiàn)at Header 占一頁的空間败徊。
按分頁來存儲(chǔ)這些 segement 和 header 會(huì)浪費(fèi)空間,但這有利于虛擬內(nèi)存的實(shí)現(xiàn)掏缎。
虛擬內(nèi)存
虛擬內(nèi)存就是一層間接尋址(indirection)皱蹦。軟件工程中有句格言就是任何問題都能通過添加一個(gè)間接層來解決。虛擬內(nèi)存解決的是管理所有進(jìn)程使用物理 RAM 的問題眷蜈。通過添加間接層來讓每個(gè)進(jìn)程使用邏輯地址空間沪哺,它可以映射到 RAM 上的某個(gè)物理頁上。這種映射不是一對(duì)一的酌儒,邏輯地址可能映射不到 RAM 上辜妓,也可能有多個(gè)邏輯地址映射到同一個(gè)物理 RAM 上。針對(duì)第一種情況,當(dāng)進(jìn)程要存儲(chǔ)邏輯地址內(nèi)容時(shí)會(huì)觸發(fā) page fault籍滴;第二種情況就是多進(jìn)程共享內(nèi)存酪夷。
對(duì)于文件可以不用一次性讀入整個(gè)文件,可以使用分頁映射(mmap())的方式讀取孽惰。也就是把文件某個(gè)片段映射到進(jìn)程邏輯內(nèi)存的某個(gè)頁上晚岭。當(dāng)某個(gè)想要讀取的頁沒有在內(nèi)存中,就會(huì)觸發(fā) page fault勋功,內(nèi)核只會(huì)讀入那一頁坦报,實(shí)現(xiàn)文件的懶加載。
也就是說 Mach-O 文件中的__TEXT段可以映射到多個(gè)進(jìn)程酝润,并可以懶加載燎竖,且進(jìn)程之間共享內(nèi)存。__DATA段是可讀寫的要销。這里使用到了 Copy-On-Write 技術(shù)构回,簡稱 COW。也就是多個(gè)進(jìn)程共享一頁內(nèi)存空間時(shí)疏咐,一旦有進(jìn)程要做寫操作纤掸,它會(huì)先將這頁內(nèi)存內(nèi)容復(fù)制一份出來,然后重新映射邏輯地址到新的 RAM 頁上浑塞。也就是這個(gè)進(jìn)程自己擁有了那頁內(nèi)存的拷貝借跪。這就涉及到了 clean/dirty page 的概念。dirty page 含有進(jìn)程自己的信息酌壕,而 clean page 可以被內(nèi)核重新生成(重新讀磁盤)掏愁。所以 dirty page 的代價(jià)大于 clean page。
Mach-O 鏡像 加載
所以在多個(gè)進(jìn)程加載 Mach-O 鏡像時(shí)__TEXT和__LINKEDIT因?yàn)橹蛔x卵牍,都是可以共享內(nèi)存的果港。而__DATA因?yàn)榭勺x寫,就會(huì)產(chǎn)生 dirty page糊昙。當(dāng) dyld 執(zhí)行結(jié)束后辛掠,__LINKEDIT就沒用了,對(duì)應(yīng)的內(nèi)存頁會(huì)被回收释牺。
安全
ASLR(Address Space Layout Randomization):地址空間布局隨機(jī)化萝衩,鏡像會(huì)在隨機(jī)的地址上加載。這其實(shí)是一二十年前的舊技術(shù)了没咙。
代碼簽名:可能我們認(rèn)為 Xcode 會(huì)把整個(gè)文件都做加密 hash 并用做數(shù)字簽名猩谊。其實(shí)為了在運(yùn)行時(shí)驗(yàn)證 Mach-O 文件的簽名,并不是每次重復(fù)讀入整個(gè)文件祭刚,而是把每頁內(nèi)容都生成一個(gè)單獨(dú)的加密散列值预柒,并存儲(chǔ)在__LINKEDIT中队塘。這使得文件每頁的內(nèi)容都能及時(shí)被校驗(yàn)確并保不被篡改。
從exec()到main()
exec()是一個(gè)系統(tǒng)調(diào)用宜鸯。系統(tǒng)內(nèi)核把應(yīng)用映射到新的地址空間憔古,且每次起始位置都是隨機(jī)的(因?yàn)槭褂?ASLR)。并將起始位置到0x000000這段范圍的進(jìn)程權(quán)限都標(biāo)記為不可讀寫不可執(zhí)行淋袖。如果是 32 位進(jìn)程鸿市,這個(gè)范圍至少是 4KB;對(duì)于 64 位進(jìn)程則至少是 4GB即碗。NULL 指針引用和指針截?cái)嗾`差都是會(huì)被它捕獲焰情。
dyld加載 dylib 文件
Unix 的前二十年很安逸,因?yàn)槟菚r(shí)還沒有發(fā)明動(dòng)態(tài)鏈接庫剥懒。有了動(dòng)態(tài)鏈接庫后内舟,一個(gè)用于加載鏈接庫的幫助程序被創(chuàng)建。在蘋果的平臺(tái)里是dyld初橘,其他 Unix 系統(tǒng)也有ld.so验游。 當(dāng)內(nèi)核完成映射進(jìn)程的工作后會(huì)將名字為dyld的Mach-O 文件映射到進(jìn)程中的隨機(jī)地址,它將 PC 寄存器設(shè)為dyld的地址并運(yùn)行保檐。dyld在應(yīng)用進(jìn)程中運(yùn)行的工作是加載應(yīng)用依賴的所有動(dòng)態(tài)鏈接庫耕蝉,準(zhǔn)備好運(yùn)行所需的一切,它擁有的權(quán)限跟應(yīng)用一樣夜只。
下面的步驟構(gòu)成了dyld的時(shí)間線:
Load dylibs -> Rebase -> Bind -> ObjC -> Initializers
加載 Dylib
從主執(zhí)行文件的 header 獲取到需要加載的所依賴動(dòng)態(tài)庫列表垒在,而 header 早就被內(nèi)核映射過。然后它需要找到每個(gè) dylib扔亥,然后打開文件讀取文件起始位置场躯,確保它是 Mach-O 文件。接著會(huì)找到代碼簽名并將其注冊(cè)到內(nèi)核旅挤。然后在 dylib 文件的每個(gè) segment 上調(diào)用mmap()踢关。應(yīng)用所依賴的 dylib 文件可能會(huì)再依賴其他 dylib,所以dyld所需要加載的是動(dòng)態(tài)庫列表一個(gè)遞歸依賴的集合谦铃。一般應(yīng)用會(huì)加載 100 到 400 個(gè) dylib 文件耘成,但大部分都是系統(tǒng) dylib榔昔,它們會(huì)被預(yù)先計(jì)算和緩存起來驹闰,加載速度很快。
Fix-ups
在加載所有的動(dòng)態(tài)鏈接庫之后撒会,它們只是處在相互獨(dú)立的狀態(tài)嘹朗,需要將它們綁定起來,這就是 Fix-ups诵肛。代碼簽名使得我們不能修改指令屹培,那樣就不能讓一個(gè) dylib 的調(diào)用另一個(gè) dylib默穴。這時(shí)需要加很多間接層。
現(xiàn)代 code-gen 被叫做動(dòng)態(tài) PIC(Position Independent Code)褪秀,意味著代碼可以被加載到間接的地址上蓄诽。當(dāng)調(diào)用發(fā)生時(shí),code-gen 實(shí)際上會(huì)在__DATA段中創(chuàng)建一個(gè)指向被調(diào)用者的指針媒吗,然后加載指針并跳轉(zhuǎn)過去仑氛。
所以dyld做的事情就是修正(fix-up)指針和數(shù)據(jù)。Fix-up 有兩種類型闸英,rebasing 和 binding锯岖。
Rebasing 和 Binding
Rebasing:在鏡像內(nèi)部調(diào)整指針的指向
Binding:將指針指向鏡像外部的內(nèi)容
可以通過命令行查看 rebase 和 bind 等信息:
xcrun dyldinfo -rebase -bind -lazy_bind myapp.app/myapp
通過這個(gè)命令可以查看所有的 Fix-up。rebase甫何,bind出吹,weak_bind,lazy_bind 都存儲(chǔ)在__LINKEDIT段中辙喂,并可通過LC_DYLD_INFO_ONLY查看各種信息的偏移量和大小捶牢。
建議用 MachOView 查看更加方便直觀。
從dyld源碼層面簡要介紹下 Rebasing 和 Binding 的流程加派。
ImageLoader是一個(gè)用于加載可執(zhí)行文件的基類叫确,它負(fù)責(zé)鏈接鏡像,但不關(guān)心具體文件格式芍锦,因?yàn)檫@些都交給子類去實(shí)現(xiàn)竹勉。每個(gè)可執(zhí)行文件都會(huì)對(duì)應(yīng)一個(gè)ImageLoader實(shí)例。ImageLoaderMachO是用于加載 Mach-O 格式文件的ImageLoader子類娄琉,而ImageLoaderMachOClassic和ImageLoaderMachOCompressed都繼承于ImageLoaderMachO次乓,分別用于加載那些__LINKEDIT段為傳統(tǒng)格式和壓縮格式的 Mach-O 文件。
因?yàn)?dylib 之間有依賴關(guān)系孽水,所以ImageLoader中的好多操作都是沿著依賴鏈遞歸操作的票腰,Rebasing 和 Binding 也不例外,分別對(duì)應(yīng)著recursiveRebase()和recursiveBind()這兩個(gè)方法女气。因?yàn)槭沁f歸杏慰,所以會(huì)自底向上地分別調(diào)用doRebase()和doBind()方法,這樣被依賴的 dylib 總是先于依賴它的 dylib 執(zhí)行 Rebasing 和 Binding炼鞠。傳入doRebase()和doBind()的參數(shù)包含一個(gè)LinkContext上下文缘滥,存儲(chǔ)了可執(zhí)行文件的一堆狀態(tài)和相關(guān)的函數(shù)。
在 Rebasing 和 Binding 前會(huì)判斷是否已經(jīng) Prebinding谒主。如果已經(jīng)進(jìn)行過預(yù)綁定(Prebinding)朝扼,那就不需要 Rebasing 和 Binding 這些 Fix-up 流程了,因?yàn)橐呀?jīng)在預(yù)先綁定的地址加載好了霎肯。
ImageLoaderMachO實(shí)例不使用預(yù)綁定會(huì)有四個(gè)原因:
Mach-O Header 中MH_PREBOUND標(biāo)志位為0
鏡像加載地址有偏移(這個(gè)后面會(huì)講到)
依賴的庫有變化
鏡像使用 flat-namespace擎颖,預(yù)綁定的一部分會(huì)被忽略
LinkContext的環(huán)境變量禁止了預(yù)綁定
ImageLoaderMachO中doRebase()做的事情大致如下:
如果使用預(yù)綁定榛斯,fgImagesWithUsedPrebinding計(jì)數(shù)加一,并return;否則進(jìn)入第二步
如果MH_PREBOUND標(biāo)志位為1(也就是可以預(yù)綁定但沒使用)搂捧,且鏡像在共享內(nèi)存中驮俗,重置上下文中所有的 lazy pointer。(如果鏡像在共享內(nèi)存中允跑,稍后會(huì)在 Binding 過程中綁定意述,所以無需重置)
如果鏡像加載地址偏移量為0,則無需 Rebasing吮蛹,直接return荤崇;否則進(jìn)入第四步
調(diào)用rebase()方法,這才是真正做 Rebasing 工作的方法潮针。如果開啟TEXT_RELOC_SUPPORT宏术荤,會(huì)允許rebase()方法對(duì)__TEXT段做寫操作來對(duì)其進(jìn)行 Fix-up。所以其實(shí)__TEXT只讀屬性并不是絕對(duì)的每篷。
ImageLoaderMachOClassic和ImageLoaderMachOCompressed分別實(shí)現(xiàn)了自己的doRebase()方法瓣戚。實(shí)現(xiàn)邏輯大同小異,同樣會(huì)判斷是否使用預(yù)綁定焦读,并在真正的 Binding 工作時(shí)判斷TEXT_RELOC_SUPPORT宏來決定是否對(duì)__TEXT段做寫操作子库。最后都會(huì)調(diào)用setupLazyPointerHandler在鏡像中設(shè)置dyld的 entry point,放在最后調(diào)用是為了讓主可執(zhí)行文件設(shè)置好__dyld或__program_vars矗晃。
Rebasing
在過去仑嗅,會(huì)把 dylib 加載到指定地址,所有指針和數(shù)據(jù)對(duì)于代碼來說都是對(duì)的张症,dyld就無需做任何 fix-up 了仓技。如今用了 ASLR 后悔將 dylib 加載到新的隨機(jī)地址(actual_address),這個(gè)隨機(jī)的地址跟代碼和數(shù)據(jù)指向的舊地址(preferred_address)會(huì)有偏差俗他,dyld需要修正這個(gè)偏差(slide)脖捻,做法就是將 dylib 內(nèi)部的指針地址都加上這個(gè)偏移量,偏移量的計(jì)算方法如下:
Slide = actual_address - preferred_address
然后就是重復(fù)不斷地對(duì)__DATA段中需要 rebase 的指針加上這個(gè)偏移量兆衅。這就又涉及到 page fault 和 COW地沮。這可能會(huì)產(chǎn)生 I/O 瓶頸,但因?yàn)?rebase 的順序是按地址排列的羡亩,所以從內(nèi)核的角度來看這是個(gè)有次序的任務(wù)摩疑,它會(huì)預(yù)先讀入數(shù)據(jù),減少 I/O 消耗夕春。
Binding
Binding 是處理那些指向 dylib 外部的指針未荒,它們實(shí)際上被符號(hào)(symbol)名稱綁定专挪,也就是個(gè)字符串及志。之前提到__LINKEDIT段中也存儲(chǔ)了需要 bind 的指針片排,以及指針需要指向的符號(hào)。dyld需要找到 symbol 對(duì)應(yīng)的實(shí)現(xiàn)速侈,這需要很多計(jì)算率寡,去符號(hào)表里查找。找到后會(huì)將內(nèi)容存儲(chǔ)到__DATA段中的那個(gè)指針中倚搬。Binding 看起來計(jì)算量比 Rebasing 更大冶共,但其實(shí)需要的 I/O 操作很少,因?yàn)橹?Rebasing 已經(jīng)替 Binding 做過了每界。
ObjC Runtime
Objective-C 中有很多數(shù)據(jù)結(jié)構(gòu)都是靠 Rebasing 和 Binding 來修正(fix-up)的捅僵,比如Class中指向超類的指針和指向方法的指針。
ObjC 是個(gè)動(dòng)態(tài)語言眨层,可以用類的名字來實(shí)例化一個(gè)類的對(duì)象庙楚。這意味著 ObjC Runtime 需要維護(hù)一張映射類名與類的全局表。當(dāng)加載一個(gè) dylib 時(shí)趴樱,其定義的所有的類都需要被注冊(cè)到這個(gè)全局表中馒闷。
C++ 中有個(gè)問題叫做易碎的基類(fragile base class)。ObjC 就沒有這個(gè)問題叁征,因?yàn)闀?huì)在加載時(shí)通過 fix-up 動(dòng)態(tài)類中改變實(shí)例變量的偏移量纳账。
在 ObjC 中可以通過定義類別(Category)的方式改變一個(gè)類的方法。有時(shí)你想要添加方法的類在另一個(gè) dylib 中捺疼,而不在你的鏡像中(也就是對(duì)系統(tǒng)或別人的類動(dòng)刀)疏虫,這時(shí)也需要做些 fix-up。
ObjC 中的 selector 必須是唯一的啤呼。
Initializers
C++ 會(huì)為靜態(tài)創(chuàng)建的對(duì)象生成初始化器议薪。而在 ObjC 中有個(gè)叫+load的方法,然而它被廢棄了媳友,現(xiàn)在建議使用+initialize斯议。對(duì)比詳見:http://stackoverflow.com/questions/13326435/nsobject-load-and-initialize-what-do-they-do
現(xiàn)在有了主執(zhí)行文件,一堆 dylib醇锚,其依賴關(guān)系構(gòu)成了一張巨大的有向圖哼御,那么執(zhí)行初始化器的順序是什么?自頂向上焊唬!按照依賴關(guān)系恋昼,先加載葉子節(jié)點(diǎn),然后逐步向上加載中間節(jié)點(diǎn)赶促,直至最后加載根節(jié)點(diǎn)液肌。這種加載順序確保了安全性,加載某個(gè) dylib 前鸥滨,其所依賴的其余 dylib 文件肯定已經(jīng)被預(yù)先加載嗦哆。
最后dyld會(huì)調(diào)用main()函數(shù)谤祖。main()會(huì)調(diào)用UIApplicationMain()。
改善啟動(dòng)時(shí)間
從點(diǎn)擊 App 圖標(biāo)到加載 App 閃屏之間會(huì)有個(gè)動(dòng)畫老速,我們希望 App 啟動(dòng)速度比這個(gè)動(dòng)畫更快粥喜。雖然不同設(shè)備上 App 啟動(dòng)速度不一樣,但啟動(dòng)時(shí)間最好控制在 400ms橘券。需要注意的是啟動(dòng)時(shí)間一旦超過 20s额湘,系統(tǒng)會(huì)認(rèn)為發(fā)生了死循環(huán)并殺掉 App 進(jìn)程。當(dāng)然啟動(dòng)時(shí)間最好以 App 所支持的最低配置設(shè)備為準(zhǔn)旁舰。直到applicationWillFinishLaunching被調(diào)動(dòng)锋华,App 才啟動(dòng)結(jié)束。
測量啟動(dòng)時(shí)間
Warm launch: App 和數(shù)據(jù)已經(jīng)在內(nèi)存中
Cold launch: App 不在內(nèi)核緩沖存儲(chǔ)器中
冷啟動(dòng)(Cold launch)耗時(shí)才是我們需要測量的重要數(shù)據(jù)箭窜,為了準(zhǔn)確測量冷啟動(dòng)耗時(shí)供置,測量前需要重啟設(shè)備。在main()方法執(zhí)行前測量是很難的绽快,好在dyld提供了內(nèi)建的測量方法:在 Xcode 中 Edit scheme -> Run -> Auguments 將環(huán)境變量DYLD_PRINT_STATISTICS設(shè)為1芥丧。
優(yōu)化啟動(dòng)時(shí)間
可以針對(duì) App 啟動(dòng)前的每個(gè)步驟進(jìn)行相應(yīng)的優(yōu)化工作。
加載 Dylib
之前提到過加載系統(tǒng)的 dylib 很快坊罢,因?yàn)橛袃?yōu)化续担。但加載內(nèi)嵌(embedded)的 dylib 文件很占時(shí)間,所以盡可能把多個(gè)內(nèi)嵌 dylib 合并成一個(gè)來加載活孩,或者使用 static archive物遇。使用dlopen()來在運(yùn)行時(shí)懶加載是不建議的,這么做可能會(huì)帶來一些問題憾儒,并且總的開銷更大询兴。
Rebase/Binding
之前提過 Rebaing 消耗了大量時(shí)間在 I/O 上,而在之后的 Binding 就不怎么需要 I/O 了起趾,而是將時(shí)間耗費(fèi)在計(jì)算上诗舰。所以這兩個(gè)步驟的耗時(shí)是混在一起的。
之前說過可以從查看__DATA段中需要修正(fix-up)的指針训裆,所以減少指針數(shù)量才會(huì)減少這部分工作的耗時(shí)眶根。對(duì)于 ObjC 來說就是減少Class,selector和category這些元數(shù)據(jù)的數(shù)量。從編碼原則和設(shè)計(jì)模式之類的理論都會(huì)鼓勵(lì)大家多寫精致短小的類和方法边琉,并將每部分方法獨(dú)立出一個(gè)類別属百,其實(shí)這會(huì)增加啟動(dòng)時(shí)間。對(duì)于 C++ 來說需要減少虛方法变姨,因?yàn)樘摲椒〞?huì)創(chuàng)建 vtable族扰,這也會(huì)在__DATA段中創(chuàng)建結(jié)構(gòu)。雖然 C++ 虛方法對(duì)啟動(dòng)耗時(shí)的增加要比 ObjC 元數(shù)據(jù)要少,但依然不可忽視渔呵。最后推薦使用 Swift 結(jié)構(gòu)體怒竿,它需要 fix-up 的內(nèi)容較少。
ObjC Setup
針對(duì)這步所能事情很少厘肮,幾乎都靠 Rebasing 和 Binding 步驟中減少所需 fix-up 內(nèi)容。因?yàn)榍懊娴墓ぷ饕矔?huì)使得這步耗時(shí)減少睦番。
Initializer
顯式初始化
使用+initialize來替代+load
不要使用__atribute__((constructor))將方法顯式標(biāo)記為初始化器类茂,而是讓初始化方法調(diào)用時(shí)才執(zhí)行。比如使用dispatch_once(),pthread_once()或std::once()托嚣。也就是在第一次使用時(shí)才初始化巩检,推遲了一部分工作耗時(shí)。
隱式初始化
對(duì)于帶有復(fù)雜(non-trivial)構(gòu)造器的 C++ 靜態(tài)變量:
在調(diào)用的地方使用初始化器示启。
只用簡單值類型賦值(POD:Plain Old Data)兢哭,這樣靜態(tài)鏈接器會(huì)預(yù)先計(jì)算__DATA中的數(shù)據(jù),無需再進(jìn)行 fix-up 工作夫嗓。
使用編譯器 warning 標(biāo)志-Wglobal-constructors來發(fā)現(xiàn)隱式初始化代碼迟螺。
使用 Swift 重寫代碼,因?yàn)?Swift 已經(jīng)預(yù)先處理好了舍咖,強(qiáng)力推薦矩父。
不要在初始化方法中調(diào)用dlopen(),對(duì)性能有影響排霉。因?yàn)閐yld在 App 開始前運(yùn)行窍株,由于此時(shí)是單線程運(yùn)行所以系統(tǒng)會(huì)取消加鎖,但dlopen()開啟了多線程攻柠,系統(tǒng)不得不加鎖球订,這就嚴(yán)重影響了性能,還可能會(huì)造成死鎖以及產(chǎn)生未知的后果瑰钮。所以也不要在初始化器中創(chuàng)建線程冒滩。