起初的疑問源自于「在 Swift 中的, Struct:Protocol 比 抽象類 好在哪里?」。但是找來找去都是 Swift 性能相關的東西。整理了點筆記,供大家可以參考一下。
一些疑問
在正題開始之前,不知道你是否有如下的疑問:
- 為什么說?Swift?相比較于?Objective-C?會更加?快??
- 為什么在編譯?Swift?的時候這么?慢??
- 如何更?優雅?的去寫 Swift ?
如果你也有類似疑問,希望這篇筆記能幫你解釋一下上面幾個問題的一些原因。(ps.上面幾個問題都很大,如果有不同的想法和了解,也希望你能分享出來,大家一起討論一下。)
Swift中的類型
首先,我們先統一一下關于類型的幾個概念。
- 平凡類型
有些類型只需要按照字節表示進行操作,而不需要額外工作,我們將這種類型叫做平凡類型 (trivial)。比如,Int 和 Float 就是平凡類型,那些只包含平凡值的 struct 或者 enum 也是平凡類型。
- 引用類型
對于引用類型,值實例是一個對某個對象的引用。復制這個值實例意味著創建一個新的引用,這將使引用計數增加。銷毀這個值實例意味著銷毀一個引用,這會使引用計數減少。不斷減少引用計數,最后當然它會變成 0,并導致對象被銷毀。但是需要特別注意的是,我們這里談到的復制和銷毀值,只是對引用計數的操作,而不是復制或者銷毀對象本身。
- 組合類型
類似 AClass 這類,引用類型包含平凡類型的,其實還是引用類型,但是對于平凡類型包含引用類型,我們暫且稱之為組合類型。
影響性能的主要因素
主要原因在下面幾個方面:
- 內存分配 (?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和其他編譯期優化,在執行的時候,會直接跳到方法的實現。
- 動態調度?: 在執行的時候,會根據運行時,采用?V-Table?的方式,找到方法的執行體,然后執行。無法進行編譯期優化。?V-Table?不同于 OC 的調度,在 OC 中,是先在運行時的時候先在子類中尋找方法,如果找不到,再去父類尋找方法。而對于 V-Table 來說,它的調度過程如下圖:
因此,在性能上「?靜態調度?>?動態調度?」并且「?Swift中的V-Table?>?Objective-C 的動態調度?」。
協議類型 (Protocol types)
在 Swift 引入了一個?協議類型?的概念,示例如下:
在上述代碼中,?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),用來存儲協議的函數。
用偽代碼表示如下:
所以,對于上文代碼中的 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會再性能上比抽象類更加好
- (寫起來更加有逼格算不算?)
但是,雖然表面上協議類型確實比抽象類更加的?“好”?,但是我還是想說,不要隨隨便便把協議當做類型來使用。
為什么這么說?先來看一段代碼:
首先,我們把 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)
首先,如果我們把協議當做類型來處理,我們稱之為?「動態多態」?,代碼如下:
而如果我們使用泛型來改寫的話,我們稱之為?「靜態多態」?,代碼如下:
而這里所謂的?動態?和?靜態?的區別在哪里呢?
在 Xcode 8 之前,唯一的區別就是由于使用了泛型,所以在調度方法是,我們已經可以根據上下文確定了這個?T?到底是什么類型,所以并不需要?Existential Container?,所以泛型沒有使用?Existential Container?,但是因為還是多態,所以還是需要VWT和PWT作為隱形參數傳遞,對于臨時變量仍然按照ValueBuffer的邏輯存儲 - 分配3個word,如果存儲數據大小超過3個word,則在堆上開辟內存存儲。如圖所示:
這樣的形式其實和把協議作為類型并沒有什么區別。唯一的就是沒有?Existential Container?的中間層了。
但是,在 Xcode 8 之后,引入了?Whole-Module Optimization?使泛型的寫法更加靜態化。
首先,由于可以根據上下文知道確定的類型,所以編譯器會為每一個類型都生成一個drawACopy的方法,示例如下:
由于每個類型都生成了一個drawACopy的方法,drawACopyOfAPoint的調用就吧編程了一個靜態調度,再根據前文靜態調度的時候,編譯器會做 inline 處理,所以上面的代碼經過編譯器處理之后代碼如下:
由于編譯器一步步的處理,再也不需要 vwt、pwt及value buffer了。所以對于泛型來做多態來說,就叫做靜態多態。
幾點總結
- 為什么在編譯 Swift 的時候這么慢
- 因為編譯做了很多事情,例如 靜態調度的inline處理,靜態多態的分析處理等
- 為什么說 Swift 相比較于 Objective-C 會更加快
- 對于Swift來說,更多的靜態的,比如靜態調度、靜態多態等。
- 更多的棧內存分配
- 更少的引用計數
- 如何更優雅的去寫 Swift
- 不要把協議當做類型來處理
- 如果需要把協議當做類型來處理的時候,需要注意 big Value 的復制就引起堆內存分配的問題。可以用 Indirect Storage + Copy On Write 來處理。
- 對于一些抽象,可以采用 Struct:Protocol 來代替抽象類。至少不會有?上帝類?出現,而且處理的好的話性能是比抽象類更好的。
參考資料
- Understanding Swift Performance
- 真實世界中的 Swift 性能優化
- Exploring Swift Memory Layout
- 水平有限,若有錯誤,希望多多指正!coderonevv#gmail.com
更多
工作之余,寫了點筆記,如果需要可以在我的?GitHub?看。
查看原文:?Swift 性能相關