天天看點

[譯] Swift 5 強制獨占性原則

  • 原文位址:Swift 5 Exclusivity Enforcement
  • 原文作者:swift.org
  • 譯文出自:掘金翻譯計劃
  • 本文永久連結:github.com/xitu/gold-m…
  • 譯者:LoneyIsError
  • 校對者:Bruce-pac, Danny1451
在了解概念時參照了喵神的所有權宣言 - Swift 官方文章 Ownership Manifesto 譯文評注版

Swift 5 允許在 Release 建構過程中預設啟用關于「獨占通路記憶體」的運作時檢查,進一步增強了 Swift 作為安全語言的能力。在 Swift 4 中,這種運作時檢查僅允許在 Debug 建構過程中啟用。在這篇文章中,首先我将解釋這個變化對 Swift 開發人員的意義,然後再深入研究為什麼它對 Swift 的安全和性能政策至關重要。

背景

為了實作 記憶體安全,Swift 需要對變量進行獨占通路時才能修改該變量。本質上來說,當一個變量作為

inout

參數或者

mutating

方法中的

self

被修改時,不能通過不同的名稱被通路的。

在以下示例中,

modifyTwice

函數通過将

count

作為

inout

參數傳入來對它進行修改。出現獨占性違規情況是因為,在

count

變量被修改的作用域内,

modifier

閉包對

count

在變量進行讀取操作的同時也被調用了。在

modifyTwice

函數中,

count

變量隻能通過

inout

修飾的

value

參數來進行安全通路,而在

modifier

閉包内,它隻能以

$0

來進行安全通路。

func modifyTwice(_ value: inout Int, by modifier: (inout Int) -> ()) {
  modifier(&value)
  modifier(&value)
}

func testCount() {
  var count = 1
  modifyTwice(&count) { $0 += count }
  print(count)
}
複制代碼           

違反獨占性的情況通常如此,程式員的意圖此時顯得有些模糊。他們希望

count

列印的值是「3」還是「4」呢?無論哪種結果,編譯器都無法保證。更糟糕的是,編譯器優化會在出現此類錯誤時産生微妙的不可預測行為。為了防止違反獨占性并允許引入依賴于安全保證的語言特性,強制獨占性最初在 Swift 4.0 中引入的:SE-0176:實施對記憶體的獨占通路。

編譯時(靜态)檢測可以捕獲許多常見的獨占性違規行為,但是還需要運作時(動态)檢測來捕獲涉及逃逸閉包,類類型的屬性,靜态屬性和全局變量的違規情況。Swift 4.0 同時提供了編譯時和運作時的強制性檢測,但運作時的強制檢測僅在 Debug 建構過程中啟用。

在 Swift 4.1 和 4.2 中,編譯器檢查能力逐漸得到加強,可以捕獲到越來越多程式員繞過獨占性規則的情況 —— 最明顯的是在非逃逸閉包中捕獲變量,或者将非逃逸閉包轉換為逃逸閉包。Swift 4.2 宣稱,在 Swift 4.2 中将獨占通路記憶體警告更新為錯誤,并解釋了一些受新強制獨占性檢測影響的常見案例。

Swift 5 修複了語言模型中剩餘的漏洞,并完全執行了該模型。 由于在 Release 編譯過程中預設啟用了對記憶體獨占情況的強制性運作時檢查,一些以前表現得很好的但未在 Debug 模式下被充分測試的 Swift 程式可能會受到一些影響.

一些罕見的還無法被編譯器檢測出來的涉及非法代碼的情況(SR-8546,SR-9043)。

對 Swift 項目的影響

Swift 5 中的強制獨占性檢查對現有項目可能會産生以下兩種影響:

  1. 如果項目源碼違反了 Swift 的獨占性規則(具體檢視 SE-0176:實施對記憶體的獨占通路),Debug 調試測試時未能執行無效代碼,然後,在建構 Release 二進制檔案時可能會觸發運作時陷阱。産生崩潰并抛出一個包含字元串的診斷消息:

    「Simultaneous accesses to …, but modification requires exclusive access」

    源代碼級别修複通常很簡單。後面的章節會展示常見的違規和修複示例。

  2. 記憶體通路檢查的開銷可能會影響的 Release 二進制包的性能。在大多數情況下,這種影響應該很小;如果你發現某個明顯的性能下降情況,請送出 bug,以便我們了解需要改進的内容。作為一般性準則,應當避免在大多數性能關鍵循環中執行類屬性通路,特别是在每個循環疊代中的不同對象上。如果必須如此,那麼你可以将類屬性修飾為

    private

    internal

    來幫助告知編譯器沒有其他代碼通路循環内的相同屬性。

你可以通過 Xcode 的「Exclusive Access to Memory」建構設定來禁用這些運作時檢查,該設定還有「Run-time Checks in Debug Builds Only」和「Compile-time Enforcement Only」兩個選項:

相對應的 swiftc 編譯器标志是

-enforce-exclusivity = unchecked

-enforce-exclusivity = none

雖然禁用運作時檢查可能會解決性能下降問題,但這并不意味着違反獨占性是安全的。如果沒有啟用強制執行,程式員就必須承擔遵守獨占性規則的責任。強烈建議不要在建構 Release 包時禁用運作時檢查,因為如果程式違反獨占他性原則,則可能會出現不可預測的結果,包括崩潰或記憶體損壞。即使程式現在似乎能正常運作,未來的 Swift 版本也可能導緻出現其他不可預測的情況,并且可能會暴露安全漏洞。

示例

在背景部分中的「testCount」示例中通過将局部變量作為

inout

參數來傳遞,與此同時在閉包中捕獲它來違反了獨占性原則。編譯器在建構時檢測到這一段時,就會如下面的螢幕截圖所示:

通常可以通過添加

let

來簡單地修複

inout

參數的違規情況:

let incrementBy = count
modifyTwice(&count) { $0 += incrementBy }
複制代碼           

下一個示例可能會在

mutating

方法中同時修改

self

,進而産生異常。

append(removingFrom:)

方法通過删除另一個數組中所有元素來增加數組元素:

extension Array {
    mutating func append(removingFrom other: inout Array<Element>) {
        while !other.isEmpty {
            self.append(other.removeLast())
        }
    }
}
複制代碼           

但是,使用此方法将自身數組中的所有元素添加到自身将引發意外情況 —— 死循環。在這裡,編譯器在建構時再次抛出異常,因為「inout arguments are not allowed to alias each other」:

為了避免這些同時修改,可以将局部變量複制到另一個

var

中,然後作為

inout

參數傳遞給 mutating 方法:

var toAppend = elements
elements.append(removingFrom: &toAppend)
複制代碼           

現在,這兩個修改方法對不同的變量進行修改,是以沒有産生沖突。

可以在 在 Swift 4.2 中将獨占通路記憶體警告更新為錯誤 中找到導緻建構錯誤的一些常見情況的示例。

通過更改第一個示例,使用全局變量而不是局部變量,可以防止編譯器在建構時抛出錯誤。然而,運作程式會命中「Simultaneous access」的檢查:

如示例中所示,在許多情況下,沖突通路發生在不同的語句中。

struct Point {
    var x: Int = 0
    var y: Int = 0

    mutating func modifyX(_ body:(inout Int) -> ()) {
        body(&x)
    }
}

var point = Point()

let getY = { return point.y  }

// Copy `y`'s value into `x`.
point.modifyX {
    $0 = getY()
}
複制代碼           

運作時檢測捕獲了在開始調用

modifyX

時的通路資訊,以及在

getY

閉包内發生沖突的通路資訊,以及顯示了導緻沖突的堆棧資訊:

Simultaneous accesses to ..., but modification requires exclusive access.
Previous access (a modification) started at Example`main + ....
Current access (a read) started at:
0    swift_beginAccess
1    closure #1
2    closure #2
3    Point.modifyX(_:)
Fatal access conflict detected.
複制代碼           

Xcode 首先确定了内部通路沖突:

從側邊欄中目前線程的視圖中選擇「上一次通路」來确定外部修改:

通過複制閉包中所需要用的任何值,可以避免獨占性違規:

let y = point.y
point.modifyX {
    $0 = y
}
複制代碼           

如果這是在沒有 getter 和 setter 的情況下編寫的:

point.x = point.y
複制代碼           

…那麼就不存在獨占性違規,因為在一個簡單的指派中(沒有

inout

參數),修改是瞬間的。

在這一點上,讀者可能想知道為什麼在讀寫兩個單獨的屬性時,原始示例被視為違反獨占性規則;

point.x

point.y

。因為

Point

被聲明為

struct

,它被認為是一個值類型,這意味着它的所有屬性都是整個值的一部分,通路任何一個屬性都會通路整個值。當通過簡單的靜态分析可以證明安全性時,編譯器會對此規則進行例外處理。 特别是,當同一語句發起對兩個不相交存儲的屬性通路時,編譯器會避免抛出違反獨占性的報告。在下一個示例中,先調用

modifyX

的方法通路

point

,以便立即将其屬性

x

作為

inout

傳遞。然後用相同的語句再次通路

point

,以便在閉包中捕獲它。因為編譯器可以立即看到捕獲的值隻用于通路屬性

y

,是以沒有錯誤。

func modifyX(x: inout Int, updater: (Int)->Int) {
  x = updater(x)
}

func testDisjointStructProperties(point: inout Point) {
  modifyX(x: &point.x) { // First `point` access
    let oldy = point.y   // Second `point` access
    point.y = $0;        // ...allowed as an exception to the rule.
    return oldy
  }
}
複制代碼           

屬性可以分為三類:

  1. 值類型的執行個體屬性
  2. 引用類型的執行個體屬性
  3. 任意類型的靜态和類屬性

隻有對第一類屬性(執行個體屬性)的修改才會要求對聚合值的整體存儲具有獨占性通路,如上面的

struct Point

示例所示。另外兩種類别可以作為獨立存儲分别執行。 如果這個例子被轉換成一個類對象,那麼将不會違反獨占性原則:

class SharedPoint {
    var x: Int = 0
    var y: Int = 0

    func modifyX(_ body:(inout Int) -> ()) {
        body(&x)
    }
}

var point = SharedPoint()

let getY = { return point.y  } // no longer a violation when called within modifyX

// Copy `y`'s value into `x`.
point.modifyX {
    $0 = getY()
}
複制代碼           

目的

上述編譯時和運作時獨占性檢查的結合對于加強 Swift 的 記憶體安全 是很必要的。完全執行這些規則,而不是讓程式員承擔遵守獨占性規則的負擔,至少有以下五種幫助:

  1. 執行獨占性檢查消除了程式涉及可變狀态和遠距離動作的危險互動。

    随着程式規模的不斷擴大,越來越可能會以意想不到的方式進行互動。下面的例子在類似于上面的

    Array.append(removedFrom:)

    例子,需要執行獨占性檢查來避免程式員将相同的變量同時作為源資料和目标資料進行傳遞。但請注意,一旦涉及到類對象,因為這兩個變量引用了同一個對象,程式就會在無意中更容易在

    src

    dest

    位置上傳遞同一個的

    Names

    執行個體。當然,這樣就會導緻死循環:
func moveElements(from src: inout Set<String>, to dest: inout Set<String>) {
    while let e = src.popFirst() {
        dest.insert(e)
    }
}
 
class Names {
    var nameSet: Set<String> = []
}
 
func moveNames(from src: Names, to dest: Names) {
    moveElements(from: &src.nameSet, to: &dest.nameSet)
}
 
var oldNames = Names()
var newNames = oldNames // Aliasing naturally happens with reference types.
 
moveNames(from: oldNames, to: newNames)
複制代碼           

SE-0176:實施對記憶體的獨占通路 更深入地描述了這個問題。

  1. 執行獨占性檢查消除了語言中未指定的行為規則。

    在 Swift 4 之前,獨占性對于明确定義的程式行為是必要的,但規則是不受限制的。在實踐中,人們很容易以微妙的方式違反這些規則,使程式容易受到不可預測的行為的影響,特别是在編譯器的各個釋出版本中。

  2. 執行獨占性檢查是穩定 ABI 的必要條件。

    未能完全執行獨占性檢查将會對 ABI 的穩定性産生不可預測的影響。在沒有進行完全檢查的情況下建構的現有二進制檔案可能在某一個版本中能夠正常運作,但在未來的編譯器版本、标準庫和運作時中無法正确運作。

  3. 執行獨占性檢查使性能優化更合法,同時保護記憶體安全。

    inout

    參數和

    mutating

    方法的獨占性檢查向編譯器提供了重要資訊,可用于優化記憶體通路和引用計數操作。鑒于 Swift 是一種記憶體安全語言,如上面第2點所述,簡單地聲明一個未指定的行為規則對于編譯器來說是不夠的。完全強制執行獨占性檢查允許編譯器基于記憶體獨占性進行優化,而不會犧牲記憶體安全性。
  4. 獨占性規則為程式員提供所有權和僅移動類型的控制權。

    在 Swift 的 所有權宣言 中新增了 獨占性原則,并解釋了它如何為語言添加所有權和僅限移動類型提供依據。

總結

通過在 Release 建構過程中強制啟動完全獨占性檢查,Swift 5 有助于消除錯誤和安全性問題,確定二進制相容性,并支援未來的優化和語言功能。

還有疑問?

請随時在 Swift 論壇的 相關主題 上釋出相關的問題。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改并 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。内容覆寫 Android、iOS、前端、後端、區塊鍊、産品、設計、人工智能等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微網誌、知乎專欄。

轉載于:https://juejin.im/post/5c778b5e6fb9a049c64487e0

繼續閱讀