Swift 5 独占性强制执行

Swift 5 版本默认在 Release 构建中启用了“内存独占访问”的运行时检查,进一步增强了 Swift 作为安全语言的能力。在 Swift 4 中,这些运行时检查仅在 Debug 构建中启用。在这篇文章中,我将首先向 Swift 开发者解释这一变化意味着什么,然后再深入探讨为什么这对 Swift 的安全性和性能策略至关重要。

背景

为了实现 内存安全,Swift 要求对变量进行独占访问才能修改该变量。本质上,在同一个变量作为 inout 参数或作为 mutating 方法中的 self 被修改的期间,不能通过不同的名称访问该变量。

在以下示例中,count 通过将其作为 inout 参数传递来访问以进行修改。独占性违规发生的原因是 modifier 闭包既读取捕获的 count 变量,又在同一变量修改的范围内被调用。在 modifyTwice 函数内部,count 变量只能通过 value inout 参数安全地访问,并且在 modified 闭包中,它只能作为 $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 修复了语言模型中的剩余漏洞,并完全强制执行该模型1。由于运行时独占性强制执行现在默认在 Release 构建中启用,因此一些以前看起来行为良好,但在 Debug 模式下未经过充分测试的 Swift 程序可能会受到影响。

1一些涉及非法代码的罕见极端情况尚未被编译器诊断出来(SR-8546, SR-9043)。

对 Swift 项目的影响

Swift 5 中的独占性强制执行可能会通过两种方式影响现有项目

  1. 如果项目源代码违反了 Swift 的独占性规则(请参阅 SE-0176:强制内存独占访问),并且 Debug 测试未能执行无效代码,则执行 Release 二进制文件可能会触发运行时陷阱。崩溃将产生一条诊断消息,其中包含字符串

    “同时访问…,但修改需要独占访问”

    源代码级别的修复通常很简单。以下部分显示了常见违规和修复的示例。

  2. 内存访问检查的开销可能会影响 Release 二进制文件的性能。在大多数情况下,影响应该很小;如果您看到可衡量的性能下降,请提交一个 bug,以便我们知道需要改进什么。作为一般准则,避免在性能最关键的循环中执行类属性访问,尤其是在每个循环迭代中对不同对象进行访问。如果无法避免,将类属性设置为 privateinternal 可以帮助编译器证明循环内部没有其他代码访问相同的属性。

这些运行时检查可以通过 Xcode 的 “内存独占访问” 构建设置禁用,该设置具有 “仅在 Debug 构建中进行运行时检查” 和 “仅编译时强制执行” 选项

Xcode exclusivity build setting

相应的 swiftc 编译器标志是 -enforce-exclusivity=unchecked-enforce-exclusivity=none

虽然禁用运行时检查可能可以解决性能下降问题,但这并不意味着独占性违规是安全的。在未启用强制执行的情况下,程序员必须承担遵守独占性规则的责任。强烈建议不要在 Release 构建中禁用运行时检查,因为如果程序违反了独占性,则可能会表现出不可预测的行为,包括崩溃或内存损坏。即使程序今天看起来运行正常,未来版本的 Swift 也可能导致额外的不可预测的行为浮出水面,并且可能会暴露安全漏洞。

示例

背景部分中的 “testCount” 示例通过将局部变量作为 inout 参数传递,同时在闭包中捕获它,从而违反了独占性。编译器在构建时检测到这一点,如下面的屏幕截图所示

testCount error

inout 参数违规通常可以通过添加 let 来轻松修复

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 参数相互别名”

append(removingFrom:) error

为了避免这些同时修改,可以将局部变量复制到另一个 var 中,然后再作为 ‘inout’ 传递给 mutating 方法

var toAppend = elements
elements.append(removingFrom: &toAppend)

现在两个修改都在不同的变量上,因此没有冲突。

一些常见情况下导致构建时错误的示例可以在 将独占访问警告升级为 Swift 4.2 中的错误 中找到。

将第一个示例更改为使用全局变量而不是局部变量可以防止编译器在构建时引发错误。相反,运行程序会陷入 “同时访问” 诊断

global count error

在许多情况下,如下一个示例所示,冲突的访问发生在不同的语句中。

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 首先精确指出内部冲突的访问

Point error: inner position

从侧边栏中当前线程的视图中选择 “上一次访问” 可以精确指出外部修改

Point error: outer position

可以通过复制任何需要在闭包中可用的值来避免独占性违规

let y = point.y
point.modifyX {
    $0 = y
}

如果这是在没有 getter 和 setter 的情况下编写的

point.x = point.y

…那么就不会有独占性违规,因为在简单的赋值中(没有 inout 参数范围),修改是瞬间完成的。

此时,读者可能会想知道,当写入和读取两个单独的属性时,为什么原始示例被认为是违反了独占性;point.xpoint.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(removingFrom:) 示例,其中需要独占性强制执行以防止程序员将同一个变量同时作为移动的源和目标传递。但是请注意,一旦涉及到类,程序就更容易在 srcdest 位置不知不觉地传递 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:强制内存独占访问 更深入地描述了这个问题。

2. 强制执行消除了语言中未指定的行为规则。

在 Swift 4 之前,独占性对于定义良好的程序行为是必要的,但这些规则未被强制执行。在实践中,很容易以微妙的方式违反这些规则,使程序容易受到不可预测的行为的影响,尤其是在编译器的不同版本之间。

3. 强制执行对于 ABI 稳定性是必要的。

未能完全强制执行独占性将对 ABI 稳定性产生不可预测的影响。在没有完全强制执行的情况下构建的现有二进制文件可能在一个版本中运行正常,但在未来版本的编译器、标准库和运行时中表现不正确。

4. 强制执行使性能优化合法化,同时保护内存安全。

inout 参数和 mutating 方法的独占性保证为编译器提供了重要信息,编译器可以使用这些信息来优化内存访问和引用计数操作。考虑到 Swift 是一种内存安全的语言,仅声明未指定的行为规则(如上面第 2 点所述)对于编译器来说是不够的保证。完全独占性强制执行允许编译器在不牺牲内存安全性的情况下基于内存独占性进行优化。

5. 需要独占性规则来让程序员控制所有权和仅移动类型。

所有权宣言 介绍了 独占性定律,并解释了它如何为向语言添加所有权和仅移动类型奠定基础。

结论

通过在 Release 构建中启用完全独占性强制执行进行发布,Swift 5 有助于消除错误和安全问题,确保二进制兼容性,并为未来的优化和语言特性提供支持。

有问题吗?

请随时在 Swift 论坛上的相关帖子中发布关于此帖子的疑问。