Swift 5.x - 内存安全(中文文档)

3,043 阅读11分钟

引言

继续学习Swift文档,从上一章节:自动引用计数,我们学习了Swift自动引用计数的内容,主要有iOS通过ARC自动管理内存来实现内存的分配,使用引用计数来判断对象是否需要销毁;还有产生循环引用的情况和解决循环引用的方案。现在,我们学习Swift内存安全的相关内容。由于篇幅较长,这里分篇来记录,接下来,开始吧!

内存安全

默认情况下,Swift可以防止不安全行为在代码中发生。例如,Swift确保变量在使用之前被初始化,内存在释放后不会被访问,并且检查数组索引是否有越界错误。

Swift还通过要求修改内存中某个位置的代码以独占方式访问该内存,从而确保对同一内存区域的多次访问不会发生冲突。因为Swift自动管理内存,所以大多数时候你根本不必考虑访问内存。但是,了解可能发生冲突的地方是很重要的,这样就可以避免编写对内存访问有冲突的代码。如果您的代码确实包含冲突,您将得到一个编译时或运行时错误。

1 了解内存访问冲突

当您执行诸如设置变量的值或将参数传递给函数之类的操作时,就会在代码中访问内存。例如,以下代码同时包含读访问权限和写访问权限:

// 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中,有一些方法可以修改跨越几行代码的值,从而可以尝试在修改过程中访问值。

你可以通过思考如何更新写在纸上的预算来发现类似的问题。更新预算需要两个步骤:首先添加项目的名称和价格,然后更改总额以反映列表中当前的项目。更新前后,您可以从预算中读取任何信息并得到正确答案,如下图所示。

在向预算中添加项目时,预算处于临时、无效状态,因为总金额尚未更新以反映新添加的项目。在添加项目的过程中读取总金额会给您错误的信息。

这个例子还演示了在修复内存访问冲突时可能遇到的一个挑战:有时有多种方法可以修复冲突,但会产生不同的答案,而且并不总是很明显哪个答案是正确的。在本例中,根据您想要的是原始总金额还是更新后的总金额,5美元或320美元可能是正确答案。在修复冲突访问之前,必须确定访问的目的。

注意
如果您编写了并发或多线程代码,冲突的内存访问可能是一个常见的问题。但是,这里讨论的冲突访问可能发生在单个线程上,并且不涉及并发或多线程代码。 如果在一个线程中对内存的访问有冲突,Swift保证在编译时或运行时都会出现错误。对于多线程代码,请使用线程清理器来帮助检测线程间的冲突访问。

1.1 内存访问特性

在冲突访问的上下文中,需要考虑内存访问的三个特征:访问是读还是写、访问的持续时间以及被访问的内存中的位置。特别是,如果您有两个访问满足以下所有条件,则会发生冲突:

  • 至少有一个是写访问。
  • 它们访问内存中相同的位置。
  • 它们的持续时间重叠。

读访问和写访问之间的区别通常很明显:写访问更改内存中的位置,但读访问不会更改。内存中的位置指正在访问的内容,例如变量、常量或属性。内存访问的持续时间可以是瞬时的,也可以是长期的。

如果其他代码不可能在访问开始后但在访问结束之前运行,则访问是即时的。从本质上讲,两个即时访问不能同时发生。大多数内存访问是瞬时的。例如,下面代码列表中的所有读写访问都是瞬时的:

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

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

但是,有几种访问内存的方法,称为长期访问,跨越其他代码的执行。即时存取与长期存取的区别在于,其他程式码有可能在长期存取开始之后但在它结束之前执行,这称为重叠。长期访问可以与其他长期访问和即时访问重叠。

重叠访问主要出现在函数和方法中使用in-out参数的代码中,或者是结构体的可变方法中。使用长期访问的特定类型的Swift代码将在下面的章节中讨论。

1.2 访问In-Out参数冲突

函数对其所有输入输出参数都具有长期写入访问权限。对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都指向内存中的同一个位置。读写访问引用同一个内存,它们重叠,产生冲突。

解决此冲突的一种方法是显式复制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(::)函数修改它的两个参数,以便在它们之间平均分配总值。用playerScore和playerTwoScore作为参数调用它不会产生冲突有两个写入访问在时间上重叠,但它们访问内存中的不同位置。相反,将playerOnScore作为两个参数的值传递会产生冲突,因为它试图同时对内存中的同一位置执行两次写入访问。

注意
因为运算符是函数,所以它们也可以长期访问其in-out参数。例如,如果balance(::)是一个名为<^>的运算符函数,则编写playerOneScore<^>playerOneScore将导致与balance(&playerOneScore,&playerScore)相同的冲突。

2 在方法里访问self的冲突

在方法调用期间,结构体上的可变方法对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

在上面的例子中,调用shareHealth(with:)方法让Oscar的播放器与Maria的播放器共享运行状况不会导致冲突。在方法调用期间有对oscar的写访问,因为oscar是可变方法中self的值,而对maria的写访问在相同的持续时间内,因为maria是作为in-out参数传递的。如下图所示,它们访问内存中的不同位置。即使这两个写访问在时间上重叠,它们并不冲突。

但是,如果将oscar作为参数传递给shareHealth(with:),则会发生冲突:

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

在方法的持续时间内,可变方法需要self的写访问权限,而in-out参数在相同的持续时间内需要对teammate的写访问权限。在这个方法中,self和teammate都引用内存中的同一个位置,如下图所示。两个写访问引用同一个内存,它们重叠,从而产生冲突。

3 访问属性冲突

像结构体、元组和枚举这样的类型是由各个组成值组成的,例如结构体的属性或元组的元素。因为这些都是值类型,所以对值的任何部分进行可变都会使整个值发生变化,这意味着对其中一个属性的读或写访问需要对整个值的读或写访问。例如,对元组元素的写访问重叠会产生冲突:

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

在上面的例子中,对元组的元素调用balance(::)会产生冲突,因为对playerInformation的写访问有重叠。两者兼有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允许这种内存安全代码。具体来说,如果满足以下条件,则可以证明对结构体属性的重叠访问是安全的:

  • 您只访问实例的存储属性,而不是计算属性或类属性。
  • 结构体是局部变量的值,而不是全局变量的值。
  • 这个结构体要么没有被任何闭包捕获,要么只被非屏蔽闭包捕获。

如果编译器不能证明访问是安全的,它就不允许访问。

参考文档:Swift - Memory Safety