在 Swift 中有个有趣的现象: 它没有与线程相关的语法, 也没有明确的互斥锁 / 锁 (mutexes/locks) 概念, 甚至 Objective-C 中有的 @synchronized 和原子属性它都没有. 幸运的是, 苹果系统的 API 可以非常容易地应用到 Swift 中. 今天, 我会介绍这些 API 的用法以及从 Objective-C 过渡的一些问题, 这些灵感都来源于 Cameron Pulsford.
快速回顾一下锁
锁 (lock) 或者互斥锁 (mutex) 是一种结构, 用来保证一段代码在同一时刻只有一个线程执行. 它们通常被用来保证多线程访问同一可变数据结构时的数据一致性. 主要有下面几种锁:
阻塞锁(Blocking locks): 常见的表现形式是当前线程会进入休眠, 直到被其他线程释放.
自旋锁(Spinlocks): 使用一个循环不断地检查锁是否被释放. 如果等待情况很少话这种锁是非常高效的, 相反, 等待情况非常多的情况下会浪费 CPU 时间.
读写锁(
Reader/writer locks
): 允许多个读线程同时进入一段代码, 但当写线程获取锁时, 其他线程 (包括读取器) 只能等待. 这是非常有用的, 因为大多数数据结构读取时是线程安全的, 但当其他线程边读边写时就不安全了.
递归锁(Recursive locks): 允许单个线程多次获取相同的锁. 非递归锁被同一线程重复获取时可能会导致死锁, 崩溃或其他错误行为.
APIs
苹果提供了一系列不同的锁 API, 下面列出了其中一些:
- pthread_mutex_t
- pthread_rwlock_t
- dispatch_queue_t
NSOperationQueue 当配置为 serial 时
- NSLock
- OSSpinLock
除此之外, Objective-C 提供了 @synchronized 语法结构, 它其实就是封装了 pthread_mutex_t . 与其他 API 不同的是,@synchronized 并未使用专门的锁对象, 它可以将任意 Objective-C 对象视为锁.
@synchronized(someObject)
区域会阻止其他
@synchronized(someObject)
区域访问同一对象指针. 不同的 API 有不同的行为和能力:
pthread_mutex_t 是一个可选择性地配置为递归锁的阻塞锁;
pthread_rwlock_t 是一个阻塞读写锁;
dispatch_queue_t 可以用作阻塞锁, 也可以通过使用 barrier block 配置一个同步队列作为读写锁, 还支持异步执行加锁代码;
NSOperationQueue 可以用作阻塞锁. 与 dispatch_queue_t 一样, 支持异步执行加锁代码.
NSLock 是 Objective-C 类的阻塞锁, 它的同伴类 NSRecursiveLock 是递归锁.
OSSpinLock 顾名思义, 是一个自旋锁.
最后,@synchronized 是一个阻塞递归锁.
值类型
注意, pthread_mutex_t,pthread_rwlock_t 和 OSSpinLock 是值类型, 而不是引用类型. 这意味着如果你用 = 进行赋值操作, 实际上会复制一个副本. 这会造成严重的后果, 因为这些类型无法复制! 如果你不小心复制了它们中的任意一个, 这个副本无法使用, 如果使用可能会直接崩溃. 这些类型的 pthread 函数会假定它们的内存地址与初始化时一样, 因此如果将它们移动到其他地方就可能会出问题. OSSpinLock 不会崩溃, 但复制操作会生成一个完全独立的锁, 这不是你想要的.
如果使用这些类型, 就必须注意不要去复制它们, 无论是显式的使用 = 操作符还是隐式地操作.
例如, 将它们嵌入到结构中或在闭包中捕获它们.
另外, 由于锁本质上是可变对象, 需要用 var 来声明它们.
其他锁都是是引用类型, 它们可以随意传递, 并且可以用 let 声明.
初始化
2015-02-10 更新: 本节中所描述的问题已经以惊人的速度被淘汰. 苹果昨天发布了 Xcode 6.3 beta 1, 其中包括 Swift 1.2. 在其他更改中, 现在使用一个空的初始化器导入 C 结构, 将所有字段设置为零. 简而言之, 你现在可以直接使用 pthread_mutex_t(), 不需要下面提到的扩展.
pthread 类型很难在 swift 中使用. 它们被定义为不透明的结构体中包含了一堆存储变量, 例如:
- struct _opaque_pthread_mutex_t {
- long __sig;
- char __opaque[__PTHREAD_MUTEX_SIZE__];
- };
目的是声明它们, 然后使用 init 函数对它们进行初始化, 使用一个指针存储和填充. 在 C 中, 它看起来像:
- pthread_mutex_t mutex;
- pthread_mutex_init(&mutex, NULL);
这段代码可以正常的工作, 只要你记得调用 pthread_mutex_init. 然而, Swift 真的真的不喜欢未初始化的变量. 与上面代码等效的 Swift 版本无法编译:
- var mutex: pthread_mutex_t
- pthread_mutex_init(&mutex, nil)
- // error: address of variable 'mutex' taken before it is initialized
Swift 需要变量在使用前初始化. pthread_mutex_init 不使用传入的变量的值, 只是重写它, 但是 Swift 不知道, 因此它产生了一个错误. 为了满足编译器, 变量需要用某种东西初始化. 在类型之后使用 (), 但这样写仍然会报错:
- var mutex = pthread_mutex_t()
- // error: missing argument for parameter '__sig' in call
Swift 需要那些不透明字段的值.__sig 可以传入零, 但是 __opaque 就有点烦人了. 下面的结构体需要桥接到 swift 中:
- struct _opaque_pthread_mutex_t {
- var __sig: Int
- var __opaque: (Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8,
Int8, Int8, Int8, Int8)
}
目前没有简单的方法使用一堆 0 构建一个元组, 只能像下面这样把所有的 0 都写出来:
- var mutex = pthread_mutex_t(__sig: 0,
- __opaque: (0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0))
这么写太难看了, 但我没找到好的方法. 我能想到最好的做法就是把它写到一个扩展中, 这样直接使用空的 () 就可以了. 下面是我写的两个扩展:
- extension pthread_mutex_t {
- init() {
- __sig = 0
__opaque = (0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0)
- }
- }
- extension pthread_rwlock_t {
- init() {
- __sig = 0
__opaque = (0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0,
- 0, 0, 0, 0, 0, 0, 0, 0)
- }
- }
可以通过下面这种方式使用:
- var mutex = pthread_mutex_t()
- pthread_mutex_init(&mutex, nil)
锁的封装
为了使这些不同的 API 更易于使用, 我编写了一系列小型函数. 我决定把 with 作为一个方便, 简短, 看起来像语法的名字, 灵感来自 python 的 with 声明. Swift 函数重载允许不同类型使用相同的名称. 基本形式如下所示:
func with(lock: SomeLockType, f: Void -> Void) { ...
然后在锁定的情况下执行函数 f. 下面我们来实现这些类型.
对于值类型, 它需要一个指向锁的指针, 以便 lock/unlock 函数可以修改它. 这个实现 pthread_mutex_t 只是调用相应的 lock 和 unlock 函数, f 函数在两者之间调用:
- func with(mutex: UnsafeMutablePointer<pthread_mutex_t>, f: Void -> Void) {
- pthread_mutex_lock(mutex)
- f()
- pthread_mutex_unlock(mutex)
- }
pthread_rwlock_t 的实现几乎完全相同:
- func with(rwlock: UnsafeMutablePointer<pthread_rwlock_t>, f: Void -> Void) {
- pthread_rwlock_rdlock(rwlock)
- f()
- pthread_rwlock_unlock(rwlock)
- }
与读写锁做个对比, 它们看起来很像:
- func with_write(rwlock: UnsafeMutablePointer<pthread_rwlock_t>, f: Void -> Void) {
- pthread_rwlock_wrlock(rwlock)
- f()
- pthread_rwlock_unlock(rwlock)
- }
dispatch_queue_t 更简单. 它只需要封装 dispatch_sync::
- func with(queue: dispatch_queue_t, f: Void -> Void) {
- dispatch_sync(queue, f)
- }
如果一个人想显摆自己很聪明, 那么可以充分利用 Swift 的函数式特性简单的写出这样的代码:
let with = dispatch_sync
这种写法存在一些问题, 最大的问题是它会和我们这里使用的基于类型的重载混淆.
NSOperationQueue 在概念上是相似的, 不过没有 dispatch_sync 可以用. 我们需要创建一个操作(operation), 将其添加到队列中, 并显式等待它完成:
- func with(opQ: NSOperationQueue, f: Void -> Void) {
- let op = NSBlockOperation(f)
- opQ.addOperation(op)
- op.waitUntilFinished()
- }
实现 NSLock 看起来像 pthread 版本, 只是锁定调用有些不同:
- func with(lock: NSLock, f: Void -> Void) {
- lock.lock()
- f()
- lock.unlock()
- }
最后, OSSpinLock 的实现同样也是如此:
- func with(spinlock: UnsafeMutablePointer<OSSpinLock>, f: Void -> Void) {
- OSSpinLockLock(spinlock)
- f()
- OSSpinLockUnlock(spinlock)
- }
模仿 @synchronized
有了上面的封装, 模仿 @synchronized 的实现就变得很简单. 给你的类添加一个属性, 持有一个锁, 然后使用 with 替代 @synchronized :
- let queue = dispatch_queue_create("com.example.myqueue", nil)
- func setEntryForKey(key: Key, entry: Entry) {
- with(queue) {
- entries[key] = entry
- }
- }
从 block 中获取数据比较麻烦.@synchronized 可以从内部 return , 但是 with 做不到. 你必须使用一个 var 变量在 block 内部赋值给它:
- func entryForKey(key: Key) -> Entry? {
- var result: Entry?
- with(queue) {
- result = entries[key]
- }
- return result
- }
按理说可以将这段代码当做模板封装在一个通用函数中, 但是它无法通过 Swift 编译器的类型推断, 目前还没有找到解决方法.
模拟原子属性
原子属性 (atomic) 并不常用. 与其他代码属性不同, 原子属性并不支持组合率. 如果函数 f 不存在内存泄漏, 函数 g 不存在内存泄漏, 那么函数 h 只是调用 f 和 g 也不会存在内存泄漏. 但是原子属性并不满足这个条件. 举一个例子, 假设你有一个定义成原子属性并且线程安全的 Account 类:
- let checkingAccount = Account(amount: 100)
- let savingsAccount = Account(amount: 0)
现在要把钱转到储蓄账户中:
- checkingAccount.withDraw(100)
- savingsAccount.deposit(100)
在另一个线程中, 统计并显示余额:
println("Your total balance is: \(checkingAccount.amount + savingsAccount.amount)")
在某些情况下, 这段代码会打印 0, 而不是 100, 尽管事实上这些 Account 对象本身是原子属性, 并且用户确实有 100 的余额. 所以, 最好让整个子系统都满足原子性, 而不是单个属性.
在极少数情况下, 原子属性是有用的, 因为它并不依赖其他特性, 只需要线程安全即可. 要在 Swift 中实现这一点, 需要一个计算属性来完成锁定, 用另一个常规属性保存值:
- private let queue = dispatch_queue_create("...", nil)
- private var _myPropertyStorage: SomeType
- var myProperty: SomeType {
- get {
- var result: SomeType?
- with(queue) {
- result = _myPropertyStorage
- }
- return result!
- }
- set {
- with(queue) {
- _myPropertyStorage = newValue
- }
- }
- }
如何选择锁 API
pthread API 在 Swift 中不太好用, 而且功能并不比其它 API 多. 一般我比较喜欢在 C 和 Objective-C 中使用它们, 因为它们又好用又高效. 但是在 Swift 中, 除非必要, 我一般不会用.
一般来说不需要用读写锁, 大多数情况下读写速度都非常快. 读写锁带来的额外开销超过了并发读取带来的效率提升.
递归锁会发生死锁. 多数情况下它们是有用的, 但如果你发现自己需要获取一个已经在当前线程被锁住的锁, 那最好重新设计代码, 通常来说不会出现这种需求.
我的建议是, 如果不知道该用什么, 那就默认选择 dispatch_queue_t . 虽然用起来相对麻烦, 但是不会产生太多问题. 该 API 非常方便, 并且确保你永远不会忘记调用 lock 和 unlock. 它提供了许多有用的 API, 如使用单个 dispatch_async 在后台执行被锁定的代码, 或者设置定时器或其他作用于 queue 的事件源, 以便它们自动执行锁定. 你甚至可以用它作为
NSNotificationCenter
观察者, 或者使用 NSOperationQueue 的属性 underlyingQueue 作为 NSURLSession 代理.
NSOperationQueue 可能认为自己和 dispatch_queue_t 一样牛, 但是实际上很少有场景需要使用它. 这个 API 使用起来更麻烦, 而且和其他 API 比没有什么优势, 无非在某些情况下, 它能自动进行操作的依赖关系管理, 也就这点比较有用.
NSLock 是一个简单的锁定类, 易于使用且效率很高. 如果需要显式锁定和解锁, 那可以用它替代 dispatch_queue_t . 但在大多数情况下不需要使用它.
OSSpinLock 对于经常使用锁定, 竞争较少且锁定代码运行速度快的用户来说, 是一个很好的选择. 它的开销非常少, 有助于提升性能. 如果代码可能会在很长一段时间内保持锁定或竞争很多, 那最好不要用这个 API, 因为这会浪费 CPU 时间. 通常来说, 你可以先使用 dispatch_queue_t , 如果这块出现了性能问题, 再考虑换成 OSSpinLock .
总结
Swift 语言层面并不支持线程同步, 但是 Apple 的系统框架有很多好用的 API.GCD 和 dispatch_queue_t 非常好用, 并且 Swift 中的 API 也是如此. 虽然 Swift 里没有 @synchronized 和原子属性, 但我们有其他更好的选择.
来源: https://juejin.im/entry/5b1a941ee51d4506b45e6bb4