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方法地址找到分別放入x0x1,然後調用_objc_msgSend,調用完成後x0裏面放的就是[NSObject new]得到的對象地址,所以後面直接找到copy方法調用。這一段基本分析後文不會再詳細描述了。

由於newcopy將引用計數+2,其後調用了兩次_objc_release,這很符合直覺,看起來編譯器並未做什麼優化。

嘗試使用allocmutableCopy,得到幾乎一致的結果,似乎就能得到只要是生成本類實例的方法都不會做優化的結論? 並不能。

將上面的代碼換成:

[[TestObject new] copy];

發現編譯後的彙編代碼仍然和上面的差不多,而此時TestObjectcopy返回了另外類的實例,退一步講,前面的NSObjectcopy方法也並未實現,所以可以猜測:

編譯器不是通過返回類型來判斷的,而是通過簡單的符號匹配,發現alloc/new/copy/mutableCopy符號就不做優化。

二、自定義帶返回值的方法

寫這樣一句代碼:

[TestObject foo];

彙編代碼為:

...      bl  _objc_msgSend  Ltmp9:      mov x29, x29    ; marker for objc_retainAutoreleaseReturnValue      bl  _objc_unsafeClaimAutoreleasedReturnValue  ...

調用方的邏輯

調用_objc_msgSendx0 -> 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_autoreleaseOBJC2上的定義卻有所變化:

__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 編譯器代碼才能真正的有說服力。不過從理解原理的角度來說按圖索驥是個非常好的學習方式,希望本文能給讀者朋友帶來一些幫助。