從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

WeZZard發表於2019-04-20

面試工作基本結束,如果不出什麼意外(比如資方最後撤回錄用邀約之類)的話我將會去一家我認為比較有作為空間的公司工作。在準備面試過程中,我買了一本「iOS 面試之道」看,然而發現裡面在技術這一部分還是有一些紕漏的。發現這些紕漏後我發了電子郵件給本書技術部分的共同作者,但是後來又發現其實那封郵件中也有一些錯誤。現在有時間校對了一下並且擴充了一些內容後選擇在中文 web 上刊登出來。因為其中一道題目涉及冗長的 Swift 物件模型和執行時細節,所以單獨寫成一篇文章釋出出來,其餘的再組成第二篇文章。這篇文章是第一篇,主要討論「iOS 面試之道」中第 34 頁中有關連結串列的程式碼範例。

自動引用計數 + 超長連結串列釋放 == 爆棧?

我注意到本書 34 頁中有關連結串列的程式碼範例使用了自動引用計數的方式管理連結串列節點的後繼節點。然而根據我的經驗,這種構造方法會造成在連結串列過長後出現連結串列在釋放時爆棧而最終導致 app 崩潰的隱患。

class ListNode {
    var val: Int
    var next: ListNode?
    
    init(_ val: Int) {
        self.val = val
        self.next = nil
    }
}
複製程式碼

我們可以開啟 Xcode 新建一個 macOS 命令列工程,輸入如下程式碼:

import Foundation

print("Main thread stack size: ", Thread.main.stackSize)

class ListNode {
    var val: Int
    var next: ListNode?
    
    init(_ val: Int) {
        self.val = val
        self.next = nil
    }
}

autoreleasepool {
    let root = ListNode(0)
    
    var current = root
    
    for _ in 0..<100000 {
        let next = ListNode(0)
        current.next = next
        current = next
    }
}

print("Foo")
複製程式碼

思考題:你在上述程式碼中感到了什麼異樣沒有?

如果不爆棧,那麼這個程式將最後輸出 "Foo"。但是在執行期間,這個程式就崩潰了——Xcode 會將左側的 Navigator 皮膚切換到 Debug Navigator 然後顯示下列內容:

爆棧

依靠直覺,我們可以猜測,應該是因為編譯器給 ListNode 自動生成的 deinit 中釋放 next 的過程是一個遞迴過程,100000 個節點將導致遞迴過程太深,然後呼叫棧爆了(俗稱爆棧)。但是問題來了:

  • 真的是這樣嗎?
  • Swift 不會做尾遞迴優化嗎?

尾遞迴優化指的是,一個遞迴函式在函式體尾部呼叫其自身時,我們可以不建立一個新的棧幀(stack frame)而直接利用原有的棧幀來進行計算,這樣就可以避免遞迴過程中可能出現的因為呼叫棧太深而爆棧的危險;這個優化是編譯優化中的一個可選部分,具體有無由編譯器廠商決定。

要深入理解尾遞迴以及討論 iOS 開發中的尾遞迴優化會涉及到編譯原理的執行時環境中的過程抽象這個部分,我之後會專門撰文談談 iOS 開發中的過程抽象,這裡我們先不做深入討論。

這些問題都需要我們對 Swift 的對類例項進行釋放時的工作機制有一個基本的瞭解。

追蹤 ListNode 例項的釋放過程

為了更加詳細地瞭解 Swift 的工作機制,我們一般都會想到去直接考察編譯後產生的二進位制檔案。我們可以將 ListNode 部分的程式碼儲存為一個名為 CallStack.swift 的檔案,然後在命令列用 swiftc CallStack.swift 編譯後再用 Hopper 反編譯出來:

反編譯內容

顯然,Swift 編譯器也使用了類似 C++ 編譯器的 name mangling 的技術對譯元(translation unit,既參與編譯的每一份原始檔,我不太清楚中文譯法,自己譯的)中的成員名字進行改編,以此讓 Swift 中函式和型別的重名現象降至最低。但是這也造成了在這張圖中,我們除了能猜測出 valnext 這兩個 properties 的 accessors 對應的過程(既二進位制檔案中函式對應的機器碼)之外,就再也猜測不出究竟哪個過程是這個自動生成的 deinit 函式對應的過程了。

「函式」和「過程」的區別在不同語境下不同。在計算機程式設計剛剛興起時,函式指的是有返回值的函式,而過程指的是沒有返回值的函式;到了現在,人們幾乎已經不再區別函式和過程。在這裡,我用函式表示 Swift 中的函式,而過程表示函式在二進位制檔案中對應的機器碼序列。

如果這時我們能回溯到更加高一級的編譯產物中,也許能找到答案。但是對於很多人而言,也許只知道原始碼轉換成 AST(抽象語法樹),AST 轉換成 LLVM IR(LLVM 中間表述),然後 LLVM IR 生成機器碼,再又連結器合成目標檔案(可執行檔案或者庫)的這個流程;並且對於這些人而言,檢視和分析 AST 或者 LLVM IR 是非常陌生的。但是 Swift 的編譯過程有點不一樣:Swift 的編譯過程不等同於傳統 Clang + LLVM 的編譯過程,在 AST和 LLVM IR之間還會生成一個叫 SIL(Swift Intermediate Language,Swift 中間語言)的產物。SIL 是 Swift AST 和 LLVM IR 之間的一層抽象,是一種具備高層(相對於機器語言這種底層)語義的 SSA 形式(靜態單次賦值形式)的編譯器中間表述。如果你看不懂前面這句,那麼可以認為 SIL 就是一種具備高階語言語義(既「指令集」抽象自 Swift 執行時,而不單純是目標平臺)以及機器底層抽象(比如說使用 %0 %1 %2 ... %n 來表示虛擬暫存器,同時可以部分操縱記憶體佈局的)這兩種知識的綜合體。因為其具備高層語義又兼顧底層,也許我們能在這裡面找到 ListNodedeinit 函式的相關資訊(如果你可以上 YouTube 可以看看這個視訊瞭解一下 SIL 是什麼,不過需要一點編譯器相關的知識)。

於是這裡我們需要在命令列使用 swiftc -emit-sil CallStack.swift 來獲得這份原始碼的 SIL。

使用 -emit-sil-emit-silgen 都可以生成 Swift SIL,但是前者會附帶語法診斷資訊,而後者是生(raw)SIL。

deinit 過程分析:SIL 視角

通過搜尋 ListNode.deinit 我們可以找到如下內容:

// ListNode.deinit
sil hidden @$s9CallStack8ListNodeCfd : $@convention(method) (@guaranteed ListNode) -> @owned Builtin.NativeObject {
// %0                                             // users: %4, %2, %1
bb0(%0 : $ListNode):
  debug_value %0 : $ListNode, let, name "self", argno 1 // id: %1
  %2 = ref_element_addr %0 : $ListNode, #ListNode.next // user: %3
  destroy_addr %2 : $*Optional<ListNode>          // id: %3
  %4 = unchecked_ref_cast %0 : $ListNode to $Builtin.NativeObject // user: %5
  return %4 : $Builtin.NativeObject               // id: %5
} // end sil function '$s9CallStack8ListNodeCfd'
複製程式碼

有趣的是,即使你在 ListNode 中自定義一個空白的 deinit 函式,Swift 編譯器還是會生成同樣的 SIL,可見 Swift 編譯器是會在自定義的 deinit 函式中自動補全該摧毀的所有例項成員的摧毀(destroy)程式碼的。

於是我們可以知道,$s9CallStack8ListNodeCfd 這個過程對應的是 ListNode.deinit 這個函式,其 SIL 的主要內容如下:

bb0(%0 : $ListNode):
  ...
  %2 = ref_element_addr %0 : $ListNode, #ListNode.next // user: %3
  destroy_addr %2 : $*Optional<ListNode>          // id: %3
  ...
}
複製程式碼

首先我們要注意到 bb0 這個東西:

bb 意指 basic block,這是來自 LLVM 中的一個概念:既一段最基本的程式碼塊。

一段改寫成 SIL 的 Swift 函式可能只有一個 basic block,也可能因為有各種控制流的加入(if-else 或者 switch-case 之類的)導致有很多個 basic blocks。bb0 就是指的函式體內的第 0 個 basic block。

然後,我們還可以注意到 bb0 背後還有一個 (%0 : $ListNode)

在這裡,這個 %0 本質上是 bb0 這個 basic block 內的本地變數,指的是第 0 號虛擬暫存器,而其型別就是 ListNode。特別的,這個第 0 號虛擬暫存器充當這個 basic block 的第一個「形式引數」——當然,bb0 不是函式,這裡我只是借用「形式引數」這個概念來幫助大家理解這個 %0 到底是個什麼玩意兒。之後你還會在 SIL 中看到 %1 %2 %3 ... %n 這種表記,這些都是「第 n 號虛擬暫存器」的意思——同時他們也被都是「本地變數」。同樣,這種表記方法也來自 LLVM IR,而 SIL 借鑑之。

最後,就是 ref_element_addrdestroy_addr 這些東西是什麼:

這些東西被稱為 SIL 指令。這些東西也是 SIL 之所以被稱為 SIL (Swift Intermediate Language) 的原因——因為這些 SIL 指令並不是有關目標平臺指令的抽象,而是 Swift 執行時的抽象。我們可以將這些 SIL 指令理解為一個函式,實際上這些指令在最後生成 LLVM IR 後也確實會去呼叫那些由 C++ 編寫的 Swift 執行時中對應的函式。

接下來我們討論一下這段 SIL 幹了啥:

這部分的內容主要就是將 bb0%0 傳入 ref_element_addr 指令。然後用 %2 接住返回值,再將這個返回值傳入 destroy_addr 指令。

我們可以在這裡找到 ref_element_addr 指令的說明:

sil-instruction ::= 'ref_element_addr' sil-operand ',' sil-decl-ref

%1 = ref_element_addr %0 : $C, #C.field
// %0 must be a value of class type $C
// #C.field must be a non-static physical field of $C
// %1 will be of type $*U where U is the type of the selected field
//   of C
複製程式碼

Given an instance of a class, derives the address of a physical instance variable inside the instance. It is undefined behavior if the class value is null.

所以我們知道,這個指令是用來獲得例項變數的地址的。

你可能看不懂 sil-instruction ::= 'ref_element_addr' sil-operand ',' sil-decl-ref 是什麼意思,不要急,我下面會講。

%2 = ref_element_addr %0 : $ListNode, #ListNode.next
複製程式碼

的意思就是獲得關於 %0 上這個 ListNode 例項的 ListNode.next 例項變數的地址。

接下來一句:

destroy_addr %2 : $*Optional<ListNode>
複製程式碼

我們可以在這裡找到 destroy_addr 的文件:

sil-instruction ::= 'destroy_addr' sil-operand

destroy_addr %0 : $*T
// %0 must be of an address $*T type
複製程式碼

Destroys the value in memory at address %0. If T is a non-trivial type, This is equivalent to:

%1 = load %0
strong_release %1
複製程式碼

except that destroy_addr may be used even if %0 is of an address-only type. This does not deallocate memory; it only destroys the pointed-to value, leaving the memory uninitialized.

If T is a trivial type, then destroy_addr is a no-op.

我們可以從中得知:這句 SIL 其實相當於執行:

strong_release %2 : $*Optional<ListNode>
複製程式碼

而文件對 strong_release 的解釋如下:

strong_release %0 : $T
// $T must be a reference type.
複製程式碼

Decrements the strong reference count of the heap object referenced by %0. If the release operation brings the strong reference count of the object to zero, the object is destroyed and @weak references are cleared. When both its strong and unowned reference counts reach zero, the object's memory is deallocated.

所以我們知道,strong_release 會減少該物件的強引用計數。在強引用計數到 0 的時候,物件被摧毀,且弱引用被置 nil。當強引用計數和 unowned 引用計數都到 0 的時候,物件記憶體被釋放(deallocated)。

結合我們構造出來的 ListNode 這個類分析一下:因為除了 ListNode 體內有一個指向 next 節點的強引用之外就沒有任何 unowned 引用了,我們不難得出:這句 SIL 意圖是摧毀(destroy)例項變數 next,但是隨後將引發例項變數 next 背後所指向的記憶體空間被釋放(deallocated)。

可是我們可以發現,這個 deinit 函式對應的 SIL 中並沒有釋放(deallocate)ListNode 例項的相關內容,我們不禁要問:是不是有一個函式會「包裹」住 deinit 然後專門用來釋放 ListNode?又或者釋放一個物件例項其實是由 Swift 執行時包辦的?

一個「驚喜」……

我們首先驗證第一個猜測:在我們生成的 SIL 內容中搜尋 $s9CallStack8ListNodeCfd 也就是 deinit 對應的過程,之後我們會發現 Swift 編譯器還生成了一個叫 $s9CallStack8ListNodeCfD 的過程,對應的 Swift 函式名根據 SIL 中的註釋應該是 ListNode.__deallocating_deinit。同時這個過程「包裹」了我們的 deinit 函式:

// ListNode.__deallocating_deinit
sil hidden @$s9CallStack8ListNodeCfD : $@convention(method) (@owned ListNode) -> () {
// %0                                             // users: %3, %1
bb0(%0 : $ListNode):
  debug_value %0 : $ListNode, let, name "self", argno 1 // id: %1
  // function_ref ListNode.deinit
  %2 = function_ref @$s9CallStack8ListNodeCfd : $@convention(method) (@guaranteed ListNode) -> @owned Builtin.NativeObject // user: %3
  %3 = apply %2(%0) : $@convention(method) (@guaranteed ListNode) -> @owned Builtin.NativeObject // user: %4
  %4 = unchecked_ref_cast %3 : $Builtin.NativeObject to $ListNode // user: %5
  dealloc_ref %4 : $ListNode                      // id: %5
  %6 = tuple ()                                   // user: %7
  return %6 : $()                                 // id: %7
} // end sil function '$s9CallStack8ListNodeCfD'
複製程式碼

所以我們第一個猜測是真的。

__deallocating_deinit 過程分析:SIL 視角

ListNode.__deallocating_deinit 函式的 SIL 中的主要內容如下:

bb0(%0 : $ListNode):
  %2 = function_ref @$s9CallStack8ListNodeCfd : $@convention(method) (@guaranteed ListNode) -> @owned Builtin.NativeObject // user: %3
  %3 = apply %2(%0) : $@convention(method) (@guaranteed ListNode) -> @owned Builtin.NativeObject // user: %4
  %4 = unchecked_ref_cast %3 : $Builtin.NativeObject to $ListNode // user: %5
  dealloc_ref %4 : $ListNode                      // id: %5
  ...
}
複製程式碼

這段 SIL 中的內容比較繁複,主要是將 bb0%0 傳遞給了過程 $s9CallStack8ListNodeCfd(也就是 ListNode.deinit 函式對應的 SIL 函式),再用 %3 接住上面這個函式的返回值後將返回值扮演(cast,我不知道這個字怎麼譯,自己想的譯法)成 ListNode 儲存到 %4 中,然後將 %4 作為實際引數傳遞給 SIL 指令 dealloc_ref

我們可以在這裡找到 dealloc_ref 的文件:

sil-instruction ::= 'dealloc_ref' ('[' 'stack' ']')? sil-operand

dealloc_ref [stack] %0 : $T
// $T must be a class type
複製程式碼

Deallocates an uninitialized class type instance, bypassing the reference counting mechanism.

The type of the operand must match the allocated type exactly, or else undefined behavior results.

The instance must have a retain count of one.

This does not destroy stored properties of the instance. The contents of stored properties must be fully uninitialized at the time dealloc_ref is applied.

The stack attribute indicates that the instruction is the balanced deallocation of its operand which must be a alloc_ref [stack]. In this case the instruction marks the end of the object's lifetime but has no other effect.

於是我們可以知道,dealloc_ref 這個 SIL 指令是用來釋放並且反初始化類例項的,然後這個過程會忽略引用計數,並且要求例項的引用計數為 1。同時,這個 SIL 指令不會摧毀(destroy)例項內部的 stored properties,stored properties 中的內容必須在執行 dealloc_ref 指令前就被清除掉(就是 ListNode.deinit 所幹的活了)。

可能很多人看不懂 sil-instruction ::= 'dealloc_ref' ('[' 'stack' ']')? sil-operand 之類的是什麼。我曾經在一本 90 年代在美國發行的有關編譯器構造的書上看到過這種表述,叫 EBNF,既 Extended Backus–Naur Form,是一種上下文無關語法(CFG)的表記方法。看不懂「上下文無關語法」的人可以理解為這是一種將程式語言所有語義上的有效性(比如函式傳參時型別必須匹配,變數賦值時型別必須匹配等)剝去之後剩下的語言結構。當然,這玩意兒可不止用在編譯器裡面,自然語言也可以應用。在編譯器相關課程中我們一般都會學習 BNF 表記法,而這個就是 BNF 的擴充套件版。其擴充套件出來的一些寫法可以使得原來用 BNF 表記的上下文無關語法(CFG)可以撰寫得更加簡潔。

這句 EBNF 表記用中文讀出來應該讀作:

SIL 指令 (sil-instruction) 可以推匯出 "dealloc_ref" "[" "stack" "]" SIL運算元 (sil-operand),其中 "[" "stack" "]" 是可選的部分。

如果我們對這句 EBNF 所描述的「上下文無關語法」規則可以接受的輸入進行舉例,那麼將有:

dealloc_ref [ stack ] %foo : $Bar

dealloc_ref %foo : $Bar
複製程式碼

如果使用 BNF 改寫,那麼我們就必須寫成這樣

sil-instruction ::= 'dealloc_ref' stack-operand-or-epsilon sil-operand
stack-operand-or-epsilon ::= 'stack'
                          |  ε
複製程式碼

是不是 EBNF 簡潔了很多呢?

你可能還會注意到 dealloc_ref 這個指令的文件提到了分配一個類例項的 SIL 指令 alloc_ref 以及一個叫 [stack] 的引數。根據上面我對 EBNF 的解釋,你不難得出其實 Swift 是支援在 stack 記憶體上分配類例項這點事實。但是我還沒空去研究如何在程式碼中實現這點,所以這裡不展開說明。

ListNode 例項釋放過程 SIL 總結

我們可以從上述節選的 SIL 內容推測出來 ListNode.__deallocating_deinit 這個函式是用來釋放(deallocate)heap 記憶體上的為物件分配的記憶體的,而這個過程中則呼叫了 ListNode.deinit。這個 ListNode.deinit 函式則負責對例項內部的成員進行摧毀(destroy)。當然,在這裡 ListNodenext 成員並沒有其他 unowned 引用或者強引用,於是摧毀(destroy)這個 next 例項成員的同時也會引發 next 節點指向的記憶體區域被釋放(deallocated)。顯然,上述過程將引發一個間接遞迴呼叫。

現在我們可以再次開啟 Hopper 找到 deinit 對應的過程。

deinit 對應過程

其中我們發現 swiftc 為 deinit 生成機器碼後這個過程實際上會呼叫一個叫 _$s9CallStack8ListNodeCSgWOh 的過程,於是我們找到它:

swift_release

是的,你會看到最終這個過程呼叫了 swift_release 這個函式,根據我們之前對 deinit 的 SIL 的研究,這個函式應該就是對應著 dealloc_ref 這個 SIL 指令,也應該就是這個過程最終行使了對 next 節點的釋放。

思考題:請猜測 Swift 為 ListNode 的成員摧毀過程單獨生成一個函式,並從 deinit 外聯(outline)到該函式的設計意圖。

我們可以從名字上猜測出這個函式的作用可能和 [NSObject -release] 或者 CFRelease 差不多,根據我們對引用計數的常識,其行為也應該就是進行引用計數到 0 之後將記憶體釋放這麼一個動作。但是這只是猜測,我們還要從原始碼角度對其進行驗證。

在這裡我們要說明的是,swift_release 是 Swift 執行時的一部分,由 Swift 標準庫提供。 Swift 標準庫在 Swift 程式被編譯的過程中將會被連結到目標檔案上。以下分析過程中我們都不能忘記我們是在考證 Swift 執行時的構成,而不是我們寫的關於 ListNode 這個程式的構成。

追蹤 swift_release

使用 git 克隆 Swift 原始碼,並將分支切換到 swift-5.0-branch

在 Swift 原始碼中搜尋 swift_release 我們可以在 include/swift/Runtime/HeapObject.h 找到下列程式碼:

namespace swift {

...

SWIFT_RUNTIME_EXPORT
void swift_release(HeapObject *object);

...

}
複製程式碼

在這裡我們可以看見 swift_release 是一個 swift 這個名稱空間下一個擁有 HeapObject * 型別形式引數的函式,但是我們還不能確定這就是我們要找的——因為這部分程式碼是由 C++ 實現的 Swift 的執行時程式碼,在 C++ 中開發者可以對函式名字改編(name mangling)規則進行選擇——既,是用 C 的規則改編還是用 C++ 的規則改編。

根據 C 的規則進行改編後,swift_release 應該叫 _swift_release;而根據 C++ 的規則改編後,抱歉,C++ 的改編規則太複雜,我也記不得……但是會和 _swift_release 差很遠。

而如果要讓一個 C++ 頭/原始檔中的函式或者變數名字使用 C 的名字改編規則進行名字改編,那麼就必須加上 extern "C" 這個字首。

為了求證這個函式就是我們要找的 swift_release 函式,我們需要找到 SWIFT_RUNTIME_EXPORT 這個巨集。我們可以在 stdlib/public/SwiftShims/Visibility.h 找到這個巨集的定義:

#if defined(__cplusplus)
#define SWIFT_RUNTIME_EXPORT extern "C" SWIFT_EXPORT_ATTRIBUTE
#else
#define SWIFT_RUNTIME_EXPORT SWIFT_EXPORT_ATTRIBUTE
#endif
複製程式碼

我們發現這是一套可以根據語言來做切換的巨集,其中如果是按照 C++ 來編譯(既被 C++ 原始檔 include)的話,SWIFT_RUNTIME_EXPORT 的內容就是extern "C" SWIFT_EXPORT_ATTRIBUTE,而如果按照 C 來編譯(既被 C 原始檔 include)的話就是 SWIFT_EXPORT_ATTRIBUTE。於是我們可以知道,這個在 swift 名稱空間下的 swift_release 函式在完成編譯後其二進位制符號並不會以 C++ 的方式進行改編,以 C 的方式進行改編。所以我們可以確定這就是我們要找的函式。

小議 swift_release 函式簽名

在這裡我們還有一個疑問:為什麼這個函式的形式引數是 HeapObject * 型別的?

很多人覺得,可能是因為 Swift 和 Objective-C 一樣有一個公共根類——只是說 Swift 沒有把這個公共根類暴露出來。

這樣說可以說對了一半,但是在這裡屬於答非所問了:確實,在後面我們可以看到:一旦 Swift 編譯過程中選擇了支援 Objective-C 執行時,那麼在 Swift 中除了 @objc 這種訊息轉發級別的保證 Swift 物件和 Objective-C 物件間互操作性(interoperability)的語言特性之外,每一個 Swift 類都會有一個對應的 Objective-C 類以保證 Swift 和 Objective-C 之間最基本的生命週期控制方面的互操作性,而所有 Swift 類的 Objective-C 對應類都繼承自一個公共根類就是 SwiftObject

至於「所有 Swift 類都有一個公共根類叫 HeapObject」,這就差得遠了。為什麼?

首先,C++ 中為了保證和 C 語言在「值語義」上的統一,其 structclass 並沒有什麼實質上的區別,在記憶體佈局上也是一模一樣。C++ 的創造者之所以保留了 struct 僅僅是為了和 C 相容。所以即使上述「所有 Swift 類都有一個公共根類HeapObject」的描述為真,那麼也應該改成「所有 Swift 類都有一個公共根型別HeapObject」.

「值語義」是什麼?

「值語義」的對義語是「引用語義」,用 Swift 的程式碼表示,他們的區別如下:

var a: Int = 4
b = a
複製程式碼

上述程式碼中 a 複製到 b 後再無關聯,這就是「值語義」的。

class Foo {
    var int: Int = 0
    init(int: Int) { self.int = int }
}

var a = Foo(int: 0)
b = a
b.int = 10 // a.int == 10; b.int == 10;
複製程式碼

上述程式碼中 a 複製到 b 後依然指向同一個物件,修改 b 的內容會導致 a 的內容同時產生變化,這就是「引用語義」的。

但是如果在 C++ 中我們書寫類似的程式碼:

class Foo {
    int value;
    Foo(int val): value(val) {}
}

auto a = Foo(0);
auto b = a;
b.value = 10; // a.int == 0; b.int == 10;
複製程式碼

那麼 avalue 將不會隨 bvalue 被賦值而一起被改變。

而一般,我們在 C++ 中都會這樣:

class Foo {
    int value;
    Foo(int val): value(val) {}
}

auto a = new Foo(0);
auto b = a;
b -> value = 10; // a -> int == 10; b -> int == 10;
複製程式碼

我們可以看到,我們將 Foo(0) 改成了 new Foo(0),然後也將 b.value = 10; 改成了 b -> value = 10;,實際上這是將 Foo 的例項分配到了 heap 記憶體上,最後返回了一個指標。這樣以來,我們就可以達到和上述 Swift 程式碼一樣的效果了。但是這仍然不是「引用語義」的——因為 new Foo(0) 返回的是一個指向 Foo 例項的指標而不是 Foo 本身,另外操作符 -> 是一個對指標起作用的操作符,上述程式碼是圍繞指標展開的而非型別本身。所以這種用指標達到「引用語義」效果的做法並不代表擁有指標的程式語言其本身是包含「引用語義」的。(當然,在最新的 C++ 實踐中我們應當使用智慧指標,特別的,在這裡我們應當使用 std::shared_ptr,但是這並不妨礙我們解釋 C++ 和 C 在「值語義」上的統一性。)

思考題:在明白了「值語義」和「引用語義」的區別後,我再問你在 Swift 中什麼時候使用 struct,什麼時候使用 class,你還會給出網上那些所謂的「標準答案」嗎?

其次,我們可以從 C++ 物件模型(既記憶體佈局)的角度來討論——因為如果一個 Swift 類要以一個 C++ 型別為根型別的話,那麼 Swift 物件和 C++ 的物件至少在記憶體佈局上要是一致的。

我們在 include/swift/SwiftShims/HeapObject.h 中找到 HeapObject 型別的定義:

#define SWIFT_HEAPOBJECT_NON_OBJC_MEMBERS       \
  InlineRefCounts refCounts

/// The Swift heap-object header.
/// This must match RefCountedStructTy in IRGen.
struct HeapObject {
  /// This is always a valid pointer to a metadata object.
  HeapMetadata const *metadata;

  SWIFT_HEAPOBJECT_NON_OBJC_MEMBERS;

#ifdef __cplusplus
  HeapObject() = default;

  // Initialize a HeapObject header as appropriate for a newly-allocated object.
  constexpr HeapObject(HeapMetadata const *newMetadata) 
    : metadata(newMetadata)
    , refCounts(InlineRefCounts::Initialized)
  { }

#ifndef NDEBUG
  void dump() const LLVM_ATTRIBUTE_USED;
#endif

#endif // __cplusplus
}
複製程式碼

可以發現這是一個 struct,其中包含一個資料成員 metadata 和一個巨集 SWIFT_HEAPOBJECT_NON_OBJC_MEMBERS

我們將巨集 SWIFT_HEAPOBJECT_NON_OBJC_MEMBERS 展開後可以得到 InlineRefCounts refCounts,而我們又可以在 include/swift/SwiftShims/RefCount.h 中找到 InlineRefCounts 的定義:

// This definition is a placeholder for importing into Swift.
// It provides size and alignment but cannot be manipulated safely there.
typedef struct {
  __swift_uintptr_t refCounts SWIFT_ATTRIBUTE_UNAVAILABLE;
} InlineRefCountsPlaceholder;

#if !defined(__cplusplus)

typedef InlineRefCountsPlaceholder InlineRefCounts;

#else

...

typedef RefCounts<InlineRefCountBits> InlineRefCounts;

...

#endif
複製程式碼

我們發現這個型別在 C 和 C++ 中看起來會不一樣:

  • 在 C 中這個型別你是無法對其進行操作的
  • 在 C++ 中這個型別是 RefCounts<InlineRefCountBits>

而最終我們可以在 stdlib/public/SwiftShims/RefCount.h 中找到 RefCountsInlineRefCountBits 的定義:

enum RefCountInlinedness { RefCountNotInline = false, RefCountIsInline = true };

...

typedef RefCountBitsT<RefCountIsInline> InlineRefCountBits;

...

template <typename RefCountBits>
class RefCounts {
  std::atomic<RefCountBits> refCounts;
  
  ...
}
複製程式碼

我們繼續可以在同一個檔案內找到 RefCountBitsT 的定義:


// Basic encoding of refcount and flag data into the object's header.
template <RefCountInlinedness refcountIsInline>
class RefCountBitsT {
  ...

  BitsType bits;
  
  ...
}
複製程式碼

所以我們可以知道,HeapObject 最終看起來是這樣子的:

struct HeapObject {
  HeapMetadata const *metadata;

  RefCounts<RefCountBitsT<true>> refCounts;

#ifdef __cplusplus
  HeapObject() = default;

  // Initialize a HeapObject header as appropriate for a newly-allocated object.
  constexpr HeapObject(HeapMetadata const *newMetadata) 
    : metadata(newMetadata)
    , refCounts(InlineRefCounts::Initialized)
  { }

#ifndef NDEBUG
  void dump() const LLVM_ATTRIBUTE_USED;
#endif

#endif // __cplusplus
}
複製程式碼

struct(包括 class)在 C++ 中存在兩種記憶體佈局:

  • 一種是和 C struct 一樣的記憶體佈局。

    這種佈局保證了和 C API 的互操作性。

  • 一種是擁有 C++ 執行時特性的記憶體佈局。

    這種佈局不能保證和 C API 的互操作性,但是擁有 C++ 特有的虛擬函式、值語義相關的建構函式、析溝函式、拷貝建構函式和賦值函式這些執行時特性。

所以說,如果 Swift 類都以一個 C++ 型別作為根型別,那麼:

  • 要麼 Swift 類的例項會和 C struct 是一樣的佈局;
  • 要麼 Swift 類的例項會和擁有 C++ 特性的 C++ 型別例項是一樣的記憶體佈局。

很明顯,一個 Swift 類是擁有繼承能力的,而繼承之後的類如果沒有被標記為 final 或者其成員函式沒有被標記為 final 的話,那麼將需要使用類似 C++ 中 vtable 的技術來對繼承後複寫(override,自己譯的)了的函式進行訊息轉發,所以 Swift 類的例項不可能和 C struct 是一樣的佈局。

這樣以來,如果我們要證明或者證否 Swift 類都擁有一個「隱性」的 C++ 根型別——既 HeapObject 的話,僅僅需要查證 HeapObject 這個型別本身是不是一個擁有 C++ 特性的 C++ 型別就可以了。

我們可以看到,上述 HeapObject 的原始碼有如下巨集:

#ifdef __cplusplus
  ...
#endif // __cplusplus
複製程式碼

這是一段典型的判定引用該標頭檔案的到底是 C 還是 C++ 原始檔的巨集,如果是 C++ 的原始檔引用了該標頭檔案,那麼這個巨集內包裹的內容將生效,如果是 C,那麼將無效。

同時這段巨集內的內容:

  HeapObject() = default;

  // Initialize a HeapObject header as appropriate for a newly-allocated object.
  constexpr HeapObject(HeapMetadata const *newMetadata) 
    : metadata(newMetadata)
    , refCounts(InlineRefCounts::Initialized)
  { }

#ifndef NDEBUG
  void dump() const LLVM_ATTRIBUTE_USED;
#endif
複製程式碼

其目的是定義一個預設的預設建構函式(比較拗口)、一個以常量表示式(constexpr)修飾的建構函式以及一個 debug 用的成員函式。在這些 C++ 的「私貨」中:

  • 我們可以看到 HeapObject 的建構函式 constexpr HeapObject(HeapMetadata const *newMetadata) 是一個 constexpr 函式,C++ 將在編譯時就對這個函式進行求值,然後將求值之後的結果寫入編譯產物;

    HeapObject 的預設建構函式 HeapObject() = default; 是預設的。

    所以這些建構函式都是 trivial 的。Trivial 是一個借鑑自數學的概念,在 C/C++ 中指可以通過簡單的賦值完成的動作。這些動作並不會導致 HeapObject 異化成一個僅僅相容 C++ 的型別——因為這些建構函式並不需要使用到 C++ 的執行時特性;

  • 新加的成員函式 void dump() const 是非虛(non-virtual)函式。

    這相當於新增一個全域性函式 void HeapObjectDump(HeapObject * this),且不會導致 HeapObject 異化成一個只能在 C++ 中使用的型別——因為非虛擬函式也不需要使用到 C++ 執行時特性。

從以上這些看,HeapObject 是考慮了與 C 的相容的——也就是說 HeapObject 採取的應該是和 C struct 一樣的佈局。現在我們可以給出 HeapObject 例項的記憶體佈局:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

而一個使用 C struct 佈局的 C++ 型別怎麼可能會是所有 Swift 類的根型別呢?

所以我們知道「所有 Swift 類都有一個公共根型別HeapObject」這點應該是不成立的。

那麼到底為什麼 swift_release 的形式引數會是 HeapObject * 型別的?

實際上,後面我們會看到,swift_release 的形式引數為 HeapObject * 型別的在這裡是一種程式設計技巧,目的是為 HeapObject * 指向的記憶體區域提供一個可以在 C/C++ 中訪問的途徑。

繼續探索 swift_release

好的。現在我們繼續探索 swift_release。因為 swift_release 是一個 swift 名稱空間下的函式,我們可以嘗試搜尋 void swift::swift_release( 以找到這個函式的實現檔案。在 stdlib/public/Runtime/HeapObject.cpp 中我們可以發現如下內容:

...

void swift::swift_release(HeapObject *object) {
  _swift_release(object);
}

static void _swift_release_(HeapObject *object) {
  SWIFT_RT_TRACK_INVOCATION(object, swift_release);
  if (isValidPointerForNativeRetain(object))
    object->refCounts.decrementAndMaybeDeinit(1);
}

auto swift::_swift_release = _swift_release_;

...
複製程式碼

我們可以看到 swift_release 這個函式最後跳轉到了 _swift_release_ 這個函式內,而 _swift_release_ 在進行了引用計數追蹤(SWIFT_RT_TRACK_INVOCATION)和原生 Swift 物件的檢查(isValidPointerForNativeRetain)後呼叫了 decrementAndMaybeDeinit 這個函式。

再搜尋 decrementAndMaybeDeinit 找到其標頭檔案 stdlib/public/SwiftShims/Refcount.h,然後我們可以發現如下內容:

enum PerformDeinit { DontPerformDeinit = false, DoPerformDeinit = true };

...

template <typename RefCountBits>
class RefCounts {
  std::atomic<RefCountBits> refCounts;

  ...

  LLVM_ATTRIBUTE_ALWAYS_INLINE
  void decrementAndMaybeDeinit(uint32_t dec) {
    doDecrement<DoPerformDeinit>(dec);
  }

  ...
}
複製程式碼

我們看到 decrementAndMaybeDeinit 呼叫了 doDecrement 這個模板函式並且將 true 傳入了第一個模板引數。於是我們可以在同一個檔案找到這個模板函式:

template <typename RefCountBits>
class RefCounts {
  ...
  
  template <PerformDeinit performDeinit>
  bool doDecrement(uint32_t dec) {
    auto oldbits = refCounts.load(SWIFT_MEMORY_ORDER_CONSUME);
    RefCountBits newbits;
    
    do {
      newbits = oldbits;
      bool fast =
        newbits.decrementStrongExtraRefCount(dec);
      if (!fast)
        // Slow paths include side table; deinit; underflow
        return doDecrementSlow<performDeinit>(oldbits, dec);
    } while (!refCounts.compare_exchange_weak(oldbits, newbits,
                                              std::memory_order_release,
                                              std::memory_order_relaxed));

    return false;  // don't deinit
  }

  ...
}
複製程式碼

這個函式中出現了我們很喜歡討論的 lock free 程式設計技巧——CAS(compare and swap),但是我們在這裡並不關心這個。我們關心的是在 do...while 迴圈體內 if(!fast) 這個條件分支下的程式碼:整體而言,這整個函式負責減少物件的引用計數,並且在適當的時候(無法套用 fast path,既包含 side table、會導致 deinit 以及引用計數小於 0)會呼叫模板函式 doDecrementSlow

我們在上述原始碼中看到了一個概念叫 underflow。根據我對 Swift 原始碼和文件的研讀,underflow 在 Swift 中至少對應三個意義,而這三個意義其實都可以看作是 overflow 在相應語境下的對義語:

  1. 數值向下越界:你宣告瞭一個值為 90 的無符號整數,卻減去了 91;
  2. 緩衝區向後(低地址空間)越界:你宣告瞭一個容量為 10 個元素的 buffer,卻嘗試訪問第 -1 個位置的元素;
  3. 引用計數向下越界,或者說引用計數小於 0;

我們接著可以在同一個檔案內找到 doDecrementSlow 這個模板函式:

template <typename RefCountBits>
class RefCounts {
  ...

  template <PerformDeinit performDeinit>
  bool doDecrementSlow(RefCountBits oldbits, uint32_t dec) {
    RefCountBits newbits;
    
    bool deinitNow;
    do {
      newbits = oldbits;
      
      bool fast =
        newbits.decrementStrongExtraRefCount(dec);
      if (fast) {
        // Decrement completed normally. New refcount is not zero.
        deinitNow = false;
      }
      else if (oldbits.hasSideTable()) {
        // Decrement failed because we're on some other slow path.
        return doDecrementSideTable<performDeinit>(oldbits, dec);
      }
      else {
        // Decrement underflowed. Begin deinit.
        // LIVE -> DEINITING
        deinitNow = true;
        assert(!oldbits.getIsDeiniting());  // FIXME: make this an error?
        newbits = oldbits;  // Undo failed decrement of newbits.
        newbits.setStrongExtraRefCount(0);
        newbits.setIsDeiniting(true);
      }
    } while (!refCounts.compare_exchange_weak(oldbits, newbits,
                                              std::memory_order_release,
                                              std::memory_order_relaxed));
    if (performDeinit && deinitNow) {
      std::atomic_thread_fence(std::memory_order_acquire);
      _swift_release_dealloc(getHeapObject());
    }

    return deinitNow;
  }
  
  ...
}
複製程式碼

這個函式在確認要執行 deinit 之後將執行 _swift_release_dealloc(getHeapObject()); 來對 heap 上為物件分配的記憶體進行釋放,而之前我們已經在 Debug Navigator 的呼叫棧中看到了 _swift_release_dealloc

但是這個 _swift_release_dealloc 裡面又做了什麼呢?

_swift_release_dealloc 初探

我們繼續搜尋 void _swift_release_dealloc( 找到其實現檔案 include/swift/Runtime/HeapObject.h,內容有下:

void _swift_release_dealloc(HeapObject *object) {
  asFullMetadata(object->metadata)->destroy(object);
}
複製程式碼

我們可以看見,這個函式將 HeapObject * 指標指向的例項中的 metadata 成員傳遞給了 asFullMetadata 函式,然後又呼叫了返回值上一個叫 destroy 的看起來像是一個「函式」的成員。

現在我們來考察asFullMetadata 這個模板函式。我們可以搜尋原始碼,在 include/Swift/ABI/Metadata.h 中找到相關內容:

...

/// Given a canonical metadata pointer, produce the adjusted metadata pointer.
template <class T>
static inline FullMetadata<T> *asFullMetadata(T *metadata) {
  return (FullMetadata<T>*) (((typename T::HeaderType*) metadata) - 1);
}

...
複製程式碼

我們可以看到,asFullMetadata 實際上是一個模板函式。在這個模板函式中,其實際上的動作是:

  1. metadata 扮演(cast)成 T::HeaderType * 型別;
  2. 然後再向後(或者說低地址空間)位移了一個 T::HeaderType 的長度;
  3. 最後再扮演(cast)成 FullMetadata<T> * 型別返回。

所以如果我們要了解 asFullMetadata(object->metadata)->destroy(object); 這句到底做了什麼,我們就必須要了解:

  1. metadata 的型別下的 HeaderType 是什麼?
  2. metadata 經過位移後,最後扮演(cast)成的型別 FullMetadata<T> * 是什麼?
  3. FullMetadata<T> 的成員 destroy 是什麼?
  4. metadata 向低地址空間位移一個 T::HeaderType 長度的位置存放的是什麼?

1. metadata 的型別下的 HeaderType 是什麼?

我們已經從前文中 HeapObject 的標頭檔案中得知 metadata 的型別是 HeapMetadata,而在 include/swift/Runtime/HeapObject.h 中我們又可以找到:

struct InProcess;

template <typename Target> struct TargetHeapMetadata;
using HeapMetadata = TargetHeapMetadata<InProcess>;
複製程式碼

所以我們可以看到 HeapMetadata 的真身其實是 TargetHeapMetadata<InProcess>

我們現在要接著查證 TargetHeapMetadata 的內容。我們可以搜尋 struct TargetHeapMetadatainclude/swift/ABI/Metadata.h 中找到如下內容:

template <typename Runtime>
struct TargetHeapMetadata : TargetMetadata<Runtime> {
  using HeaderType = TargetHeapMetadataHeader<Runtime>;

  TargetHeapMetadata() = default;
  constexpr TargetHeapMetadata(MetadataKind kind)
    : TargetMetadata<Runtime>(kind) {}
#if SWIFT_OBJC_INTEROP
  constexpr TargetHeapMetadata(TargetAnyClassMetadata<Runtime> *isa)
    : TargetMetadata<Runtime>(isa) {}
#endif
};
複製程式碼

於是我們知道了 metadata 型別下的 HeaderType 就是 TargetHeapMetadataHeader<InProcess>。其定義也可以在 include/swift/ABI/Metadata.h 中被找到:

template <typename Runtime>
struct TargetHeapMetadataHeader
    : TargetHeapMetadataHeaderPrefix<Runtime>,
      TargetTypeMetadataHeader<Runtime> {
  constexpr TargetHeapMetadataHeader(
      const TargetHeapMetadataHeaderPrefix<Runtime> &heapPrefix,
      const TargetTypeMetadataHeader<Runtime> &typePrefix)
    : TargetHeapMetadataHeaderPrefix<Runtime>(heapPrefix),
      TargetTypeMetadataHeader<Runtime>(typePrefix) {}
};
複製程式碼

2. metadata 經過位移後,最後扮演(cast)成的型別 FullMetadata * 是什麼?

我們同樣可以在 include/swift/ABI/Metadata.h 中找到如下內容:

template <class T> struct FullMetadata : T::HeaderType, T {
  typedef typename T::HeaderType HeaderType;

  FullMetadata() = default;
  constexpr FullMetadata(const HeaderType &header, const T &metadata)
    : HeaderType(header), T(metadata) {}
};
複製程式碼

所以我們可以知道:metadata 經過位移後,最終將會被扮演(cast)成 FullMetadata<HeapMetadata> *

根據 FullMetadata<T> 的定義,FullMetadata<T> 被特化成 FullMetadata<HeapMetadata> 之後將繼承 TargetHeapMetadataHeader<InProcess>TargetHeapMetadata<InProcess>

又根據我們之前的考證:

  • 因為 FullMetadata 的第一繼承目標 TargetHeapMetadataHeader 本身沒有資料成員,要考證其資料成員,我們僅需要展開 TargetHeapMetadataHeader 的繼承目標 TargetHeapMetadataHeaderPrefixTargetTypeMetadataHeader,進而就可以知道 FullMetadata 的一部分資料成員。

    我們可以在 include/swift/ABI/Metadata.h 中找到上述兩個型別的定義:

    template <typename Runtime>
    struct TargetTypeMetadataHeader {
      /// A pointer to the value-witnesses for this type.  This is only
      /// present for type metadata.
      TargetPointer<Runtime, const ValueWitnessTable> ValueWitnesses;
    };
    
    ...
    
    template <typename Runtime>
    struct TargetHeapMetadataHeaderPrefix {
      /// Destroy the object, returning the allocated size of the object
      /// or 0 if the object shouldn't be deallocated.
      TargetPointer<Runtime, HeapObjectDestroyer> destroy;
    };
    複製程式碼

    我們不難得出 FullMetadata<HeapMetadata> 下第一個資料成員是 :

    TargetPointer<Runtime, HeapObjectDestroyer> destroy;
    複製程式碼

    第二個資料成員是:

    TargetPointer<Runtime, const ValueWitnessTable> ValueWitnesses;
    複製程式碼
  • FullMetadata 的第二繼承目標 TargetHeapMetadata 本身也沒有資料成員,要考證其資料成員,我們僅需要展開 TargetHeapMetadata 的繼承目標 TargetMetadata,進而就可以知道 FullMetadata 剩下的資料成員。

    我們可以在 include/swift/ABI/Metadata.h 中找到上述這個型別的定義:

    template <typename Runtime>
    struct TargetMetadata {
      using StoredPointer = typename Runtime::StoredPointer;
      
      ...
      
    private:
      /// The kind. Only valid for non-class metadata; getKind() must be used to get
      /// the kind value.
      StoredPointer Kind;
      
      ...
    }
    複製程式碼

    我們不難得出 FullMetadata<HeapMetadata> 的第三個資料成員是:

    StoredPointer Kind;
    複製程式碼

那麼 TargetPointerStoredPointer 又是什麼呢?

include/swift/ABI/Metadata.h 中我們可以找到 TargetPointer 的定義:

template <typename Runtime, typename T>
using TargetPointer = typename Runtime::template Pointer<T>;
複製程式碼

我們看到 TargetPointer 最後實際上會使用模板引數 Runtime 內的 Poitner 這個型別。所以「尋找 TargetPointer 的定義」這個任務現在轉化為了尋找模板引數 Runtime 的特化目標型別內的 Poitner 這個型別的定義。

而我們在 InProcess——也就是上述型別的模板引數 Runtime 的特化目標中就可以找到 StoredPointerPointer 的定義:

struct InProcess {
  using StoredPointer = uintptr_t;
  
  ...
  
  template <typename T>
  using Pointer = T*;
  
  ...
}
複製程式碼

我們將上述 PointerStoredPointer 定義代入之後,可以得到如下結構:

struct FullMetadata<HeapMetadata> {
  HeapObjectDestroyer * destroy;
  const ValueWitnessTable * ValueWitnesses;
  uintptr_t Kind;
}
複製程式碼

最後在 include/swift/ABI/Metadata.h 中找到 HeapObjectDestroyer 的定義:

using HeapObjectDestroyer =
  SWIFT_CC(swift) void(SWIFT_CONTEXT HeapObject *);
複製程式碼

我們可以看到 destroy 其實就是一個指向 SWIFT_CC(swift) void (*)(SWIFT_CONTEXT HeapObject *) 函式指標的一個成員。其中,SWIFT_CCSWIFT_CONTEXT 這兩個巨集的內容在 include/swift/Runtime/Config.h 內,為編譯器提供 Swift 專屬的呼叫規制(calling convention,我也不知道這個字怎麼譯,自己想的譯法)方面的識別符號。所以最終這個函式指標會指向一個 Swift 函式。

HeapObjectDestroyer 的定義代入 FullMetadata<HeapMetadata> 後我們不難得出下圖:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

3. FullMetadata 的成員 destroy 是什麼?

如上圖所示:destroy 是一個指向 void (*)(HeapObject *) 函式指標的一個成員,而這個函式是一個 Swift 函式。

4. 由 metadata 向低地址空間位移一個 T::HeaderType 長度的位置存放的是什麼?

我們可以看到,metadata 首先被扮演成了 T::HeaderType *。在這裡我們代入特化後的結果既是 HeapMetadata::HeaderType。然後從上圖得知,HeapMetadata::HeaderType 的長度是兩個指標的長度,那麼 metadata 向低地址空間位移一個 HeapMetadata::HeaderType 長度後實際上會跑到 destroy 這個成員的地址上。最後我們將指標扮演成 FullMetadata<HeapMetadata> *,那麼我們將以 FullMetadata<HeapMetadata> 來觀察這個指標背後的內容。

在這裡我可以畫圖來進行直觀的說明:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

同時,我們也證明了 swift_release 的形式引數是 HeapObject * 在這裡只是一種程式設計技巧——其為該指標背後所指向的記憶體提供一個在 C/C++ 中訪問的途徑。

但是 HeapObject 中的 metadata 又是怎麼來的?metadata 指向的記憶體空間後兩個指標長度為什麼會是這個類的 destroy 函式?從邏輯上說,要回答這個問題,更好的方法是考察 ListNode 例項的分配過程,而不是釋放過程。

追蹤 ListNode 例項的記憶體分配過程

我們再次看到我們之前生成的 SIL 中的一部分內容:

// ListNode.init(_:)
sil hidden @$s9CallStack8ListNodeCyACSicfc : $@convention(method) (Int, @owned ListNode) -> @owned ListNode {
  ...
} // end sil function '$s9CallStack8ListNodeCyACSicfc'
複製程式碼

是的,我們第一時間會想到要在 init(_:) 函式對應的 SIL 函式中去尋找線索。但是很可惜,這裡面沒有和 metadata 相關的內容。但是當我們搜尋init(_:) 函式在 SIL 中對應的名字:$s9CallStack8ListNodeCyACSicfc 時,我們會發現有一個叫 $s9CallStack8ListNodeCyACSicfC 的 SIL 函式(對應 Swift 函式名:ListNode.__allocating_init(_:))呼叫了 init(_:) 函式。

// ListNode.__allocating_init(_:)
sil hidden @$s9CallStack8ListNodeCyACSicfC : $@convention(method) (Int, @thick ListNode.Type) -> @owned ListNode {
// %0                                             // user: %4
bb0(%0 : $Int, %1 : $@thick ListNode.Type):
  %2 = alloc_ref $ListNode                        // user: %4
  // function_ref ListNode.init(_:)
  %3 = function_ref @$s9CallStack8ListNodeCyACSicfc : $@convention(method) (Int, @owned ListNode) -> @owned ListNode // user: %4
  %4 = apply %3(%0, %2) : $@convention(method) (Int, @owned ListNode) -> @owned ListNode // user: %5
  return %4 : $ListNode                           // id: %5
} // end sil function '$s9CallStack8ListNodeCyACSicfC'
複製程式碼

注意到上述程式碼的第 5 行,我們不難推斷出: ListNode.__allocating_init(_:) 這個函式應該是使用了 alloc_ref 這條 SIL 指令來完成對 ListNode 例項的記憶體分配工作的。但是在這裡,整段程式碼依然沒有 metadata 的任何線索。

調查 __allocating_init:LLVM IR 視角

這時候,我們不妨將視野降低一個層級:我們直接考察 LLVM IR,看看裡面會有什麼內容。首先使用 swiftc -emit-ir CallStack.swift > CallStack.swift.ll 來生成 CallStack.swift 的 LLVM IR。我們可以通過搜尋 $s9CallStack8ListNodeCyACSicfC 在第 232 行找到 ListNode.__allocating_init(_:) 函式在 LLVM IR 中對應的函式:

define hidden swiftcc %T9CallStack8ListNodeC* @"$s9CallStack8ListNodeCyACSicfC"(i64, %swift.type* swiftself) #0 {
entry:
  %2 = call swiftcc %swift.metadata_response @"$s9CallStack8ListNodeCMa"(i64 0) #7
  %3 = extractvalue %swift.metadata_response %2, 0
  %4 = call noalias %swift.refcounted* @swift_allocObject(%swift.type* %3, i64 32, i64 7) #2
  %5 = bitcast %swift.refcounted* %4 to %T9CallStack8ListNodeC*
  %6 = call swiftcc %T9CallStack8ListNodeC* @"$s9CallStack8ListNodeCyACSicfc"(i64 %0, %T9CallStack8ListNodeC* swiftself %5)
  ret %T9CallStack8ListNodeC* %6
}
複製程式碼

我們可以在這個 LLVM IR 函式中清晰地看到 metadata 這個字。那麼我們來分析一下這段 LLVM IR 函式:

在這裡首先要普及一下 LLVM IR 中的一些基本知識:

  • LLVM IR 是一種擁有型別系統的編譯器中間表述
  • % 開頭的是本地變數
  • @ 開頭的是全域性變數

首先第一句:

%2 = call swiftcc %swift.metadata_response @"$s9CallStack8ListNodeCMa"(i64 0)
複製程式碼

通過閱讀 call 這條 LLVM IR 指令的文件,我們不難得知這句的意思是通過使用 Swift 的呼叫規制(calling convention)來呼叫 LLVM 函式 $s9CallStack8ListNodeCMa,並且傳入一個值為 0i64 型別(不難猜測就是 64 位整型),在獲得了一個型別為 %swift.metadata_response 的返回值後再賦值給 %2

那麼問題來了:$s9CallStack8ListNodeCMa 又是什麼?實際上,如果你在 profiling 的時候足夠仔細,你可能會發現分配 Swift 物件例項的呼叫棧裡面會出現 type metadata accessor 這種記錄(抱歉我作弊了)。在後面的探索中,我們也將不難知道:$s9CallStack8ListNodeCMa 就是 type metadata accessor。

我們可以在我們匯出的這份 LLVM IR 的頭部找到 %swift.metadata_response 的定義:

%swift.type = type { i64 }
%swift.metadata_response = type { %swift.type*, i64 }
複製程式碼

其中,type 關鍵字是 LLVM IR 中自定義 struct 型別的關鍵字。%swift.type 就是一個成員只有一個 64 位整型值的 struct,我們可以從後面的探索得知,這就是一個 Swift 型別對應的 Objective-C meta-class。%swift.type* 則代表一個指向 %swift.type 的指標。於是型別 %swift.metadata_response 改寫成 C 語言程式碼實際上是:

struct {
    struct {
        int64_t metaclass;
    } SwiftType;
    struct SwiftType * swiftType;
    int64_t foo;
} MetadataResponse;
複製程式碼

然後第二句:

%3 = extractvalue %swift.metadata_response %2, 0
複製程式碼

通過閱讀 extractvalue 這條 LLVM IR 指令的文件,我們不難得知這句的意思就是以 %swift.metadata_response 型別的結構觀察本地變數 %2,將其中的第 0 個元素取出。於是我們會實際得到一個 %swift.type * 型別的值,然後賦值給 %3

接著第三句:

%4 = call noalias %swift.refcounted* @swift_allocObject(%swift.type* %3, i64 32, i64 7) #2
複製程式碼

這句的意思是呼叫 @swift_allocObject 函式,將本地變數 %3 的內容以 %swift.type* 的型別傳入第一個引數,將 32i64 的型別傳入第二個引數,將 7i64 的型別傳入第三個引數,最後將返回值以 %swift.refcounted* 型別賦值給本地變數 %4

你可能不知道 noalias 是什麼意思。其實理不理解 noalias 在這裡沒有什麼影響,noalias 在修飾返回值時表示:@swift_allocObject 函式的返回值不會在該函式的執行過程中通過該函式的引數這個途徑被訪問。

接著第四句:

%5 = bitcast %swift.refcounted* %4 to %T9CallStack8ListNodeC*
複製程式碼

這表示,將本地變數 %4 中的內容從 %swift.refcounted* 型別扮演(cast)成 %T9CallStack8ListNodeC* 型別(用 Swift 來表述就是:CallStack 模組內的 ListNode 這個類的引用),然後賦值給本地變數 %5

接著第五句:

%6 = call swiftcc %T9CallStack8ListNodeC* @"$s9CallStack8ListNodeCyACSicfc"(i64 %0, %T9CallStack8ListNodeC* swiftself %5)
複製程式碼

這句的意思是以 Swift 的呼叫規制呼叫 @"$s9CallStack8ListNodeCyACSicfc" 這個函式(也就是 ListNode.init(_:)),將 0i64 型別傳入第一個引數,將本地變數 %5 中的內容以 %T9CallStack8ListNodeC* 的型別(也就是 ListNode 的引用)傳入第二個引數,並且給出第二個引數是 self 引數(swiftself)的提示,然後將返回值以 %T9CallStack8ListNodeC*ListNode 的引用)型別賦值給本地變數 %6

最後第六句:

ret %T9CallStack8ListNodeC* %6
複製程式碼

將本地變數 %6 中的內容以 %T9CallStack8ListNodeC*ListNode 的引用)型別返回。

綜上,我們可以將以上 LLVM IR 改寫成以下 C-like 的語言:

typealias %swift.type * SwiftTypeRef;
typealias %swift.refcounted * SwiftRefCountedRef;
typealias %swift.metadata_response MetadataResponse;
#define ListNodeTypeMetadataAccessor $s9CallStack8ListNodeCMa

ListNode * ListNode__allocating_init(int64_t arg1, SwiftTypeRef self) {
    MetadataResponse %2 = ListNodeTypeMetadataAccessor(0);
    SwiftTypeRef %3 = %2.swiftType;
    SwiftRefCountedRef %4 = swift_allocObject(%3, 32, 7);
    ListNode * %5 = (ListNode *) %4;
    ListNode * %6 = ListNodeInit(0, %5);
    return %6;
}
複製程式碼

於是我們可以看到,ListNode.__allocating_init(_:) 確實通過呼叫 type metadata accessor(既$s9CallStack8ListNodeCMa 這個函式)訪問了 ListNode 的 metadata,並且以此分配了 ListNode 例項的記憶體空間。

調查 Type Metadata Accessor:LLVM IR 視角

接下來我們再來考察 ListNode 的 type metadata accessor(也就是 $s9CallStack8ListNodeCMa 這個函式),看看 Swift 到底是如何在執行時獲取一個 class 型別的 metadata 的。搜尋 $s9CallStack8ListNodeCMa 我們可以在第 242 行找到這個函式的定義:

; Function Attrs: nounwind readnone
define hidden swiftcc %swift.metadata_response @"$s9CallStack8ListNodeCMa"(i64) #4 {
entry:
  %1 = load %swift.type*, %swift.type** @"$s9CallStack8ListNodeCML", align 8
  %2 = icmp eq %swift.type* %1, null
  br i1 %2, label %cacheIsNull, label %cont

cacheIsNull:                                      ; preds = %entry
  %3 = call %objc_class* @swift_getInitializedObjCClass(%objc_class* bitcast (i64* getelementptr inbounds (<{ void (%T9CallStack8ListNodeC*)*, i8**, i64, %objc_class*, %swift.opaque*, %swift.opaque*, i64, i32, i32, i32, i16, i16, i32, i32, <{ i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor }>*, i8*, i64, i64, i64 (%T9CallStack8ListNodeC*)*, void (i64, %T9CallStack8ListNodeC*)*, { i8*, %TSi* } (i8*, %T9CallStack8ListNodeC*)*, i64 (%T9CallStack8ListNodeC*)*, void (i64, %T9CallStack8ListNodeC*)*, { i8*, %T9CallStack8ListNodeCSg* } (i8*, %T9CallStack8ListNodeC*)*, %T9CallStack8ListNodeC* (i64, %swift.type*)* }>, <{ void (%T9CallStack8ListNodeC*)*, i8**, i64, %objc_class*, %swift.opaque*, %swift.opaque*, i64, i32, i32, i32, i16, i16, i32, i32, <{ i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor }>*, i8*, i64, i64, i64 (%T9CallStack8ListNodeC*)*, void (i64, %T9CallStack8ListNodeC*)*, { i8*, %TSi* } (i8*, %T9CallStack8ListNodeC*)*, i64 (%T9CallStack8ListNodeC*)*, void (i64, %T9CallStack8ListNodeC*)*, { i8*, %T9CallStack8ListNodeCSg* } (i8*, %T9CallStack8ListNodeC*)*, %T9CallStack8ListNodeC* (i64, %swift.type*)* }>* @"$s9CallStack8ListNodeCMf", i32 0, i32 2) to %objc_class*))
  %4 = bitcast %objc_class* %3 to %swift.type*
  store atomic %swift.type* %4, %swift.type** @"$s9CallStack8ListNodeCML" release, align 8
  br label %cont

cont:                                             ; preds = %cacheIsNull, %entry
  %5 = phi %swift.type* [ %1, %entry ], [ %4, %cacheIsNull ]
  %6 = insertvalue %swift.metadata_response undef, %swift.type* %5, 0
  %7 = insertvalue %swift.metadata_response %6, i64 0, 1
  ret %swift.metadata_response %7
}
複製程式碼

這個函式牽扯到 SSA Form phi nodes 的還原,不好做句讀,有興趣可以直接在這裡獲取免費正版的 SSA Book 學習一下 SSA Form 相關的知識。這裡我直接展示其改寫成 C-like 虛擬碼後的樣子:

typealias %swift.type * SwiftTypeRef;
typealias %swift.metadata_response MetadataResponse;
#define ListNodeTypeMetadataAccessor $s9CallStack8ListNodeCMa
#define listNodeMetadataRecord $s9CallStack8ListNodeCMf
static SwiftTypeRef * ListNodeSwiftType = $s9CallStack8ListNodeCML;

MetadataResponse ListNodeTypeMetadataAccessor(i64 arg1) {
    SwiftTypeRef swiftType;
    
    if (*ListNodeSwiftType == NULL) {
        swiftType = (SwiftTypeRef)swift_getInitializedObjCClass(&(listNodeMetadataRecord -> objc_class));
        * ListNodeSwiftType = swiftType
    } else {
        swiftType = * ListNodeSwiftType
    }
    
    MetadataResponse metadataResponse = {
        swiftType,
        0,
    };
    
    return metadataResponse;
}
複製程式碼

我們可以看到:

  • 首先這個函式將檢查全域性變數 ListNodeSwiftType(也就是 $s9CallStack8ListNodeCML 這個符號)的內容:
    • 如果為 0,那麼就會將 listNodeMetadataRecord -> objc_class 傳入 swift_getInitializedObjCClass 來獲得 ListNodeswiftType
    • 如果不是 0,那麼會直接使用 ListNodeSwiftType 作為 ListNodeswiftType
  • 之後再將 ListNodeswiftType 構造成 metadataResponse 並且返回。

要判斷上述程式碼中,我們會執行哪個條件分支,我們必須首先知道 ListNodeSwiftType也就是 $s9CallStack8ListNodeCML 的內容。在我們匯出的 LLVM IR 檔案的第 37 行我們可以找到如下內容:

@"$s9CallStack8ListNodeCML" = internal global %swift.type* null, align 8
複製程式碼

所以我們知道了,對於我們編譯的 ListNode 這段程式碼,$s9CallStack8ListNodeCML 也就是 ListNodeTypeMetadata 會是 null(也就是 0)。同時我們也可以在 Hopper 中搜尋 $s9CallStack8ListNodeCML 來進行求證。

$s9CallStack8ListNodeCML

可以看到 $s9CallStack8ListNodeCML 確實是 0。也就是說在我們的程式碼的執行過程中,ListNode 的 type metadata accessor 會呼叫 swift_getInitializedObjCClass 來幫助生成 ListNode 型別的 metadata 記錄。

在上面這段 LLVM IR 中,swift_getInitializedObjCClass(&(listNodeMetadataRecord -> objc_class)) 這句所對應的 LLVM IR 一般被認為是大部分 LLVM 學習者都容易搞混的點。我這裡將這句的 LLVM IR 抽出來單獨講一下:

%3 = call %objc_class* @swift_getInitializedObjCClass(%objc_class* bitcast (i64* getelementptr inbounds (<{ void (%T9CallStack8ListNodeC*)*, i8**, i64, %objc_class*, %swift.opaque*, %swift.opaque*, i64, i32, i32, i32, i16, i16, i32, i32, <{ i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor }>*, i8*, i64, i64, i64 (%T9CallStack8ListNodeC*)*, void (i64, %T9CallStack8ListNodeC*)*, { i8*, %TSi* } (i8*, %T9CallStack8ListNodeC*)*, i64 (%T9CallStack8ListNodeC*)*, void (i64, %T9CallStack8ListNodeC*)*, { i8*, %T9CallStack8ListNodeCSg* } (i8*, %T9CallStack8ListNodeC*)*, %T9CallStack8ListNodeC* (i64, %swift.type*)* }>, <{ void (%T9CallStack8ListNodeC*)*, i8**, i64, %objc_class*, %swift.opaque*, %swift.opaque*, i64, i32, i32, i32, i16, i16, i32, i32, <{ i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, i32, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor, %swift.method_descriptor }>*, i8*, i64, i64, i64 (%T9CallStack8ListNodeC*)*, void (i64, %T9CallStack8ListNodeC*)*, { i8*, %TSi* } (i8*, %T9CallStack8ListNodeC*)*, i64 (%T9CallStack8ListNodeC*)*, void (i64, %T9CallStack8ListNodeC*)*, { i8*, %T9CallStack8ListNodeCSg* } (i8*, %T9CallStack8ListNodeC*)*, %T9CallStack8ListNodeC* (i64, %swift.type*)* }>* @"$s9CallStack8ListNodeCMf", i32 0, i32 2) to %objc_class*))
複製程式碼

我們可以看到,這一行實在太長。我們可以對其做一下美化:我們將內聯在 LLVM IR 指令中的型別結構抽出命名為 %SwiftTypeMetadata,並且將這句 LLVM IR 分解為多句,於是可得:

%0 = i64* getelementptr inbounds (%SwiftTypeMetadata, %SwiftTypeMetadata * @"$s9CallStack8ListNodeCMf", i32 0, i32 2)
%1 = %objc_class* bitcast (%objc_class* %0 to %objc_class*)
%3 = call %objc_class* @swift_getInitializedObjCClass(%objc_class* %1)
複製程式碼

這下我們可以很清晰地進行解釋了:

  • 首先我們要知道,getelementptr 這個指令是用來獲得從某一個基地址開始之後某一個元素的地址的。很多 LLVM 學習者都會把這條指令理解成「獲取某一個基地址開始之後的某一個元素」,這是錯的。

    在這裡,這條指令以 $s9CallStack8ListNodeCMf 為基礎地址,先以 %SwiftTypeMetadata * 型別審視這個地址,移動到從 0 數起的第 0 個元素(i32 0 的作用)後,再以 %SwiftTypeMetadata 型別審視當前地址,移動到從 0 數起的第 2 個元素(i32 2 的作用),最後將經過兩次移動之後的地址賦值給 %0

    於是我們這裡實際上獲得了一個指向 $s9CallStack8ListNodeCMf 第二個地址的指標;

  • 之後我們再用 bitcast%0 扮演成 %objc_class* 再賦值給 %1

  • 然後呼叫 @swift_getInitializedObjCClass,並將 %1%objc_class* 作為引數傳入。

那麼問題來了,%SwiftTypeMetadata 這個型別到底是什麼樣子的?因為上述 LLVM IR 中我們以 %SwiftTypeMetadata 來審視了 $s9CallStack8ListNodeCMf 這個符號,所以我們不妨看看 $s9CallStack8ListNodeCMf 的內容是什麼。在我們生成的 LLVM IR 的第 38 行可以看見 $s9CallStack8ListNodeCMf 的內容。為了方便大家觀察,這裡我將這個結構體的內容畫了出來:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

我們又知道,我們將指向上圖所示欄位中從 0 開始數第 2 個欄位的指標扮演成了 %objc_class*(一個指向 Objective-C class 的指標),那麼我們不妨用 Objective-C class 的結構體來看看 $s9CallStack8ListNodeCMf 的內容:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

可以看見,這個結構體中確實隱含著一個 Objective-C class 結構體。

實際上,如果你記性好,那麼你還會發現,這個結構體的第一個成員就是我們的 ListNode.__deallocating_deinit 函式——也就是摧毀(destroy)函式。根據我們前面對 Swift 執行時中 _swift_dealloc_release 函式的研究,我們不難產生直覺——莫非這個結構體從 0 開始數第 2 個成員就是 HeapObjectmetadata 指標指向的目標?

目前我們還不能肯定,因為程式碼還沒有研究完,我們還不知道後面會發生什麼。

接下來我們必須知道 swift_getInitializedObjCClass 這個函式做了什麼。

搜尋我們匯出的 LLVM IR 檔案,我們可以發現 swift_getInitializedObjCClass 是一個只有宣告(declare)沒有定義(define)的檔案。這說明這個函式將在編譯時被連結到目標檔案,也就是說,這是一個存在於 Swift 標準庫中的函式。

調查 swift_getInitializedObjCClass

於是我們可以在 Swift 原始碼中搜尋 swift_getInitializedObjCClass,並在 stdlib/public/runtime/SwiftObject.mm 檔案中發現如下內容:

Class swift::swift_getInitializedObjCClass(Class c) {
  // Used when we have class metadata and we want to ensure a class has been
  // initialized by the Objective-C runtime. We need to do this because the
  // class "c" might be valid metadata, but it hasn't been initialized yet.
  return [c class];
}
複製程式碼

是的,這個函式僅僅向傳入的引數 c 傳送了 [Class +class] 訊息,根據 Objective-C 執行時的知識,這將確保 c 在 Objective-C 執行時中被初始化(既 [Class +initialize] 被呼叫)。然後這個函式會返回 [c class]——既 c 本身。於是我們可以看見,這個函式就如其名字一樣——僅僅只是保證這個 Objective-C class 被初始化而已。

Type Metadata Accessor 小結

我們可以看到 swift_getInitializedObjCClass 在這裡並沒有起到什麼偷天換日的作用,所以我們之前的直覺是對的:HeapObjectmetadata 指標就是指向 $s9CallStack8ListNodeCMf 開始的結構體上從 0 開始數第 2 個元素的。

我們可以畫圖做一下說明:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

Type Metadata 生成

可以 type metadata 又是怎麼生成的呢?我們可以看到 lib/IRGen/GenMeta.cpp 檔案中的 SingletonClassMetadataBuilder 這個 C++ 類。這個類將通過使用 LLVM 的 API 來為 ListNode 生成 type metadata 的 LLVM IR。

class SingletonClassMetadataBuilder :
    public ClassMetadataBuilderBase<SingletonClassMetadataBuilder> {
    
    ...
}
複製程式碼

你會發現這份檔案中還有其他的 class metadata builder,實際上,根據 class 是否套用 @_fixed_layout 屬性,是否是模組外定義的,是否是泛型的這三點,Swift 會有不同的 type metadata 生成策略。這裡我還沒有時間一一研究。

我們可以看到其繼承自 ClassMetadataBuilderBase<SingletonClassMetadataBuilder>,而我們又可以在同一個檔案內找到 ClassMetadataBuilderBase 的定義:

template<class Impl>
class ClassMetadataBuilderBase : public ClassMetadataVisitor<Impl> {
  ...
}
複製程式碼

不難看出,ClassMetadataBuilderBase<Impl> 繼承自 ClassMetadataVisitor<Impl>,而 ClassMetadataVisitor 就是非常經典的 visitor 模式的應用了。

我們看到 ClassMetadataVisitorlayout 函式,這就是具體生成 type metadata 的地方。

template <class Impl> class ClassMetadataVisitor
    : public NominalMetadataVisitor<Impl>,
      public SILVTableVisitor<Impl> {
public:
  void layout() {
    // HeapMetadata header.
    asImpl().addDestructorFunction();

    // Metadata header.
    super::layout();

    asImpl().addSuperclass();
    asImpl().addClassCacheData();
    asImpl().addClassDataPointer();

    asImpl().addClassFlags();
    asImpl().addInstanceAddressPoint();
    asImpl().addInstanceSize();
    asImpl().addInstanceAlignMask();
    asImpl().addRuntimeReservedBits();
    asImpl().addClassSize();
    asImpl().addClassAddressPoint();
    asImpl().addNominalTypeDescriptor();
    asImpl().addIVarDestroyer();

    // Class members.
    addClassMembers(Target);
  }
}
複製程式碼

其中 super::layout() 呼叫的是 NominalMetadataVisitor<Impl>layout 函式,我們在這裡也將其貼出來:

template <class Impl> class NominalMetadataVisitor {
public:
  void layout() {
    // Common fields.
    asImpl().addValueWitnessTable();
    asImpl().noteAddressPoint();
    asImpl().addMetadataFlags();
  }
}
複製程式碼

layout 函式體內的函式們逐一展開後,我們就可以將之前獲得的 ListNode 的 type metadata 中所有的欄位都對上號了:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

一般性非泛型非 @_fixed_layout 的 Swift 物件記憶體佈局及 type metadata 佈局

同時,在這裡我們也可以給出 Swift 中一般非泛型,非 @_fixed_layout 類的記憶體佈局及相應的 type metadata 記憶體佈局:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

思考題:你能說明一下 Swift 為什麼要這樣設計嗎?

超長連結串列爆棧原因小結

至此,我們可以總結出,對於使用 ListNode 這種構型的連結串列,其被釋放的過程中:

  1. 根節點首先進入釋放過程;
  2. 根節點的 __deallocating_deinit 又呼叫了根節點的 deinit 函式;
  3. 根節點的 deinit 又呼叫了一個由編譯器自動生成的外聯的 ListNode 成員摧毀過程;
  4. 編譯器自動生成的外聯的 ListNode 成員摧毀過程又呼叫了 swift_release 來解除對 next 節點的強引用;
  5. swift_release 呼叫 _swift_release_ 來解除對 next 節點的強引用;
  6. 因為這裡只有一處對這個 next 節點進行強引用且沒有 unowned 引用,所以 _swift_release_ 最終會通過 _swift_release_dealloc 來解除對 next 節點強引用
  7. _swift_release_dealloc 通過呼叫 ListNode__deallocating_deinit 函式來摧毀 next 節點;
  8. next 節點引用計數變成 0,進入釋放過程;

我們可以通過檢視完全的呼叫棧來查證上述表述是不是真的。

為了檢視完全的呼叫棧而不是 Debug Navigator 中那點呼叫棧摘要,我們要點選 Xcode 編輯區左上角「四個方格」的圖示,然後點選 Disassembly,再點選當前棧幀 ListNode.deinit 開啟全部呼叫棧。最後我們可以看到下圖:

呼叫棧

我們可以注意到,上圖中沒有 swift_release_swift_release_ 兩個函式的過程活動記錄(procedure activation record,可以粗淺理解為呼叫棧和 CPU 上暫存器中的記錄)。這是因為編譯器對這兩個函式做了尾部呼叫優化(TCO),將 call 系列指令(考慮到 32 位和 64 位平臺上的完成相同功能的指令名字並不相同,我這裡及以下都將稱之為「系列指令」)改成了 jmp 系列指令,這樣後繼的 _swift_release__swift_release_dealloc 函式就可以複用起始函式 swift_release 的呼叫棧了,而我們觀察到的就是複用了之後的呼叫棧。

我們可以在 Xcode 中打下 symbolic breakpoints 來求證。

按下 command + 8 將 Navigator 皮膚切換到 Breakpoint Navigator。按下皮膚左下角的 "+" 按鈕新增兩個 symbolic breakpoints:swift_release_swift_release_

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

要注意,在節點生成過程中也會觸發引用計數,所以這個時候 swift_release 也會被呼叫,所以我們首先要關掉這兩個 breakpoints:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

然後在 print("Bar") 這個地方打上 breakpoint:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

再次執行程式,待程式執行到 print("Bar") 在 breakpoint 除暫停之後開啟 swift_relase_swift_release_ 的斷點再繼續。之後我們將看到程式將在 swift_relase_swift_release_ 的入口處中止。

我們可以看到 swift_relase_swift_release_ 的呼叫以及 _swift_release__swift_release_dealloc 的呼叫全部都是由 jmp 系列指令完成的。這就是尾部呼叫優化(TCO)在指令集這個微觀層面的體現。

libswiftCore.dylib`swift_release:
    ....
    0x7fff65730905 <+5>:  jmpq   *0x3351ac9d(%rip)         ; _swift_release
    ....
複製程式碼
libswiftCore.dylib`_swift_release_:
    ...
    0x7fff65730ce1 <+145>: jmp    0x7fff65731a50            ; _swift_release_dealloc
    ...
複製程式碼

於是我們通過觀察函式的過程活動記錄的方法證明了上述我們對 ListNode 的釋放過程的描述是正確的。於是我們可以知道:對於以 ListNode 這種方式實現的連結串列的根節點被完全銷燬之前,其後繼節點就會被釋放,然後因為再也沒有對其後繼節點進行強引用的地方了,於是這個後繼節點也進入到了一個自動的銷燬過程。這種銷燬就像核裂變一樣,是鏈式反應的,而一旦這個「反應鏈」很長——既連結串列本身很長的話就會引起爆棧。當然,爆棧的結果就是應用崩潰。

另外,我們可以觀察到,編譯器並沒有對這個間接遞迴過程進行尾遞迴優化。

不會爆棧的連結串列的實現方法

那麼用什麼方法可以實現永遠不會在釋放時爆棧的連結串列呢?

方法 1: 釋放時人工處理

我們可以在 ListNode 釋放時考慮對 next 節點進行逆向釋放,這相當於一道比較多見的面試題:反轉連結串列。於是我們可以寫出如下程式碼:

class ListNode {
    var val: Int
    var next: ListNode?
    
    init(_ val: Int) {
        self.val = val
        self.next = nil
    }
    
    deinit {
        var currentOrNil: ListNode? = self
        var nodes = [ListNode]()
        
        while let current = currentOrNil {
            nodes.append(current)
            currentOrNil = current.next
        }
        
        for each in nodes.reversed() {
            each.next = nil
        }
    }
}
複製程式碼

這樣,我們將一個遞迴轉換成迴圈就可以避免 ListNode 釋放時後繼節點連鎖釋放所帶來的爆棧。我自己寫的一個玩具級 LRUCache (之前聽說某廠面試必考 LRU cache 所以寫了個練手)中所使用的雙向連結串列的 deinit 就使用了這種方法。

然而,使用這種方法構建的連結串列在每次釋放時都要將所有連結串列節點插入一個動態陣列,雖然動態陣列插入的複雜度是均攤 O(1) 的,但是這依然要耗費 O(n) 的輔助空間,在時間上也會多 O(n) 的開銷(這也是我為什麼說我寫的那個 LRU cache 是玩具級的原因)。有沒有什麼辦法可以把這些消除掉?

方法 2: 對連結串列節點進行 Pooling

考慮到連結串列是一種將內部結構特徵(前驅節點或者後繼節點)暴露在使用者面前的資料結構,這使得我們在使用連結串列的同時也不得不考慮如何去維護這個連結串列的內部結構,從而給我們帶來了不小的智力負擔。於是我們可以將其封裝在一個型別內部,僅僅讓這個型別可以對連結串列進行操作,然後再對這個型別封裝出一些資料集合型別所使用的功能,這樣就可以減輕使用連結串列時的智力負擔。

關於以上這點請看星際爭霸 I 的開發者撰寫的在星際爭霸 I 的開發過程中與連結串列鬥智鬥勇的系列文章:

I. Tough Times on the Road to Starcraft

II. Avoiding Game Crashes Related to Linked Lists

III. Explaining the Implementation Details of The Fix(抱歉,第三篇他鴿了七年了)

這樣以來,我們就可以在這個型別內部套用 pooling 模式了。Pooling 是一個在計算密集型領域(如遊戲客戶端、大型軟體客戶端和大型伺服器端中)常見的設計模式,其在 iOS 平臺上的研發中並不常見。甚至可以說,從 Objective-C 2.0 無效化 [NSObject +allocWithZone:] 這個 API 來看,蘋果是並不鼓勵開發者在 Objective-C 中使用 pooling 模式的——你要 pooling 那就只能用 C++ 來進行 pooling。Pooling 模式的具體做法是:預先分配一個記憶體池然後在要建立物件時直接從這個記憶體池中劃分出一部分給這個物件即可——這樣我們就可以避免系統的記憶體分配函式需要訪問作業系統中關於資源排程安排的資訊這個開銷,進一步提高計算效能。當然,在面對解決 ListNode 構型的連結串列過長而導致釋放時崩潰這個問題時,這些都只是 bonus 而不是我們使用 pooling 的根本目的。

在這裡我們使用 pooling 模式的根本目的在於:如果我們給每一個要使用連結串列的封裝型別的例項都建立一個記憶體池,那麼在釋放連結串列時我們只需要釋放這個記憶體池就可以了。

剛剛說了,蘋果似乎並不鼓勵開發者在 Objective-C 中使用 pooling 模式,其實在 Swift 中也無法簡單套用 pooling 模式——因為 class 例項的記憶體分配過程是由 Swift 執行時掌控的。我們可以在 stdlib/public/runtime/Heap.cpp 中找到下列函式:

void *swift::swift_slowAlloc(size_t size, size_t alignMask) {
  void *p;
  // This check also forces "default" alignment to use AlignedAlloc.
  if (alignMask <= MALLOC_ALIGN_MASK) {
    p = malloc(size);
  } else {
    size_t alignment = (alignMask == ~(size_t(0)))
                           ? _swift_MinAllocationAlignment
                           : alignMask + 1;
    p = AlignedAlloc(size, alignment);
  }
  if (!p) swift::crash("Could not allocate memory.");
  return p;
}
複製程式碼

這就是 Swift 執行時中進行 heap 記憶體分配的函式,也是最終為 class 例項分配記憶體空間的函式,我們可以看見其當前實現是利用 C 標準庫的 malloc 進行記憶體分配的,這個過程不可能有任何開發者的參與。為了使用 pooling 模式,我們只能使用 Swift 標準庫中的 UnsafeMutablePointer 配合 struct 來實現一個記憶體池。

當然,你也可以嘗試更「野」(野蠻、暴力之意)的路子:用 C 建立好記憶體池,保證好記憶體池中每一個例項和 Objective-C 的記憶體模型一致後再用 CF 物件 bridge 到 Swift 的方法將記憶體池中的內容 bridge 過來。這樣有一個好處就是你可以得到 class 例項而不用裸操作原始指標了。

如果你對這個方法感興趣,可以參考這篇日本 Qiita 社群(也是一個開發者社群)上的文章:

Swift の Toll-Free Bridge の実裝を読む

整體設計思路如下圖:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

如圖所示,_ListStorage 內的維護了一個記憶體池 buffer、一個頭部節點指標 headOffset(代表使用節點鏈)和一個重用節點指標 reuseHeadOffset(代表重用節點鏈)。在這個連結串列實現中,因為所有節點都在記憶體池中,我們可以使用節點在記憶體池中的偏移量來記錄 next 節點的位置。

  • 初始化時,頭部節點指標為空(-1),重用節點指標指向記憶體池的第一個元素,記憶體池內的每一個單元的 next 指標都指向下一個單元,最後一個單元的 next 指標為空(-1),此時記憶體池上每一個單元都在重用節點鏈上。
  • 加入時,從重用節點鏈上拿下頭部節點(如果沒有就對記憶體池進行擴容),插入到使用節點鏈的頭部節點。
  • 刪除時,將使用節點鏈的頭部節點拿下,並且插入到重用節點鏈的頭部節點。

因為我們總是分配相同大小的單元,所以我們不需要像 malloc 那樣在分配的時候留一個小空間來記錄本次分配的空間的大小,所以這個記憶體池的實現會變得非常簡單。

我將這個實現的完整程式碼放在了 GitHub 上,這裡我挑幾個實現中的重點說說:

  • 我在 buffer 內的單元(程式碼中對應 bucket 這個概念)中並沒有直接使用指標來表示後繼節點,而是使用了節點在記憶體池中的偏移量來作為指標值。這麼做有一點好處:我們在應用寫入時複製(COW)時,整個容器僅僅需要簡單複製就可以了。不然的話,我們就要一個指標一個指標來進行釐清了。

  • 記憶體池的增長因子我設定成了 1.5

    let newCapacity = max(1, _capacity + ((_capacity + 1) >> 1))
    複製程式碼

    因為當我們使用 1.5 的增長因子時,作業系統將有可能有機會重複利用之前分配過的空間。這樣可以起到減少記憶體碎片的效果。

    舉例如下:

    第一次分配記憶體: 1  2
    
    第二次分配記憶體: .  .  1  2  3
    
    第三次分配記憶體: .  .  .  .  .  1  2  3  4  5
    
    第四次分配記憶體:.  .  .  .  .  .  .  .  .  .  1  2  3  4  5  6  7  8
    
    第五次分配記憶體:.  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  . 1 2 3 4 5 6 7 8 9 A B C
    
    第六次分配記憶體:1  2  3  4  5  6  7  8  9  A  B  C  D  E  F  11 12
    複製程式碼

    我們可以看到,在第六次分配記憶體的時候,作業系統存在一個重複利用之前分配過的空間的機會。

    Swift 中 ArrayContiguousArray 的增長因子則是 2。我們可以看到 stdlib/public/core/ArrayShared.swift 這個檔案中有如下內容:

    @inlinable
    internal func _growArrayCapacity(_ capacity: Int) -> Int {
        return capacity * 2
    }
    複製程式碼

    這句就控制了 Swift ArrayContiguousArray 的擴容因子。所以對於 Swift 的 ArrayContiguousArray而言,作業系統將沒有這種重複利用之前分配過的空間的機會。

    在實際工程中,騰訊 IEG 出品的 RapidJSON 和 Facebook 出品的 FBFolly 都是使用的 1.5 為動態陣列型別的容器的增長因子。

    Clang 和 GCC 的 C++ STL 中的 std::vector 增長因子也是 2。

  • 我們可以通過讓我們的連結串列型別遵從於 SequenceCollection 以讓我們能更加方便的在 Swift 中使用這個型別(我在範例程式碼中已經這麼做了)。

    但是要注意的是,因為 Collection 協議自帶 Index,而對連結串列使用索引訪問的時間複雜度是 O(n) 的,加上 Collection 在實現了 subscript 的 getter 之後就會自動實現一個使用這個 subscriptiterator,所以如果這時候我們通過 Swift 中一般遍歷 Sequence 和 Collection 的方法(如 for-in 迴圈)來遍歷連結串列,那麼這個效能將會非常差(時間複雜度升至 O(n^2))。

    所以,你應該自己實現一個 iterator 來完成 O(n) 的遍歷時間複雜度(我在範例程式碼中也已經這麼做了)。

  • 如果要用於生產環境,你應該還要讓這個連結串列型別遵從於 RangeReplaceableCollection。這個協議有一個叫 removeAll(keepingCapacity:) 的函式可以跟我們約定一個釋放記憶體池中無用空間的介面。

方法 3: 僅對連結串列節點的引用進行 Pooling

上述方法太麻煩了,還要自己造記憶體池(雖然這是個很簡單的特例)。有沒有什麼更簡單的方法?

還是有的。

我們可以依然回到使用 class 來建立連結串列節點這個基本方法,然後選擇僅僅對連結串列節點的引用進行 pooling。這有點像 UITableView 內部對 reusable cells 進行重用的處理方法;Facebook 的 ComponentKit 也會在 view 層級的每一個 view 內部建立一個 reuse pool 來對由 ComponentKit 控制的 subviews 進行重用。這些例子中都僅僅只對物件指標進行了 pooling 而不是物件整體。

整體設計思路如下圖:

從一道 iOS 面試題到 Swift 物件模型和執行時細節——「iOS 面試之道」勘誤

如圖所示,_ListStorage 維護了一個有關連結串列節點的陣列 nodes、頭節點在陣列中的偏移量 headNodeIndex 可重用節點在陣列中的索引。同時,在連結串列節點中我們依然要使用陣列內偏移量來記錄 next 節點在陣列中的位置。這樣做在這個實現中的好處主要是可以規避引用計數。

我也將這個實現的完整程式碼放在了 GitHub 上。

同樣,對於這個連結串列實現,如果要用於生產環境,你應該還要讓這個連結串列型別遵從於 RangeReplaceableCollection。這個協議有一個叫 removeAll(keepingCapacity:) 的函式可以跟我們約定一個釋放資料結構中無用空間的介面。

同時我也在範例專案內做了一個簡易的 benchmark,方法二的效能明顯優於方法三。


本文提及資源索引

SSA Book

星際爭霸 I 開發者連結串列心得 I:I. Tough Times on the Road to Starcraft

星際爭霸 I 開發者連結串列心得 II:II. Avoiding Game Crashes Related to Linked Lists

我寫的不爆棧的連結串列範例程式碼:LinkedListExamples

另外,查閱 Swift 原始碼我推薦使用 CLion(沒收推廣費)


本文使用 OpenCC 完成繁簡轉換。

相關文章