内存安全(Memory Safety)

216 阅读10分钟

默认情况下,swift防止你的代码中不安全的表现发生。例如,swift确保变量在你使用之前初始化,在内存被释放之后不能访问,检查数组越界错误。

swift也确保多个对相同区域内存的访问不会冲突,通过要求修改内存中一个位置的代码有对那个内存完全独有的访问。因为swift自动管理内存,大多数时间你完全不需要考虑访问内存。不过,理解哪里会发生潜在的冲突很重要,所以你可以避免写有对内存冲突的访问的代码。如果你的代码包含冲突,你会收到一个编译时或者运行时的错误。

理解内存访问的冲突(Understanding Conflicting Access to Memory)

当你做像设置一个变量的值或者给函数传递一个参数这样的事的时候在你的代码中会发生访问内存。例如,下面的代码包含一个读内存和一个写内存:

// 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确保会给你一个编译或者运行时错误。对于多线程代码,使用Thread Sanitizer来帮助获取多线程的冲突访问。

内存访问特性(Characteristics of Memory Access)

冲突访问的环境中有三个要考虑的内存访问:访问是读还是写,访问的期间,被访问内存的地址。特别的是,如果你有满足下面全部三个条件的两个访问会发生冲突:

  • 至少一个写访问
  • 访问内从中一样的地址
  • 他们的期间重叠

读和写访问的区别很明显:写访问修改内存的地址,但是读操作不改变。内存中的地址引用正在访问的--例如,一个变量,常量,或者属性。内存访问的期间是瞬间的或者长期的:

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

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

不过有多种方式来访问内存,名为长期访问,跨越其他代码执行。瞬时访问和长期访问的区别是在长期访问开始之后结束之前可能其他代码运行,叫做overlap。一个长期访问可以重叠其他长期访问和瞬时访问。

重叠访问主要出现在使用in-out参数的函数和方法或者结构体的可变参数的代码中。使用长期访问的swift代码的特殊种类在后面章节讨论。

对In-Out参数的冲突访问(Conflicting Access to In-Out Parameters)

一个函数对它的全部的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的值递增了现在的step大小。读操作在写作做之前结束,所以没有冲突。

另一个对于iin-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(_:_:)函数修改了它的两个参数来平分他们之间的总值。用playerOneScore和playerTwoScore作为参数调用它不会产生冲突--同时重叠的有两个写访问,但是他们访问不同内存地址。相对的,作为一个值给两个参数传递playerOneScore产生一个冲突,因为他尝试同时对同一内存地址进行写访问。

因为操作符是函数,他们对他们的in-out参数也可以有长期访问。例如,如果banlance(_:_:)是一个操作符函数名为<^>,写playerOneScore<^>playerOneScore会导致和balance(&layerOneScore,&playerOneScore)一样的冲突。

方法中对self的冲突访问(Conflicting Access to self in Methods)

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

上面的例子中,调用Oscar的玩家的shareHealth(with:)方法来和Maria的玩家分享健康不会引起冲突。在方法调用的时候有对Oscar的写访问因为Oscar在一个可变方法中是self的值,同时有一个对Maria的写访问因为Maria作为in-out参数传递进来。像下面图片展示的,他们访问内存中不同的地址。即使两个写访问同一时间重叠,他们不会冲突。


不过,若果你把Oscar作为参数传给shareHEalth(with:),会有一个冲突:

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

mutating 方法在方法的期间需要self的写访问,in-out参数同时也需要对teammate的写访问。在方法中,self和teammate都引用相同的内存地址--像下面图片展示的。两个写访问引用相同的内存并且他们重叠,产生一个冲突。


访问属性的冲突(Conflicting Access to Properties)

像结构体,元祖和枚举的类型由独立的内部值组合而成,例如结体的属性或者元祖的元素。因为这些是值类型,修改任何部分的值会修改整个值,意味着对一个属性的读或者写访问需要对整个值的读或者写访问。例如,对元祖的元素的写访问重叠会产生一个冲突:

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

上面的例子中,在元祖的元素上调用balance(_:_:)产生一个冲突是因为有对playerInformation的重叠的写访问。playerInformation.health和playerinformation.energy作为in-out参数传递。两种情况中,对一个元祖的元素的写操作需要对整个元祖的写操作。这意味着在重叠期间内有两个对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的健康和能量作为两个in-out参数传给balance(_:_)。比那一起可以证明保护了内存安全是因为两个存储属性没有交互。

防止对结构体的属性的重叠访问不是总被需要来保持内存安全。内存安全是期望的保证,但是独有的访问是比内存安全更严格的要求--意味着一些代码保护内存安全,即使它违反了独有的内存访问。swift允许这个内存安全代码是如果编译器可以证明非独有的内存访问仍然是安全的。特殊的,他可以证明结构体的属性的重叠访问是安全的如果下面的条件符合:

  • 你只访问实例的存储属性,不是计算属性或者类属性
  • 结构体是本地变量而不是全局变量
  • 结构体没有被任何闭包捕获或者他只被非逃逸的闭包捕获

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