Swift 性能相關

起初的疑問源自於「在 Swift 中的, Struct:Protocol 比 抽象類 好在哪裏?」。可是找來找去都是 Swift 性能相關的東西。整理了點筆記,供你們能夠參考一下。git

一些疑問

在正題開始以前,不知道你是否有以下的疑問:程序員

  • 爲何說 Swift 相比較於 Objective-C 會更加
  • 爲何在編譯 Swift 的時候這麼
  • 如何更優雅的去寫 Swift ?

若是你也有相似疑問,但願這篇筆記能幫你解釋一下上面幾個問題的一些緣由。(ps.上面幾個問題都很大,若是有不一樣的想法和了解,也但願你能分享出來,你們一塊兒討論一下。)github

Swift中的類型

首先,咱們先統一一下關於類型的幾個概念。算法

  • 平凡類型

有些類型只須要按照字節表示進行操做,而不須要額外工做,咱們將這種類型叫作平凡類型 (trivial)。好比,Int 和 Float 就是平凡類型,那些只包含平凡值的 struct 或者 enum 也是平凡類型。編程

struct AStruct {
    var a: Int
}
struct BStruct {
    var a: AStruct
}
// AStruct & BStruct 都是平凡類型
複製代碼
  • 引用類型

對於引用類型,值實例是一個對某個對象的引用。複製這個值實例意味着建立一個新的引用,這將使引用計數增長。銷燬這個值實例意味着銷燬一個引用,這會使引用計數減小。不斷減小引用計數,最後固然它會變成 0,並致使對象被銷燬。可是須要特別注意的是,咱們這裏談到的複製和銷燬值,只是對引用計數的操做,而不是複製或者銷燬對象自己。swift

struct CStruct {
    var a: Int
}
class AClass {
    var a: CStruct
}
class BClass {
    var a: AClass
}
// AClass & BClass 都是引用類型
複製代碼
  • 組合類型

相似 AClass 這類,引用類型包含平凡類型的,其實仍是引用類型,可是對於平凡類型包含引用類型,咱們暫且稱之爲組合類型。數組

struct DStruct {
    var a: AClass
}
// DStruct 是組合類型
複製代碼

影響性能的主要因素

主要緣由在下面幾個方面:安全

  • 內存分配 (Allocation):主要在於 堆內存分配 仍是 棧內存分配
  • 引用計數 (Reference counting):主要在於如何 權衡 引用計數。
  • 方法調度 (Method dispatch):主要在於 靜態調度動態調度 的問題。

內存分配(Allocation)

今天主要談一談 內存分區 中的 性能優化

  • 堆(heap)

堆是用於存放進程運行中被動態分配的內存段,它的大小並不固定,可動態擴張或 縮減。當進程調用malloc等函數分配內存時,新分配的內存就被動態添加到堆上(堆被擴張); 當利用free等函數釋放內存時,被釋放的內存從堆中被剔除(堆被縮減)數據結構

  • 棧 (stack heap)

棧又稱堆棧, 是用戶存放程序臨時建立的局部變量,也就是說咱們函數括弧「{}」 中定義的變量(但不包括static聲明的變量,static意味着在數據段中存放變量)。除此之外, 在函數被調用時,其參數也會被壓入發起調用的進程棧中,而且待到調用結束後,函數的返回值 也會被存放回棧中。因爲棧的後進先出特色,因此 棧特別方便用來保存/恢復調用現場。從這個意義上講,咱們能夠把堆棧當作一個寄存、交換臨時數據的內存區。

在 Swift 中,對於 平凡類型 來講都是存在 中的,而 引用類型 則是存在於 中的,以下圖所示:

咱們都知道,Swift建議咱們多用 平凡類型,那麼 平凡類型引用類型 好在哪呢?換句話說「在 中的數據和 中的數據相比有什麼優點?」

  • 數據結構
    • 存放在棧中的數據結構較爲簡單,只有一些值相關的東西
    • 存放在堆中的數據較爲複雜,如上圖所示,會有type、retainCount等。
  • 數據的分配與讀取
    • 存放在棧中的數據從棧區底部推入 (push),從棧區頂部彈出 (pop),相似一個數據結構中的棧。因爲咱們只可以修改棧的末端,所以咱們能夠經過維護一個指向棧末端的指針來實現這種數據結構,而且在其中進行內存的分配和釋放只須要從新分配該整數便可。因此棧上分配和釋放內存的代價是很小。
    • 存放在堆中的數據並非直接 push/pop,相似數據結構中的鏈表,須要經過必定的算法找出最優的未使用的內存塊,再存放數據。同時銷燬內存時也須要從新插值。
  • 多線程處理
    • 棧是線程獨有的,所以不須要考慮線程安全問題。
    • 堆中的數據是多線程共享的,因此爲了防止線程不安全,需同步鎖來解決這個問題題。

綜上幾點,在內存分配的時候,儘量選擇 而不是 會讓程序運行起來更加快。

引用計數(Reference counting)

首先 引用計數 是一種 內存管理技術,不須要程序員直接去操做指針來管理內存。

而採用 引用計數內存管理技術,會帶來一些性能上的影響。主要如下兩個方面:

  • 須要經過大量的 release/retain 代碼去維護一個對象生命週期。
  • 存放在 堆區 的是多線程共享的,因此對於 retainCount 的每一次修改都須要經過同步鎖等來保證線程安全。

對於 自動引用計數 來講, 在添加 release/retain 的時候採用的是一個寧肯多寫也不漏寫的原則,因此 release/retain 有必定的冗餘。這個冗餘量大概在 10% 的左右(以下圖,圖片來自於iOS可執行文件瘦身方法)。

而這也是爲何雖然 ARC 底層對於內存管理的算法進行了優化,在速度上也並無比 MRC 寫出來的快的緣由。這篇文章 詳細描述了 ARC 和 MRC 在速度上的比較。

綜上,雖然由於自動引用計數的引入,大大減小了內存管理相關的事情,可是對於引用計數來講,過多或者冗餘的引用計數是會減慢程序的運行的。

而對於引用計數來講,還有一個權衡問題,具體如何權衡會再後文解釋。

方法調度 (Method dispatch)

在 Swift 中, 方法的調度主要分爲兩種:

  • 靜態調度: 能夠進行inline和其餘編譯期優化,在執行的時候,會直接跳到方法的實現。
struct Point {
    var x, y: Double
    func draw() {
        // Point.draw implementation
    } 
}
func drawAPoint(_ param: Point) {
    param.draw()
}
let point = Point(x: 0, y: 0)
drawAPoint(point)
// 1.編譯後變爲下面的inline方式
point.draw()
// 2.運行時,直接跳到實現 Point.draw implementation
複製代碼
  • 動態調度: 在執行的時候,會根據運行時,採用 V-Table 的方式,找到方法的執行體,而後執行。沒法進行編譯期優化。V-Table 不一樣於 OC 的調度,在 OC 中,是先在運行時的時候先在子類中尋找方法,若是找不到,再去父類尋找方法。而對於 V-Table 來講,它的調度過程以下圖:

所以,在性能上「靜態調度 > 動態調度」而且「Swift中的V-Table > Objective-C 的動態調度」。

協議類型 (Protocol types)

在 Swift 引入了一個 協議類型 的概念,示例以下:

protocol Drawable {
    func draw()
}
struct Point : Drawable {
    var x, y: Double
    func draw() { ... }
}
struct Line : Drawable {
    var x1, y1, x2, y2: Double
    func draw() { ... }
}
var drawables: [Drawable]
// Drawable 就稱爲協議類型
for d in drawables {
    d.draw()
}
複製代碼

在上述代碼中,Drawable 就稱爲協議類型,因爲 平凡類型 沒有繼承,因此實現多態上出現了一些棘手的問題,可是 Swift 引入了 協議類型 很好的解決了 平凡類型 多態的問題,可是在設計 協議類型 的時候有兩個最主要的問題:

  • 對於相似 Drawable 的協議類型來講,如何去調度一個方法?
  • 對於不一樣的類型,具備不一樣的size,當保存到 drawables 數組時,如何保證內存對齊?

對於第一個問題,如何去調度一個方法?由於對於 平凡類型 來講,並無什麼虛函數指針,因此在 Swift 中並無 V-Table 的方式,可是仍是用到了一個叫作 The Protocol Witness Table (PWT) 的函數表,以下圖所示:

對於每個 Struct:Protocol 都會生成一個 StructProtocol 的 PWT

對於第二個問題,如何保證內存對齊問題?

有一個簡單粗暴的方式就是,取最大的Size做爲數組的內存對齊的標準,可是這樣一來不但會形成內存浪費的問題,還會有一個更棘手的問題,如何去尋找最大的Size。因此爲了解決這個問題,Swift 引入一個叫作 Existential Container 的數據結構。

  • Existential Container

這是一個最普通的 Existential Container。

  • 前三個word:Value buffer。用來存儲Inline的值,若是word數大於3,則採用指針的方式,在堆上分配對應須要大小的內存
  • 第四個word:Value Witness Table(VWT)。每一個類型都對應這樣一個表,用來存儲值的建立,釋放,拷貝等操做函數。(管理 Existential Container 生命週期)
  • 第五個word:Protocol Witness Table(PWT),用來存儲協議的函數。

用僞代碼表示以下:

// Swift 僞代碼
struct ExistContDrawable {
    var valueBuffer: (Int, Int, Int)
    var vwt: ValueWitnessTable
    var pwt: DrawableProtocolWitnessTable
}
複製代碼

因此,對於上文代碼中的 Point 和 Line 最後的數據結構大體以下:

這裏須要注意的幾個點:

  • 在 ABI 穩定以前 value buffer 的 size 可能會變,對因而不是 3個 word 還在 Swift 團隊還在權衡.
  • Existential Container 的 size 不是隻有 5 個 word。示例以下:

對於這個大小差別最主要在於這個 PWT 指針,對於 Any 來講,沒有具體的函數實現,因此不須要 PWT 這個指針,可是對於 ProtocolOne&ProtocolTwo 的組合協議,是須要兩個 PWT 指針來表示的。

OK,因爲 Existential Container 的引入,咱們能夠將協議做爲類型來解決 平凡類型 沒有繼承的問題,因此 Struct:Protocol 和 抽象類就愈來愈像了。

回到咱們最初的疑問,「在 Swift 中的, Struct:Protocol 比 抽象類 好在哪裏?」

  • 因爲 Swift 只能是單繼承,因此 抽象類 很容易形成 「上帝類」,而Protocol能夠是一個多這多個則沒有這個問題
  • 在內存分配上上,Struct是在棧中的,而抽象類是在堆中的,因此簡單數據的Struct:Protocol會再性能上比抽象類更加好
  • (寫起來更加有逼格算不算?)

可是,雖然表面上協議類型確實比抽象類更加的**「好」**,可是我仍是想說,不要隨隨便便把協議當作類型來使用。

爲何這麼說?先來看一段代碼:

struct Pair {
    init(_ f: Drawable, _ s: Drawable) {
        first = f ; second = s
    }
    var first: Drawable
    var second: Drawable
}
複製代碼

首先,咱們把 Drawable 協議當作一個類型,做爲 Pair 的屬性,因爲協議類型的 value buffer 只有三個 word,因此若是一個 struct(好比上文的Line) 超過三個 word,那麼會將值保存到堆中,所以會形成下圖的現象:

一個簡單的複製,致使屬性的copy,從而引發 大量的堆內存分配

因此,不要隨隨便便把協議當作類型來使用。上面的狀況發生於無形之中,你卻沒有發現。

固然,若是你非要將協議當作類型也是能夠解決的,首先須要把Line改成class而不是struct,目的就是引入引用計數。因此,將Line改成class以後,就變成了以下圖所示:

至於修改了 line 的 x1 致使全部 pair 下的 line 的 x1 的值都變了,咱們能夠引入 Copy On Write 來解決。

當咱們 Line 使用平凡類型時,因爲line佔用了4個word,當把協議做爲類型時,沒法將line存在 value buffer 中,致使了堆內存分配,同時每一次複製都會引起堆內存分配,因此咱們採用了引用類型來替代平凡類型,增長了引用計數而下降了堆內存分配,這就是一個很好的引用計數權衡的問題。

泛型(Generic code)

首先,若是咱們把協議當作類型來處理,咱們稱之爲 「動態多態」,代碼以下:

protocol Drawable {
    func draw()
}
func drawACopy(local : Drawable) {
    local.draw()
}
let line = Line()
drawACopy(line)
// ...
let point = Point()
drawACopy(point)

複製代碼

而若是咱們使用泛型來改寫的話,咱們稱之爲 「靜態多態」,代碼以下:

// Drawing a copy using a generic method
protocol Drawable {
    func draw()
}
func drawACopy<T: Drawable>(local : T) {
    local.draw()
}
let line = Line()
drawACopy(line)
// ...
let point = Point()
drawACopy(point)
複製代碼

而這裏所謂的 動態靜態 的區別在哪裏呢?

在 Xcode 8 以前,惟一的區別就是因爲使用了泛型,因此在調度方法是,咱們已經能夠根據上下文肯定了這個 T 究竟是什麼類型,因此並不須要 Existential Container,因此泛型沒有使用 Existential Container,可是由於仍是多態,因此仍是須要VWT和PWT做爲隱形參數傳遞,對於臨時變量仍然按照ValueBuffer的邏輯存儲 - 分配3個word,若是存儲數據大小超過3個word,則在堆上開闢內存存儲。如圖所示:

這樣的形式其實和把協議做爲類型並無什麼區別。惟一的就是沒有 Existential Container 的中間層了。

可是,在 Xcode 8 以後,引入了 Whole-Module Optimization 使泛型的寫法更加靜態化。

首先,因爲能夠根據上下文知道肯定的類型,因此編譯器會爲每個類型都生成一個drawACopy的方法,示例以下:

func drawACopy<T : Drawable>(local : T) {
    local.draw()
}
// 編譯後 
func drawACopyOfALine(local : Line) {
    local.draw()
}
func drawACopyOfAPoint(local : Point) {
    local.draw()
}

//好比:
drawACopy(local: Point(x: 1.0, y: 1.0))
//變爲
drawACopyOfAPoint(local : Point(x: 1.0, y: 1.0))
複製代碼

因爲每一個類型都生成了一個drawACopy的方法,drawACopyOfAPoint的調用就吧編程了一個靜態調度,再根據前文靜態調度的時候,編譯器會作 inline 處理,因此上面的代碼通過編譯器處理以後代碼以下:

drawACopy(local: Point(x: 1.0, y: 1.0))
//會變爲
Point(x: 1.0, y: 1.0).draw()
複製代碼

因爲編譯器一步步的處理,不再須要 vwt、pwt及value buffer了。因此對於泛型來作多態來講,就叫作靜態多態。

幾點總結

  • 爲何在編譯 Swift 的時候這麼慢
    • 由於編譯作了不少事情,例如 靜態調度的inline處理,靜態多態的分析處理等
  • 爲何說 Swift 相比較於 Objective-C 會更加快
    • 對於Swift來講,更多的靜態的,好比靜態調度、靜態多態等。
    • 更多的棧內存分配
    • 更少的引用計數
  • 如何更優雅的去寫 Swift
    • 不要把協議當作類型來處理
    • 若是須要把協議當作類型來處理的時候,須要注意 big Value 的複製就引發堆內存分配的問題。能夠用 Indirect Storage + Copy On Write 來處理。
    • 對於一些抽象,能夠採用 Struct:Protocol 來代替抽象類。至少不會有上帝類出現,並且處理的好的話性能是比抽象類更好的。

參考資料

更多

工做之餘,寫了點筆記,若是須要能夠在個人 GitHub 看。

相關文章
相關標籤/搜索