默认情况下,Swift 防止在你的代码中发生不安全的行为。例如,Swift 确保变量在使用前已被初始化,内存不会在被释放后被访问,并且数组索引会被检查以防止越界错误。
Swift 还通过要求修改内存中某个位置的代码拥有对该内存的独占访问权,来确保同一内存区域不会发生冲突。由于 Swift 会自动管理内存,大多数情况下你不需要考虑访问内存。然而,了解潜在冲突可能发生的地方很重要,这样你就可以避免编写对内存有冲突访问的代码。如果你的代码确实存在冲突,你将在编译时或运行时收到错误。
理解对内存的冲突访问
访问内存发生在你像设置变量的值或向函数传递参数这样的操作时。例如,以下代码包含读取访问和写入访问:
// 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 保证你将在编译时或运行时得到一个错误。对于多线程代码,可以使用线程 sanitizer 来帮助检测跨线程的冲突访问。
内存访问的特性
在考虑冲突访问时,需要考虑内存访问的三个特征:访问是读取还是写入,访问的持续时间以及访问的内存位置。具体来说,如果满足以下所有条件,则会发生冲突:
- 这些访问不是同时都是读取,也不是同时都是原子操作。
- 它们访问的是同一内存位置。
- 它们的持续时间重叠。
读取和写入访问之间的区别通常很明显:写入访问会改变内存中的位置,但读取访问不会。内存中的位置指的是被访问的内容——例如,一个变量、常量或属性。内存访问的持续时间要么是瞬时的,要么是长期的。
如果访问是调用原子操作 Atomic
或 AtomicLazyReference
的调用,或者仅使用 C 原子操作,则该访问是原子的;否则,该访问是非原子的。有关 C 原子函数的列表,请参阅 stdatomic(3)
man 页。
如果其他代码在该访问开始后但在结束前无法运行,则该访问是瞬时的。由于其性质,两个瞬时访问不能同时发生。大多数内存访问都是瞬时的。例如,下面代码列表中的所有读取和写入访问都是瞬时的。
func oneMore(than number: Int) -> Int {
return number + 1
}
var myNumber = 1
myNumber = oneMore(than: myNumber)
print(myNumber)
// Prints "2"
然而,还有一些称为长期访问的方式来访问内存,这些访问跨越了其他代码的执行。即时访问和长期访问之间的区别在于,在长期访问开始但尚未结束之前,其他代码可以运行,这称为重叠。长期访问可以与其他长期访问和即时访问重叠。
重叠的访问主要出现在使用函数和方法中的输入输出参数或结构的突变方法的代码中。下面的章节将讨论使用长期访问的具体种类的 Swift 代码。
输入输出参数的冲突访问
函数对其所有输入输出参数具有长期写入访问权限。输入输出参数的写入访问始于所有非输入输出参数被评估之后,并持续整个函数调用的整个过程。如果有多个输入输出参数,写入访问将按照参数出现的顺序开始。
这种长期写入访问的一个后果是,即使作用域规则和访问控制允许这样做,你也不能访问最初作为传址参数传递的变量,因为任何对原始变量的访问都会产生冲突。例如:
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(©OfStepSize)
// Update the original.
stepSize = copyOfStepSize
// stepSize is now 2
当您在调用 increment(_:)
之前复制 stepSize
时,很明显 copyOfStepSize
的值会增加当前步长。读取访问在写入访问开始之前结束,所以不存在冲突。
另一个长期对输入输出参数进行写操作的后果是,将单个变量作为同一函数的多个输入输出参数的参数使用会产生冲突。例如:
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)
相同的冲突。
冲突的访问 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
的写访问,因为 oscar
是在修改方法中 self
的值,同时在相同的时间段内,对 maria
也存在写访问,因为 maria
是以 inout 参数传递的。如下面的图所示,它们访问的是内存中的不同位置。尽管两种写访问在时间上重叠,但它们不会冲突。
但是,如果你将 oscar
作为参数传递给 shareHealth(with:)
,就会出现冲突:
oscar.shareHealth(with: &oscar)
// Error: conflicting accesses to oscar
变异方法在方法执行期间需要对 self
进行写访问,而 in-out 参数在相同的时间段内需要对 teammate
进行写访问。在方法内部,self
和 teammate
都指向内存中的同一位置——如下面的图所示。两个写访问指向同一内存并且重叠,从而产生冲突。
冲突的访问属性
类型如结构体、元组和枚举是由单独的组成部分值组成的,例如结构体的属性或元组的元素。由于这些是值类型,修改值中的任何部分都会修改整个值,这意味着读取或写入一个属性需要读取或写入整个值。例如,对元组元素的重叠写入访问会产生冲突:
var playerInformation = (health: 10, energy: 20)
balance(&playerInformation.health, &playerInformation.energy)
// Error: conflicting access to properties of playerInformation
在上面的例子中,对元组元素调用 balance(_:_:)
会产生冲突,因为存在对 playerInformation
的重叠写入访问。playerInformation.health
和 playerInformation.energy
都作为 inout 参数传递,这意味着 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
}
在上面的示例中,奥斯卡的健康和能量作为两个 in-out 参数传递给 balance(_:_:)
。编译器可以证明内存安全性得以保留,因为这两个存储属性之间没有任何交互。
对结构属性的重叠访问的限制并不总是为了保持内存安全性所必需的。内存安全性是期望的保证,但独占访问比内存安全性更为严格的要求——这意味着一些代码可以保持内存安全性,即使它违反了对内存的独占访问。Swift 允许这种内存安全的代码,如果编译器可以证明非独占访问内存仍然是安全的。具体来说,如果满足以下条件,编译器可以证明对结构属性的安全重叠访问是安全的:
- 你仅访问了实例的存储属性,而不是计算属性或类属性。
- 结构是局部变量的值,而不是全局变量。
- 该结构要么未被捕获任何闭包,要么仅被非脱逃闭包捕获。
如果编译器无法证明访问是安全的,它将不允许该访问。