天天看點

Swift | 記憶體安全

1. 簡介

一般來說,Swift 會阻止代碼中的不安全行為。例如,Swift 會保證變量在被使用前已經初始化,在釋放某變量後其記憶體也會變得不可通路,以及檢查數組索引是否存在越界錯誤。

Swift 還通過要求修改記憶體中位置的代碼具有對該記憶體的獨占通路權,來確定對同一記憶體區域的多重通路不會産生沖突。由于 Swift 會自動管理記憶體,是以大多數時候你根本不需要考慮記憶體通路的問題。然而,了解什麼地方會有潛在的記憶體沖突發生也是很重要的,這樣你就可以避免寫出對記憶體通路有沖突的代碼。如果你的代碼中确實包含沖突,則會出現編譯時錯誤或運作時錯誤。

譯自 Swift 官方文檔,是從 老司機周報 #130 中看到的這一篇,着實解答了我的一些疑惑🎯。

2. 了解關于記憶體的通路沖突

當你執行設定變量的值、将參數傳遞給函數之類的代碼時,通路記憶體這件事情會就發生。舉個例子,以下代碼包含了一個讀取操作和一個通路操作:

// A write access to the memory where one is stored.
var one = 1

// A read access from the memory where one is stored.
print("We're number \(one)!")           

複制

當不同部分的代碼試圖同時通路同一塊記憶體時,可能會發生記憶體沖突通路。同時通路同一塊記憶體可能會導緻不可預測或不一緻的行為。在 Swift 中,有多種方法可以實作在跨越好幾行代碼的過程下修改某個值,這導緻可以實作在修改自身的過程中去嘗試通路自己的值。

現在通過一個相似的問題來更好地幫助你了解這種沖突,例如你現在要在一張紙上更新你的購物預算清單。更新這張預算清單分為兩個步驟:

  1. 你需要添加商品的名稱和價格
  2. 你需要更改總價來比對你更新後的賬單。在這個更新步驟的前後,你都可以從賬單中正确的讀取任何資料,如下圖所示。
Swift | 記憶體安全

當你往清單中添加商品時,清單處于一個臨時的、無效的狀态,因為這時總價還沒有被更新、還不能反映那些新加的商品。是以當你在添加商品的過程中,讀取總價格的話,會給你一個錯誤的答案。

這個例子同樣也展示了在解決沖突通路時你可能會遇到的問題:不一樣解決沖突方式會帶來不一樣的答案,要知道哪個答案是正确的通常來說沒有那麼顯而易見。在這個例子中,主要看你是想要原來的總價格還是更新後的總價格,5和5 和 5和320 都可能是正确答案。也就是說,在你解決沖突通路之前,你得先要搞清楚你要的是什麼。

注意:

  • 如果你是在編寫有關并發或多線程的代碼,那麼記憶體通路沖突可能是一個常見的問題。但要注意的是,我們在這讨論的沖突通路是可能發生在單線程上,并且不涉及并發或多線程代碼。
  • 如果你在單線程中對記憶體的通路存在沖突,Swift 會確定在編譯時或運作時報錯。對于多線程代碼,請使用 Thread Sanitizer 來檢測多線程的沖突通路。

3. 沖突通路的特征

在沖突通路的時候,有三個通路的特征值得注意:

  1. 這個通路操作是讀還是寫
  2. 通路的時常
  3. 具體通路的位置

具體來說,如果你有兩個滿足了以下所有條件的通路操作,那麼他們是會發生沖突的:

  • 他們之中至少一個是寫入操作或非原子(nonatomic)操作
  • 他們通路了記憶體中的相同位置
  • 它們的持續時間是有重疊的

通常來說,一個讀取通路和一個寫入通路的差別是很明顯的:一個寫入通路會改變記憶體中的位置,但讀取通路不會。記憶體中的位置是指要通路的内容,例如:變量、常量或屬性。記憶體通路可以是瞬時的,也可以是維持一段時間的。

如果你的一個操作僅使用了 C 原子(atomic)操作,則該操作是原子操作,否則就是非原子的。有關這些功能,詳見 stdatomic(3)手冊頁。

如果你的某個通路在開始之後和結束之前都無法運作其他代碼,那麼這個通路就是一個瞬時通路。從本質上來說,兩個瞬時通路是不能在同一時間發生的。并且,大多數記憶體通路操作都是瞬時的。舉個例子,以下代碼中讀取和寫入通路都是瞬時的:

func oneMore(than number: Int) -> Int {
    return number + 1
}

var myNumber = 1
myNumber = oneMore(than: myNumber)
print(myNumber)
// Prints "2"

           

複制

然而,還有那麼幾種通路記憶體的方式,被稱作長期通路(long-term accesses),這種通路方式會涵蓋其他代碼的執行時機。瞬時通路和長期通路之間的差別在于,其他代碼可以在一個長期通路期間(開始之後至結束之前)運作,這就叫重疊(overlap)。長期通路可以與其他長期通路重疊,也可以和瞬時通路重疊。

重疊通路主要出現在用了 in-out 參數的函數和方法中、或是出現在結構體的 mutating 方法中。在下面的幾個部分中會讨論使用長期通路的特定類型 Swift 代碼。

4. In-Out 參數的通路

一個函數對其所有 in-out 參數具有長期寫入通路(long-term write access)的能力。In-out 參數的寫入通路是等所有非 in-out 參數被評估(?)之後才開始,并且将持續該函數調用的整個過程。如果有多個 in-out 參數,則寫入通路的開始順序與參數出現的順序相同。

使用這種長期寫入通路的一個後果是,你不可以通路以 in-out 形式傳遞的原始變量(即使從範圍規則和通路控制的角度來說這樣是允許的),任何對原始變量的通路都會導緻沖突的發生。下面舉個例子:

var stepSize = 1

func increment(_ number: inout Int) {
    number += stepSize
}

increment(&stepSize)
// Error: conflicting accesses to stepSize           

複制

在上面的代碼中,stepSize 是一個全局變量,正常來說我們可以在 increment(_:) 内通路他。然而,對 stepSize 的讀取通路和對 number 的寫入通路重疊了。如下圖所示,number 和 stepSize 都指向記憶體中的同一位置, 讀取和寫入通路引用相同的記憶體,并且它們重疊,進而産生了沖突。

Swift | 記憶體安全

解決這種沖突的一個辦法是顯式複制 stepSize:

// Make an explicit copy.
var copyOfStepSize = stepSize
increment(&copyOfStepSize)

// Update the original.
stepSize = copyOfStepSize
// stepSize is now 2           

複制

當你在調用 increment(_:) 之前就複制 stepSize 的話,很明顯 copyOfStepSize 會在目前基礎上增加。讀取通路在寫入通路開始之前結束,是以沒有沖突。

另一個對 in-out 函數使用長期通路會産生的問題是,當你将單個變量作為同一函數的多個 in-out 參數來傳遞時,會産生沖突。舉個例子:

func balance(_ x: inout Int, _ y: inout Int) {
    let sum = x + y
    x = sum / 2
    y = sum - x
}
var playerOneScore = 42
var playerTwoScore = 30
balance(&playerOneScore, &playerTwoScore)  // OK
balance(&playerOneScore, &playerOneScore)
// Error: conflicting accesses to playerOneScore           

複制

上述 balance(_:_:) 函數的作用是修改兩個參數的值以讓他們平均配置設定,使用 playerOneScore 和 playerTwoScore 作為參數時不會産生沖突(雖然它們有兩個時間重疊的寫入通路,但是他們通路的是記憶體中的不同位置)。相反,将 playerOneScore 作為兩個參數的值傳遞會産生沖突,因為它試圖同時對記憶體中的同一位置執行兩次寫入通路。

注意:

因為運算符也是函數,是以他們也可以進行帶有 in-out 參數的長期通路。例如,如果 balance(_:_:) 是名為 <^> 的運算符,則編寫 playerOneScore <^> playerOneScore 将産生與 balance(&playerOneScore, &playerOneScore)一樣的沖突錯誤。

5. 在函數中通路自身導緻的沖突

一個結構體中的 mutating 方法被調用期間,他是可以對它的 self 進行寫入通路的。例如,有一個遊戲中,每個玩家受傷時健康值會減少,在用技能時能量值會減少。

struct Player {
    var name: String
    var health: Int
    var energy: Int

    static let maxHealth = 10
    mutating func restoreHealth() {
        health = Player.maxHealth
    }
}           

複制

在上面的 restoreHealth() 方法中,對 self 的寫入通路從該方法的開頭開始,一直持續到該方法傳回為止。在這種情況下,restoreHealth() 中沒有其他代碼可以重疊通路 Player 執行個體的屬性。而下面的 shareHealth(with:) 方法将另一個 Player 執行個體用作 in-out 參數,進而可能導緻通路重疊。

extension Player {
    mutating func shareHealth(with teammate: inout Player) {
        balance(&teammate.health, &health)
    }
}

var oscar = Player(name: "Oscar", health: 10, energy: 10)
var maria = Player(name: "Maria", health: 5, energy: 10)
oscar.shareHealth(with: &maria)  // OK           

複制

在上面的示例中,調用 Oscar 與 Maria 共享生命值的 shareHealth(with:) 方法不會引起沖突。在方法調用過程中,對 oscar 有寫入通路,因為 oscar 是 mutating 方法中 self 的值,并且與 maria 的寫入通路的持續時間是一緻的,因為 maria 是作為 in-out 參數傳遞的。如下圖所示,你可以看到它們通路記憶體中的不同位置。是以即使兩個寫通路在時間上重疊,也不會沖突。

Swift | 記憶體安全

但是,如果你傳遞一個 oscar 作為 shareHealth(with:) 的參數,就會産生沖突:

oscar.shareHealth(with: &oscar)
// Error: conflicting accesses to oscar           

複制

這個 mutating 方法需要在方法持續時間内對 self 進行寫入通路,而 in-out 參數需要在相同持續時間内對 teammate 進行寫入通路。在該方法中,自己和隊友都指向記憶體中的同一位置--如下圖所示。這兩個寫入通路引用相同的記憶體,并且它們重疊,進而産生了沖突。

Swift | 記憶體安全

6. 通路屬性時的沖突

類似于結構體、枚舉和元組這些類型都是由堵路的組合值組成的,例如結構體的屬性,或者是元組的元素。因為這些都是值類型,是以對值類型的任何部分的修改都會使整個值發生更改,這意味着對某一個屬性的讀取或者寫入操作是需要去對整個值讀取或者寫入。例如,對元組元素的重疊寫入通路會産生沖突:

var playerInformation = (health: 10, energy: 20)
balance(&playerInformation.health, &playerInformation.energy)
// Error: conflicting access to properties of playerInformation           

複制

在上述的例子中,對元組的元素調用 blance(_:_:) 會産生沖突,這是因為他們在對 playerInformation 的寫入通路存在重疊。playerInformation.health 和 playerInformation.energy 都是作為 in-out 參數被傳入的,這意味着 balance(_:_:) 需要在函數調用期間對其進行寫入通路。在這兩種情況下,對元組元素的寫入通路都需要對整個元組區進行寫入通路。那就是說有兩個對 playerInformation 的寫入通路,并且持續時間重疊,進而導緻沖突。

下面的代碼展示了一個類似的錯誤,出現在對一個全局變量結構體的屬性進行重疊寫入通路。

var holly = Player(name: "Holly", health: 10, energy: 10)
balance(&holly.health, &holly.energy)  // Error           

複制

事實上,對結構屬性的大多數重疊通路都是安全的。例如,如果在上面的示例中将變量 holly 更改為局部變量而不是全局變量,則編譯器是正常工作的,證明了對結構體的存儲屬性的重疊通路是安全的:

func someFunction() {
    var oscar = Player(name: "Oscar", health: 10, energy: 10)
    balance(&oscar.health, &oscar.energy)  // OK
}           

複制

在上面的示例中,Oscar 的 health 和 energy 作為兩個 in-out 參數傳遞給了 balance(_:_:)。編譯器可以證明這樣是記憶體安全的,這兩個存儲的屬性不會以任何方式互動。

在保護記憶體安全時,限制結構體屬性的重複通路并非是必須的。記憶體安全是理想的保證,但是獨占通路是一個比記憶體安全更嚴格的要求--這意味着即使有一些代碼違反了獨占通路的要求,它也可以是符合記憶體安全的要求的。如果編譯器可以證明對記憶體的非獨占通路仍然是安全的,則 Swift 允許使用這種僅做到了記憶體安全的代碼。特别指出,如果滿足以下條件,那就可以證明重疊通路某結構體的屬性是安全的:

  • 你隻通路了執行個體的存儲屬性,而不是計算屬性或類屬性
  • 這個結構體是局部變量而不是全局變量
  • 這個結構體要麼沒有被任何閉包捕獲,要麼隻被非逃逸閉包捕獲

如果編譯器無法證明這個通路是安全的,則它是不被允許進行通路的。