内存安全
通过合理地组织代码来避免内存访问冲突。
默认情况下,Swift 会阻止代码中不安全的行为。例如,Swift 会确保所有变量都在使用前被初始化、内存不可在被释放后访问、还会对数组的下标做越界检查。
Swift 会要求修改内存的代码拥有对被修改区域的独占访问权,以确保对同一块内存区域的多次访问不会产生冲突。由于 Swift 会自动管理内存,大多数情况下你不需要考虑内存访问相关的问题。但是,为了避免编写出会产生内存访问冲突的代码,你还是很有必要了解什么情况下会出现这种问题。如果你的代码有内存访问冲突的问题,系统会在编译时或运行时报错。
理解内存访问冲突
在进行变量赋值、函数传参这样的操作时,你的代码会访问内存。举个例子,下面的代码包含了一次读操作和一次写操作:
// 向 one 所在的内存区域发起一次写操作
var one = 1
// 向 one 所在的内存区域发起一次读操作
print("We're number \(one)!")
当多个不同地方的代码试图访问同一块内存区域时,访问冲突就有可能出现。对一块内存区域的同时访问可能导致程序出现无法预测或不稳定的行为。Swift 中有许多修改数值的方式,其中一些会横跨多行代码,这意味着修改某个数值的过程本身也有可能产生对此数值的访问。
要理解这个问题,你可以尝试想象一下在纸上更新一个预算表的流程。更新预算表分为两步:第一步你需要先添加每个预算项目的名字和数额,第二步才是更新预算总额。在整个更新流程的之前及之后,你可以从预算中读取任何信息,而这些信息都是正确的,就像下图所示一样。
但是,在你向预算表添加项目的过程中,它会短暂地处于一个临时、不合法的状态,因为预算总额此时还没有被更新以反映这些新添加的项目。在项目添加的过程中读取到的总额是不正确的。
这个例子还展示了一个在你修复内存访问冲突问题中会遇到的挑战:有时可能存在多种修复冲突的方式,但它们最终产生的结果并不相同,且很难确定到底哪种结果是符合预期的。在这个例子中,$5 或者 $320 都可以是正确答案 —— 这取决于你的「预期」是读取到更新前的总额还是更新后的总额。在你修复访问冲突之前,你需要先明确内存访问的目的和预期结果是什么。
内存访问的特点
在访问冲突的语境下,我们需要考虑内存访问的三个特点:此次访问是读还是写、访问的时长、被访问内存区域的位置。特别地,两次内存访问会在满足以下所有条件时产生冲突:
读操作和写操作之间的区别是显而易见的:写操作会改变内存区域,而读操作不会。「内存区域」指的是被访问的内容(例如变量、常量、属性)。内存访问的要么瞬间完成,要么持续较长时间。
一次访问如果满足以下条件之一则为原子访问:
- 是对 [
Atomic] 或 [AtomicLazyReference] 的原子操作调用 - 仅使用 C 原子操作
否则就是非原子访问。
关于 C 原子函数的列表,请参阅 stdatomic(3) 手册页。
[Atomic]: https://developer.apple.com/documentation/synchronization/atomic
[AtomicLazyReference]: https://developer.apple.com/documentation/synchronization/atomiclazyreference
如果在一次内存访问的过程中没有任何其他代码可以在其开始后、结束前运行,则这次访问是瞬时完成的。其性质决定了两次瞬时访问不可能同时发生。大多数内存访问都是瞬时完成的。比如,下面这段代码中的所有读写操作都是瞬时完成的:
func oneMore(than number: Int) -> Int {
return number + 1
}
var myNumber = 1
myNumber = oneMore(than: myNumber)
print(myNumber)
// Prints "2"
然而,也有其他被称作长时访问的内存访问 —— 它们的执行过程会「横跨」其它代码的执行。长时访问和瞬时访问的区别在于:前者执行开始后、结束前的这段时间内,其它的代码有可能会执行,我们称之为重叠。一次长时访问可以与其它的长时或瞬时访问重叠。
重叠访问通常出现在函数和方法的 in-out 参数以及结构体的变值方法中。下文中会讨论 Swift 中具体哪些类型的代码会使用长时访问。
对 In-Out 参数的访问冲突
一个函数会对它所有的 in-out 参数保持长时写访问。in-out 参数的写访问会在所有非 in-out 参数处理完之后开始,直到函数执行完毕为止。如果存在多个 in-out 参数,则写访问的开始顺序和参数的排列顺序一致。
这种长时保持的写访问带来的问题是:即便作用域和访问权限规则允许,你也不能再访问以 in-out 形式传入的原始变量。这是因为任何访问原始变量的行为都会造成冲突,例如:
var stepSize = 1
func increment(_ number: inout Int) {
number += stepSize
}
increment(&stepSize)
// 错误:stepSize 访问冲突
在上面的代码里,stepSize 是一个全局变量,并且它可以通常可以在 increment(_:) 里被访问。然而,对于 stepSize 的读访问与 number 的写访问重叠了。就像下面展示的那样,number 和 stepSize 都指向了同一个内存区域。针对同一块内存区域的读和写访问重叠了,因此产生了冲突。
其中一个解决冲突的方式是显式地复制一份 stepSize:
// 显式复制
var copyOfStepSize = stepSize
increment(©OfStepSize)
// 更新原来的值
stepSize = copyOfStepSize
// stepSize 现在的值是 2
由于你在调用 increment(_:) 前复制了 stepSize,显然 copyOfStepSize 会以当前 stepSize 的值增加。读访问在写访问开始前就结束了,所以不会产生冲突。
对于 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)
// 错误:playerOneScore 访问冲突
上面的 balance(::) 函数会将传入的两个参数平均化。将 playerOneScore 和 playerTwoScore 作为参数传入不会产生错误 —— 虽然这两个写访问在时间上重叠了,但它们访问的是不同的内存位置;相反,将 playerOneScore 同时传入两个参数则会冲突,因为这样会发起两次在时间上重叠、针对同一内存位置的写访问。
方法中的 self 访问冲突
一个结构体的变值(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
在上面的例子中,通过调用 shareHealth(with:) 方法来将 Oscar 的生命值分享给 Maria 并不会造成冲突。因为 oscar 是变值方法中 self 所对应的值,所以方法执行过程存在对于 oscar 的写访问;在相同的时间窗口内,方法对于 maria 也会有写访问,因为 maria 是作为 in-out 参数传入的。尽管这两次写访问在时间上发生了重叠,它们并不冲突。
然而,如果你将 oscar 所谓参数传入 shareHealth(with:),就会产生冲突了:
oscar.shareHealth(with: &oscar)
// 错误:对 oscar 的访问出现冲突
在整个变值方法执行期间,方法不仅需要对保持对 self 的写访问,其 in-out 参数还需要对 teammate 保持相同时长的写访问。在方法内,self 和 teammate 都指向了内存中的同一位置(如下图所示)。因为两次写访问不仅在时间上重叠,访问的内存区域也重叠了,所以产生了冲突。
属性访问冲突
结构体、元组、枚举这样的类型是由多个独立的值构成的(例如结构体的属性、元组的元素)。它们都是值类型,所以修改值的任何一部分都是对于整个值的修改。这意味着对于其中任何一个属性的读或写访问,都需要对于整个值的访问。例如,对于元组元素的重叠写访问会造成冲突:
var playerInformation = (health: 10, energy: 20)
balance(&playerInformation.health, &playerInformation.energy)
// 错误:对 playerInformation 的属性访问有冲突
在上面的示例中,因为出现了对于 playerInformation 的重叠写访问,对元组中的元素调用 balance(::) 就会产生冲突。playerInformation.health 和 playerInformation.energy 都被作为 in-out 参数被传入了,这意味着 balance(::) 在执行期间需要保持对它们的写访问,而这又意味着 balance(::) 需要保持两次对 playerInformation 整体的重叠写访问,这样一来就发生了访问冲突。
下方的代码展示了对一个存储在全局变量中的结构体的重叠写访问,这会导致相同的错误。
var holly = Player(name: "Holly", health: 10, energy: 10)
balance(&holly.health, &holly.energy) // 错误
在实践中,大多数时候对于结构体属性的重叠访问是安全的。举个例子,如果上方例子中的变量 holly 是一个本地变量而非全局变量,编译器可以证明对于该结构体的属性的重叠访问是安全的:
func someFunction() {
var oscar = Player(name: "Oscar", health: 10, energy: 10)
balance(&oscar.health, &oscar.energy) // OK
}
在上方的例子中,Oscar 的生命值和能量值被作为两个 in-out 参数传递给了 balance(::)。此时编译器能够证明内存是安全的,因为这两个属性并不会以任何方式交互。
要保证内存安全,限制结构体属性的重叠访问并不总是必要的。内存安全性是我们想获得的一种保证,但是「独占访问」是相比「内存安全」更加严格的一种要求 —— 这意味着即使有些代码违反了「独占访问」的原则,它也可以是内存安全的。只要编译器可以「证明」这种非专属的访问是内存安全的,Swift 就会允许这样的代码存在。特别地,在以下条件满足时,编译器就可以证明对结构体属性的重叠访问是安全的:
如果编译器无法证明访问安全性,它就会拒绝访问。
[Atomic]: https://developer.apple.com/documentation/synchronization/atomic
[AtomicLazyReference]: https://developer.apple.com/documentation/synchronization/atomiclazyreference