iOS 底層拾遺:autorelease 優化
- 2019 年 12 月 23 日
- 筆記
由於 ARC 下 retain/release/autorelease
的調用都是編譯器代勞,所以需要使用編譯後的代碼進行分析,通常筆者選擇 Xcode 自帶的工具,它有一個優勢是自動將一些符號地址改為符號名,並且可以選擇 Running 或 Archiving 下的彙編代碼,後者生成的代碼往往是前者的優化版本。
本文基於 Runtime 750,arm64,Archiving 彙編代碼。
先前置聲明一個後文會用到的類:
@interface TestObject : NSObject <NSCopying> @end @implementation TestObject + (id)foo { return [NSObject new]; } - (id)copyWithZone:(NSZone *)zone { return [NSObject new]; } @end
一、alloc / new / copy / mutableCopy 方法
編寫這樣一段代碼:
[[NSObject new] copy];
彙編代碼為:
... adrp x8, l_OBJC_CLASSLIST_REFERENCES_$_@PAGE ldr x0, [x8, l_OBJC_CLASSLIST_REFERENCES_$_@PAGEOFF] adrp x8, l_OBJC_SELECTOR_REFERENCES_@PAGE ldr x1, [x8, l_OBJC_SELECTOR_REFERENCES_@PAGEOFF] bl _objc_msgSend mov x19, x0 adrp x8, l_OBJC_SELECTOR_REFERENCES_.6@PAGE ldr x1, [x8, l_OBJC_SELECTOR_REFERENCES_.6@PAGEOFF] bl _objc_msgSend bl _objc_release mov x0, x19 bl _objc_release ...
...@PAGE / ...@PAGEOFF
兩句一起出現表示通過頁基址+符號在頁中偏移來計算地址。首先把NSObject
類地址和new
方法地址找到分別放入x0
和x1
,然後調用_objc_msgSend
,調用完成後x0
裏面放的就是[NSObject new]
得到的對象地址,所以後面直接找到copy
方法調用。這一段基本分析後文不會再詳細描述了。
由於new
和copy
將引用計數+2,其後調用了兩次_objc_release
,這很符合直覺,看起來編譯器並未做什麼優化。
嘗試使用alloc
和mutableCopy
,得到幾乎一致的結果,似乎就能得到只要是生成本類實例的方法都不會做優化的結論? 並不能。
將上面的代碼換成:
[[TestObject new] copy];
發現編譯後的彙編代碼仍然和上面的差不多,而此時TestObject
的copy
返回了另外類的實例,退一步講,前面的NSObject
的copy
方法也並未實現,所以可以猜測:
編譯器不是通過返回類型來判斷的,而是通過簡單的符號匹配,發現alloc/new/copy/mutableCopy
符號就不做優化。
二、自定義帶返回值的方法
寫這樣一句代碼:
[TestObject foo];
彙編代碼為:
... bl _objc_msgSend Ltmp9: mov x29, x29 ; marker for objc_retainAutoreleaseReturnValue bl _objc_unsafeClaimAutoreleasedReturnValue ...
調用方的邏輯
調用_objc_msgSend
前x0 -> TestObject, x1 -> foo
,這裡出現了一個不符合直覺的 C 函數_objc_unsafeClaimAutoreleasedReturnValue
,光看名字猜不到具體是幹嘛的,所以去掉 C 語言符號修飾_
,直接查看源碼:
id objc_unsafeClaimAutoreleasedReturnValue(id obj) { if (acceptOptimizedReturn() == ReturnAtPlus0) return obj; return objc_releaseAndReturn(obj); }
objc_releaseAndReturn
函數不展開,只需知道是真正的release
操作。那麼大家就奇怪了,理論上foo
方法會把返回值先加入自動釋放池,這裡根本就不需要release
,常理來說只有當這個acceptOptimizedReturn() == ReturnAtPlus0
為 YES 不做release
操作才與大家的意識契合。那麼看一下這個關鍵方法:
enum ReturnDisposition : bool { ReturnAtPlus0 = false, ReturnAtPlus1 = true }; static ALWAYS_INLINE ReturnDisposition acceptOptimizedReturn() { ReturnDisposition disposition = getReturnDisposition(); setReturnDisposition(ReturnAtPlus0); // reset to the unoptimized state return disposition; } static ALWAYS_INLINE ReturnDisposition getReturnDisposition() { return (ReturnDisposition)(uintptr_t)tls_get_direct(RETURN_DISPOSITION_KEY); } static ALWAYS_INLINE void setReturnDisposition(ReturnDisposition disposition) { tls_set_direct(RETURN_DISPOSITION_KEY, (void*)(uintptr_t)disposition); }
看到最終是使用tls_get_direct(RETURN_DISPOSITION_KEY)
取的 TLS(Thread Local Storage)里的值,取了後還把這個值變為false
。看起來這裡的代碼非常簡單,我們只需要關心是 何時把 TLS 對應的值變成true
的 。
被調用方的邏輯
由於剛才分析了,foo
函數會將生成的對象放入自動釋放池,這裡理論上不需要release
,直接分析彙編代碼看是否有什麼奇怪操作:
... bl _objc_msgSend ldp x29, x30, [sp], #16 b _objc_autoreleaseReturnValue ...
發現調用了_objc_autoreleaseReturnValue
函數,切到源碼:
id objc_autoreleaseReturnValue(id obj) { if (prepareOptimizedReturn(ReturnAtPlus1)) return obj; return objc_autorelease(obj); } static ALWAYS_INLINE bool prepareOptimizedReturn(ReturnDisposition disposition) { assert(getReturnDisposition() == ReturnAtPlus0); if (callerAcceptsOptimizedReturn(__builtin_return_address(0))) { if (disposition) setReturnDisposition(disposition); return true; } return false; }
objc_autorelease
就是真正的autorelease
操作,會將對象加入自動釋放池。若if
判斷成功會調用了一個前面分析過的方法setReturnDisposition(...)
將 TLS 對應 Key 的值設置為true
,如果設置成功後將放棄autorelease
操作。
核心邏輯分析
這裡先考慮做了優化的情況。
重點分析: 若這裡不進行autorelease
,調用foo
後生成的對象將不會被自動釋放池管理,這個對象的引用計數為 1。那之前的objc_unsafeClaimAutoreleasedReturnValue(...)
函數就需要進行release
操作,[TestObject foo]
生成對象的引用計數才能減為 0。對比前面分析完全符合,至此,形成了邏輯通路。
優化點: 這個場景少了一步autorelease
,多了一步release
,也就是省去了加入自動釋放池的時間消耗、避免對象對自動釋放池的內存佔用。
另外一個場景,代碼如下:
id obj = [TestObject foo];
彙編代碼有些變化:
... bl _objc_msgSend mov x29, x29 ; marker for objc_retainAutoreleaseReturnValue bl _objc_retainAutoreleasedReturnValue bl _objc_release ...
筆者只是加了一個臨時變量持有這個返回值,直覺上會調用_objc_retain
然後調用_objc_release
,但先調用的是_objc_retainAutoreleasedReturnValue
:
id objc_retainAutoreleasedReturnValue(id obj) { if (acceptOptimizedReturn() == ReturnAtPlus1) return obj; return objc_retain(obj); }
foo
方法若不會將創建的對象進行autorelease
,那麼這裡也就不需要進行retain
了,很好理解。
優化點: 這個場景少了一步autorelease
,少了一步retain
,優化效果就變得明顯了。
如何判斷 autorelease 是否需要優化?
看關鍵的一個判斷:
if (callerAcceptsOptimizedReturn(__builtin_return_address(0))) ...
__builtin_return_address(n)
拿到的是前第 n + 1 函數棧的lr
(Link Register) ,那麼這裡就是拿到上一個棧的lr
(函數在調用其它函數時會將下一句指令地址寫入lr
便於恢復執行),也就是前面的這句彙編代碼(別疑惑函數棧這麼深怎麼會拿到第一個函數的lr
,因為這些函數都是內聯的):
mov x29, x29 ; marker for objc_retainAutoreleaseReturnValue
看起來這句代碼什麼也沒做,先看一下這個判斷函數:
static ALWAYS_INLINE bool callerAcceptsOptimizedReturn(const void *ra) { // fd 03 1d aa mov fp, fp // arm64 instructions are well-aligned if (*(uint32_t *)ra == 0xaa1d03fd) { return true; } return false; }
這個函數就是將ra
指向的值和0xaa1d03fd
對比,若相等就執行優化。注釋已經比較明白了,這個值就是mov fp, fp
的十六進制表示(注意 iOS 是小端)。
那麼只要被調用方拿到調用方的lr
判斷就行了,所以是否進行這個優化的決定權在調用方手中。
MRC 模式下是否開啟了優化
將代碼設置為-fno-objc-arc
,隨便寫些代碼查看彙編,發現編譯器並不會將開發者顯式寫的objc_autorelease/objc_retain/objc_release
函數強制改為objc_autoreleaseReturnValue
等方法,且 MRC 下編譯的代碼在調用方法時不會加入mov fp fp
企圖優化(推理也可知,因為retain/release
操作是不能優化的)。
objc_retain/objc_release
就是直接進行引用計數加減,而objc_autorelease
在OBJC2
上的定義卻有所變化:
__attribute__((aligned(16))) id objc_autorelease(id obj) { if (!obj) return obj; if (obj->isTaggedPointer()) return obj; return obj->autorelease(); } inline id objc_object::autorelease() { if (isTaggedPointer()) return (id)this; if (fastpath(!ISA()->hasCustomRR())) return rootAutorelease(); return ((id(*)(objc_object *, SEL))objc_msgSend)(this, SEL_autorelease); } objc_object::rootAutorelease() { if (isTaggedPointer()) return (id)this; if (prepareOptimizedReturn(ReturnAtPlus1)) return (id)this; return rootAutorelease2(); }
若當前類沒有自定義的retain/release
方法實現時,最終仍然會調用prepareOptimizedReturn(ReturnAtPlus1)
進行優化,所以當調用方是 ARC,被調用方是 MRC 時這個優化仍然有效。
為什麼要雙方協商 autorelease 優化?
總結一下: 不管被調用方是 MRC 還是 ARC,進行autorelease
操作時都會嘗試去優化,但是只有調用方是 ARC 時才能優化成功。
那麼協商的意義就很重要了,這樣才能保證版本兼容以及 MRC 和 ARC 的兼容。
為什麼使用線程局部存儲?
一個線程可以理解為一個一個順序執行指令的,那麼用一個 bool 值就能解決線程執行的所有代碼的優化。
TLS 是線程私有的,所以 autorelease 的優化是線程間互不影響的,引用計數的加減線程安全由相應的函數保證。若這個優化的控制是多線程共同制約的,那麼單純一個 bool 值是實現不了的,需要更複雜的數據結構,並且要額外處理線程並發的安全問題,這些工作反而會降低程序執行的效率。
後語
本文通過探索的方式分析了 autorelease 的優化邏輯,實際上並不能鐵板釘釘的說明事實,只有通過查看 clang 編譯器代碼才能真正的有說服力。不過從理解原理的角度來說按圖索驥是個非常好的學習方式,希望本文能給讀者朋友帶來一些幫助。