锁,线程安全,和swift (Locks, Thread Safety, and Swift: 2017 Edition )
Posted on 2022-07-21 22:27 pencilCool 阅读(426) 评论(0) 编辑 收藏 举报翻译自: https://www.mikeash.com/pyblog/friday-qa-2017-10-27-locks-thread-safety-and-swift-2017-edition.html
关于锁的快速回顾
lock,mutex ,是一种确保在任何时候只有一个线程在特定的代码区域内活动的结构。
它们通常被用来确保访问一个可变数据结构的多个线程都能看到一个一致的视图。
有几种锁:
- 阻塞锁在一个线程等待另一个线程释放锁的时候使其处于睡眠状态。这是最常见的行为。
- 自旋锁使用一个繁忙的循环来不断检查一个锁是否被释放。如果等待很少,这就更有效率,但如果等待很普遍,就会浪费CPU时间。
- 读者/写者锁允许多个 "读者 "线程同时进入一个区域,但当一个 "写者 "线程获得锁时,排除所有其他线程(包括读者)。这可能很有用,因为许多数据结构从多个线程同时读取是安全的,但当其他线程正在读取或写入时,写入是不安全的。
- 递归锁允许一个线程多次获得同一个锁。非递归锁在从同一线程重新进入时可能会出现死锁、崩溃或其他错误行为。
APIs
苹果的API有一堆不同的互斥设施。这是一个很长的但并不详尽的列表。
- pthread_mutex_t.
- pthread_rwlock_t。
- DispatchQueue。
- 操作队列(OperationQueue),当被配置为串行时。
- NSLock。
- os_unfair_lock。
- @synchronized是一个阻塞性的递归锁。
自旋锁和饥饿
自旋锁是锁的一种类型,但这里列出的API没有一个是自旋锁。这与之前的文章相比是一个很大的变化,也是我写这个更新的主要原因。
自旋锁真的很简单,而且在正确的情况下很有效率。不幸的是,对于现代世界的复杂性来说,它们有点太简单了。
问题在于线程的优先级。当可运行的线程多于CPU内核时,优先级较高的线程会得到优先权。这是一个有用的概念,因为CPU核总是有限的资源,你不希望在用户试图使用你的用户界面时,一些对时间不敏感的后台网络操作偷走了时间。
当一个高优先级的线程被卡住,不得不等待一个低优先级的线程完成一些工作,但高优先级的线程阻止低优先级的线程实际执行该工作,这可能会导致长时间的挂起,甚至是永久性的死锁。
死锁的情况是这样的,H是一个高优先级的线程,L是一个低优先级的线程。
1 L获得了自旋锁。
2 L开始做一些工作。
3 H准备运行,并抢占了L。
4 H试图获取自旋锁,但失败了,因为L仍然持有它。
5 H开始愤怒地在自旋锁上旋转,反复地试图获取它,并垄断了CPU。
6 在L完成它的工作之前,H不能继续进行。除非H停止愤怒地在自旋锁上旋转,否则L无法完成其工作。
7 sadness。
有一些方法可以解决这个问题。例如,H可以在第4步把它的优先级捐给L,让L及时完成它的工作。做一个自旋锁来解决这个问题是有可能的,但苹果的旧自旋锁API,OSSpinLock,并没有。
这在很长一段时间内都很好,因为线程优先级在苹果的平台上没有得到太多的使用,而且优先级系统使用动态优先级,使死锁的情况不会持续太久。最近,服务质量类使得不同的优先级更加普遍,也使得死锁情况更有可能持续下去。
OSSpinLock在很长一段时间内做得很好,但随着iOS 8和macOS 10.10的发布,它不再是一个好主意。它现在已经被正式废弃了。取而代之的是os_unfair_lock,它填补了与低级的、不成熟的、廉价的锁相同的总体目的,但它足够成熟,以避免优先级的问题。
值类型
注意,pthread_mutex_t, pthread_rwlock_t, 和 os_unfair_lock 是值类型,而不是引用类型。这意味着如果你在它们身上使用=,你就会产生一个拷贝。这一点很重要,因为这些类型是不能被复制的 如果你复制了其中一个pthread类型,这个副本将是不可用的,当你试图使用它时,可能会崩溃。与这些类型一起工作的pthread函数假定这些值是在与它们被初始化的地方相同的内存地址上,之后把它们放在其他地方是个坏主意。 os_unfair_lock不会崩溃,但你会得到一个完全独立的锁,这绝不是你想要的。
如果你使用这些类型,你必须注意不要复制它们,无论是显式的 = 操作符,还是隐式的,例如,将它们嵌入到一个结构中或在一个闭包中捕获它们。
此外,由于锁是固有的可变对象,这意味着你需要用var而不是let来声明它们。
其他的是引用类型,意味着它们可以随意传递,可以用let来声明。
初始化
你必须小心处理pthread锁,因为你可以使用empty ()初始化器创建一个值,但这个值不会是一个有效的锁。这些锁必须使用pthread_mutex_init或pthread_rwlock_init单独初始化。
var mutex = pthread_mutex_t()
pthread_mutex_init(&mutex, nil)
在这些类型上写一个扩展是很有诱惑力的,它可以将初始化包起来。然而,不能保证初始化器直接在变量上工作,而不是在一个副本上。由于这些类型不能被安全地复制,除非你让它返回一个指针或一个封装类,否则不能安全地编写这样一个扩展。
如果你使用这些API,别忘了在处置锁的时候调用相应的destroy函数。
使用
DispatchQueue有一个基于回调的API,这使得安全使用它很自然。根据你需要受保护的代码是同步运行还是异步运行,调用sync或async并将代码传递给它来运行。
queue.sync(execute: { ... })
queue.async(execute: { ... })
对于同步的情况,API很好地捕获了受保护代码的返回值,并将其作为同步方法的返回值。
let value = queue.sync(execute: { return self.protectedProperty })
你甚至可以在保护块内抛出错误,它们会传播出去。
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。也许你对你的返回语句总是很守规矩,从不这样做。如果你抛出一个错误怎么办?
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机制。在你锁定的时候,推迟解锁。那么无论你如何退出代码,锁都会被释放。
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()
}
let value = withLocked(&mutex, { return self.protectedProperty })
选择你的锁定API
DispatchQueue是一个明显的最爱。它有一个漂亮的Swifty API,而且使用起来很舒服。Dispatch库得到了苹果公司的大量关注,这意味着它可以依靠良好的性能,可靠的工作,并获得许多很酷的新功能。
DispatchQueue允许很多有趣的高级用途,比如安排计时器或事件源直接在你用作锁的队列上启动,确保处理程序与使用该队列的其他事物同步。设置目标队列的能力允许表达复杂的锁层次结构。自定义并发队列可以很容易地用作读者-写者锁。你只需要改变一个字母就可以在后台线程上异步执行受保护的代码,而不是同步执行。而且这个API很容易使用,很难被滥用。这是一个全面的胜利。GCD迅速成为我最喜欢的API之一,并且直到今天仍然是一个原因。
像大多数东西一样,它并不完美。一个调度队列由内存中的一个对象表示,所以有一点开销。他们缺少一些小众的功能,如条件变量或递归性。每隔一段时间,能够进行单独的锁定和解锁调用而不是被迫使用基于回调的API是非常有用的。DispatchQueue通常是正确的选择,如果你不知道该选什么,它是一个很好的默认值,但偶尔也有理由使用其他的。
当每个锁的开销很重要(因为某些原因你有大量的锁),而你又不需要花哨的功能时,os_unfair_lock可以是一个好的选择。它被实现为一个32位的整数,你可以把它放在你需要的地方,所以开销很小。
正如它的名字所暗示的,os_unfair_lock 缺少的一个功能就是公平性。锁的公平性意味着至少有一些尝试来确保等待一个锁的不同线程都有机会获得它。如果没有公平性,当其他线程在等待时,一个快速释放和重新获得锁的线程就有可能垄断它。
这是否是一个问题,取决于你在做什么。在一些用例中,公平是必要的,而在一些用例中,公平根本就不重要。缺乏公平性使得os_unfair_lock有更好的性能,所以它可以在不需要公平性的情况下提供一个优势。
pthread_mutex则处于中间位置。它比os_unfair_lock大得多,只有64字节,但你仍然可以控制它的存储位置。它实现了公平性,尽管这只是苹果公司实现的一个细节,而不是API规范的一部分。它还提供了各种其他的高级特性,比如使互斥的递归的能力,以及花哨的线程优先级的东西。
pthread_rwlock提供了一个读/写器锁。它只占用了200个字节,并且没有提供什么有趣的功能,所以似乎没有什么理由使用它而不是并发的DispatchQueue。
NSLock是pthread_mutex的一个封装器。很难想出它的用例,但如果你需要明确的锁定/解锁调用,但又不想手动初始化和销毁pthread_mutex的麻烦,那么它可能会很有用。
OperationQueue像DispatchQueue一样提供了基于回调的API,有一些高级功能,比如操作之间的依赖管理,但没有DispatchQueue提供的许多其他功能。使用OperationQueue作为锁API的理由不多,尽管它对其他事情很有用。
简而言之:DispatchQueue可能是正确的选择。在某些情况下,os_unfair_lock可能更好。其他的通常不适合使用。
总结
Swift没有线程同步的语言设施,但API弥补了这一点。GCD仍然是苹果公司皇冠上的明珠之一,而Swift的API也非常棒。在极少数不适合的情况下,还有很多其他的选择可以选择。我们没有@synchronized或原子属性,但我们有更好的东西。