本文翻译经过原作者 Mike Ash 同意。

在黑暗的 Swift 1 时代,我写了一篇关于 Swift 中锁和线程安全的文章。时间的流逝使这篇文章过时了,读者 Seth Willits 建议我根据现在的情况更新它,所以有了这篇文章!

这篇文章会重新提到老文章里的一些内容,不过会将它们更新到最新的情况,还会有一些关于它们改变的讨论。阅读这篇文章不要求读过老文章。

锁的快速回顾

锁(lock)或者互斥锁(mutex)是一种保证一段代码在任何时候只有一个线程在执行的结构。它们通常被用来保证多个线程访问同一个可变数据结构时的数据一致性。有这么几个类型的锁:

  1. 阻塞锁(blocking locks)在等待其它线程释放锁时会睡眠线程。阻塞锁是一般情况下使用的锁。
  2. 自旋锁(spinlocks)使用一个忙等循环(busy loop)不断的检查锁是否被释放。自旋锁在等待较少的情况下很高效,但是在等待较多的情况下非常浪费 CPU 时间。
  3. 读写锁(reader/writer locks)允许多个「读」线程同时进入一段代码,但是阻止其他线程(包括读线程)进入当一个「写」线程获取到锁时。在很多数据结构被多个线程同时读取是安全的,但是有多个线程读或写时写数据是不安全的情况下,读写锁是很有用的。
  4. 递归锁(recursive locks)允许同一个线程多次获取同一个锁。非递归锁在被同一个线程重复获取时可能会导致死锁、崩溃或者其他不正常行为。

APIs

Apple 提供了一大堆锁的 API。下面有一个很长但不包含全部锁的列表:

  1. pthread_mutex_t
  2. pthread_rwlock_t
  3. DispatchQueue
  4. OperationQueue 当配置为 serial 时
  5. NSLock
  6. os_unfair_lock

除了这些以外,Objective-C 提供了 @synchronized 语法支持,现在这个时间点它的底层是由 pthread_mutex_t 实现的。不像其他的 API,@synchronized 不使用明确的锁对象,而是把任意的 Objective-C 对象当作锁。一段 @synchronized(someObject) 代码会阻塞其他使用相同对象指针的 @synchronized 代码。这些不同的 API 有着不同的行为和功能:

  1. pthread_mutex_t 是一种阻塞锁,并且可以被配置为递归锁。
  2. pthread_rwlock_t 是一种读写锁。
  3. DispatchQueue 可以被当作阻塞锁使用。也可以通过将其配置为并发队列并配合内存屏障作为读写锁使用。还支持异步执行加锁代码。
  4. OperationQueue 可以被用作阻塞锁。和 DispatchQueue 一样,支持异步执行加锁代码。
  5. NSLock 是一种 Objective-C 类包装的阻塞锁。它的小伙伴 NSRecursiveLock 是一种递归锁。
  6. os_unfair_lock 是一种简单(less sophisticated)、低级(lower-level)的阻塞锁。

最后,@synchronized 是一种阻塞、递归锁。

没有自旋锁

我在锁的不同类型里提到了自旋锁,但是列出来的 API 里并没有自旋锁。这是这篇文章和老文章有很大不同的地方,也是我写这篇文章的主要原因。

自旋锁非常简单,在合适的情况下非常高效。不幸的是,在复杂的现代世界里,它太简单了。

问题出于线程优先级(thread priorities)。当可运行的线程数超过 CPU 核数时,高优先级的线程会被优先选择。这是一个有用主意,因为 CPU 核心永远是有限的资源,你不会希望当你的用户使用 UI 时被一些时间不敏感的后台网络操作偷取了 CPU 时间。

当一个高优先级线程被阻塞,等待低优先级线程完成工作,但是高优先级线程阻止低优先级线程实际执行工作,这会导致长时间的无反应或者永久的死锁。

发生死锁的情况像下面描述的这样,使用 H 代表高优先级线程,L 代表低优先级线程:

  1. L 获得自旋锁。
  2. L 开始执行工作。
  3. H 可以运行了,并且抢占了 L 的核心。
  4. H 尝试获得自旋锁,但是因为 L 还持有着而失败。
  5. H 开始愤怒的旋转自旋锁,不停的尝试获取它,并且霸占了 CPU。
  6. H 不能继续执行知道 L 完成它的工作。L 不能完成它的工作除非 H 停止愤怒的旋转自旋锁。
  7. 悲伤的故事。

有很多方法可以解决这个问题。比如,H 可以在第 4 步将它的优先级捐献给 L,让 L 快速的完成工作。让自旋锁解决这个问题是可能的,但是 Apple 的老旧自旋锁 API OSSpinLock 不能。

这在很长一段时间里并不是问题,因为线程优先级在 Apple 的平台上并没有怎么使用,并且优先级系统使用动态优先级调整来防止上面提到的死锁情况持续太久。近来,quality of service classes 使不同优先级的使用更常见了,也使得上面这种死锁的情况更常见了。

OSSpinLock 良好的工作了很长时间,但在 iOS 8 和 macOS 10.10 发布后不再是一个好的工具了。现在已正式的被弃用。替代它的是 os_unfair_lock,符合原本低级、简单和开销小的要求,但足够精巧来避免线程优先级带来的问题。

值类型

注意 pthread_mutex_tpthread_rwlock_tos_unfair_lock 都是值类型,不是引用类型。这代表着如果你对它们使用 =,你会得到一份拷贝。这很重要,因为这些类型不能被拷贝!如果你拷贝一份 pthread 类型,拷贝是不可用的,当你使用它时可能会导致崩溃。和这些类型一起使用的 pthread 函数们认为这些类型的值存放在它们初始化时的内存地址上,之后将它们移动到别的位置不是一个好主意。os_unfair_lock 不会崩溃,但是你将会得到一个你永远不会想要的结果,一个完全不一样的锁。

如果你使用这些类型,你必须保证永远不要拷贝它们,不管是明确的使用了 = 操作符,或者是无意的,比如将它们放入 struct 或者被闭包捕获。

另外,因为锁本质上是一个可变对象,所以你需要使用 var 而不是 let 来声明它们。

其他的则是引用类型,你可以随你喜欢的四处传递它们,并且可以用 let 来声明。

初始化

你需要小心对待 pthread 锁,因为你可以通过空()构造函数来创建一个值,但是这个值还不是一个有效的锁。这些锁必须分别通过使用 pthread_mutex_init 或者 pthread_rwlock_init 来初始化:

var mutex = pthread_mutex_t()
pthread_mutex_init(&mutex, nil)

给这些类型写一个扩展(extension)来包装初始化过程是很诱人的。然而这无法保证构造函数直接工作到一个变量而不是一份拷贝上。因为这些类型无法安全的被拷贝,这样的扩展是无法安全的写出来的,除非你返回一个指针或者一个包装类(wrapper class)。

如果你使用这些 API,销毁锁时别忘了调用相应的 destory 函数。

使用

DispatchQueue 有着基于回调(callback-based)的 API,这使得对它的使用有着天然的安全性。取决于你想同步或者异步的执行被保护的代码,调用 sync 或者 async 并传入代码来运行:

queue.sync(execute: { ... })
queue.async(execute: { ... })

对于 sync 来说,它提供了一个很棒的特性,将被保护的代码的返回值捕获为 sync 方法的返回值:

let value = queue.sync(execute: { return self.protectedProperty })

你甚至可以在被保护的代码中 throw 错误,它会将其传递出来。

OperationQueue 很相似,但是没有内建的方法来传出返回值或者错误。你需要自己实现,或者使用 DispatchQueue 来替代。

其他的 API 需要单独的加锁和解锁调用,当你忘了调用其中一个时会非常刺激。这些调用长这样:

pthread_mutex_lock(&mutex)
...
pthread_mutex_unlock(&mutex)

nslock.lock()
...
nslock.unlock()

os_unfair_lock_lock(&lock)
...
os_unfair_lock_unlock(&lock)

因为这些 API 都一模一样,接下来的例子我只会使用 nslock。其他除了函数名以外都是一样的。

当被保护的代码很简单的时候,这可以工作的很好。但是如果情况更复杂呢?举个例子:

nslock.lock()
if earlyExitCondition {
    return nil
}
let value = compute()
nslock.unlock()
return value

哎哟,有时候你没有解锁!这是一种写出难以找到的 bug 的方法。也许你总是正确的对待你的 return 语句,绝不会写出这种代码。但如果你要抛出错误呢?

nslock.lock()
guard something else { throw error }
let value = compute()
nslock.unlock()
return value

同样的问题!也许你非常的自律并且从不会写这种代码。虽然你的代码是安全的,但是代码有那么一点丑陋:

nslock.lock()
let value = compute()
nslock.unlock()
return value

明显的修复方式是使用 Swift 的 defer 语法。在你加锁的时候,defer 接锁。不管你怎么样退出代码,锁一定会被释放:

nslock.lock()
defer { nslock.unlock() }
return compute()

这对提早退出、抛错或者正常退出都有效。

写这两行代码还是会让人不爽,所以让我们将这些事情包装成一个像 DispatchQueue 那样基于回调的函数:

func withLocked<T>(_ lock: NSLock, 
                   _ f: () throws -> T) rethrows -> T {
    lock.lock()
    defer { lock.unlock() }
    return try f()
}

let value = withLocked(lock, { return self.protectedProperty })

当为值类型实现这个函数的时候,你需要接收一个锁的指针而不是锁本身作为参数。记住,你不会想要拷贝这些玩意!pthread 版本长这样:

func withLocked<T>(_ mutexPtr: UnsafeMutablePointer<pthread_mutex_t>,
                   _ f: () throws -> T) rethrows -> T {
    pthread_mutex_lock(mutexPtr)
    defer { pthread_mutex_unlock(mutexPtr) }
    return try f()
}

选择你的锁 API

DispatchQueue 显然是最受喜爱的。它有着 Swifty 的 API,用起来也很爽。Apple 对 Dispatch 这个库投入了很多注意,这表明你可以指望它性能良好、运行可靠并且获得一堆炫酷的新功能。

DispatchQueue 允许很多有用的进阶用法,比如在被你当作锁的队列上触发安排好的定时器(timers)或者事件源(event sources),保证它们的 handler 和其他使用这个队列的东西同步。设定目标队列(target queues)的能力允许表示复杂的锁层级。自定义并发队列可以很容易的被用作读写锁。你只要改变一个字母就能从同步执行变成异步的在一个后台线程上执行被保护的代码。 并且它的 API 很容易使用,很难被误用。不管在哪方面都很优秀。这都是 GCD 快速变为我最喜欢的 API 之一的原因。

像所有的事物一样,它不是完美的。一个 Dispatch 队列是由一个内存中的对象表示的,所以开销有一点点大。它还缺少一些有用的功能,比如条件变量(condition variables)和递归性。总会有一些时候,使用独立的加锁和解锁调用比被强制使用基于回调的 API 更好使。DispatchQueue 通常是正确的选择,同样也是你不知道该选择什么时最棒的默认选项,但偶尔会有一些原因需要使用其他的 API。

os_unfair_lock 是一个当每个锁的开销不能太大(因为某些原因你可能有一大堆锁)并且不需要一些华丽功能时的正确选择。它由一个可以放在任何地方的 32 位整数实现,所以开销非常小。

像它的名字提示的一样,os_unfair_lock 的一个特性就是没有公平性。锁的公平性表示至少会有企图,保证所有等待锁释放的线程都有机会获得锁。没有公平性的话,一个线程可以在有其他线程等待时通过快速释放和重新获取锁来独占一个锁。

这是不是一个问题取决于你在做什么。有一些时候公平性是必要的,有一些时候公平性是没有什么用的。缺少公平性使得 os_unfair_lock 有着更好的性能,使它在公平性不需要的场景更有优势。

pthread_mutex 夹在上面两者之间。它比 os_unfair_lock 明显大很多,有 64 字节,但你还是可以控制它存储在哪里。它实现了公平性,尽管这是 Apple 的实现中的细节,而不是 API 标准中写出的。它也提供了各种进阶功能,比如使锁递归的能力和复杂的线程优先级相关的玩意。

pthread_rwlock 提供了一种读写锁。它占用巨大的 200 字节但并没有提供什么有趣的功能,所以看起来并没有多少理由使用它而不使用一个并发的 DispatchQueue

NSLock 是对 pthread_mutex 的包装。很难想象出一个它的用例,但当你需要明确的加锁和解锁调用但是不想要 pthread_mutex 那些麻烦的手动初始化和销毁时它会很有用。

OperationQueue 提供了和 DispatchQueue 相似的基于回调的 API,还有一下进阶功能比如 operation 间的依赖管理,但是缺少很多其他 DispatchQueue 提供的功能。尽管它在做别的事情时很有用,但 OperationQueue 没有什么原因被用作加锁的 API。

总之:DispatchQueue 很可能是正确的选择。在某些特定的情况,os_unfair_lock 也许更好。其他的通常不会被选择使用。

总结

Swift 没有对线程同步的语言支持,但是有 API 来补救。GCD 仍然是 Apple 皇冠上的一个宝石,它提供给 Swift 的 API 也非常棒。在少数它不合适的情况下,还有其他的选项提供选择。我们没有 @synchronized 和原子属性(atomic properties),但是我们有更好的东西。