仔细检查锁优化以在 Swift 中实现线程安全的延迟加载
Double check lock optimization to implement thread-safe lazy-loading in Swift
我已经在 class 中实现了我认为的双重检查锁定,以实现线程安全延迟加载。
以防万一你想知道,这是我目前正在研究的DI library。
我说的代码是the following:
final class Builder<I> {
private let body: () -> I
private var instance: I?
private let instanceLocker = NSLock()
private var isSet = false
private let isSetDispatchQueue = DispatchQueue(label: "\(Builder.self)", attributes: .concurrent)
init(body: @escaping () -> I) {
self.body = body
}
private var syncIsSet: Bool {
set {
isSetDispatchQueue.async(flags: .barrier) {
self.isSet = newValue
}
}
get {
var isSet = false
isSetDispatchQueue.sync {
isSet = self.isSet
}
return isSet
}
}
var value: I {
if syncIsSet {
return instance! // should never fail
}
instanceLocker.lock()
if syncIsSet {
instanceLocker.unlock()
return instance! // should never fail
}
let instance = body()
self.instance = instance
syncIsSet = true
instanceLocker.unlock()
return instance
}
}
逻辑是允许并发读取 isSet
,因此对 instance
的访问可以是 运行 从不同线程并行进行的。为了避免竞争条件(这是我不是 100% 确定的部分),我有两个障碍。设置 isSet
时一个,设置 instance
时一个。诀窍是仅在 isSet
设置为 true 后才解锁后者,因此等待 instanceLocker
解锁的线程在 isSet
上被异步写入时第二次被锁定并发调度队列。
我认为我离最终解决方案很近了,但由于我不是分布式系统专家,所以我想确定一下。
此外,使用调度队列不是我的第一选择,因为它让我觉得阅读 isSet
不是超级高效,但同样,我不是专家。
所以我的两个问题是:
- 这是 100% 线程安全的吗?如果不是,为什么?
- 有没有效率更高的
在 Swift?
中执行此操作的方法
IMO,此处正确的工具是 os_unfair_lock
。双重检查锁定的要点是避免完全内核锁定的代价。 os_unfair_lock
规定在无争议的情况下。 "unfair" 的一部分是它不对等待线程作出承诺。如果一个线程解锁,则允许它重新锁定而另一个等待线程没有机会(因此可能会饿死)。实际上,对于一个非常小的关键部分,这是不相关的(在这种情况下,您只是检查一个局部变量是否为 nil)。它是比分派到队列更低级别的原语,队列非常快,但不如 unfair_lock 快,因为它依赖于像 unfair_lock.
这样的原语
final class Builder<I> {
private let body: () -> I
private var lock = os_unfair_lock()
init(body: @escaping () -> I) {
self.body = body
}
private var _value: I!
var value: I {
os_unfair_lock_lock(&lock)
if _value == nil {
_value = body()
}
os_unfair_lock_unlock(&lock)
return _value
}
}
请注意,您在 syncIsSet
上进行同步是正确的。如果您将其视为原语(这在其他双重检查同步中很常见),那么您将依赖 Swift 不承诺的东西(编写 Bools 的原子性和它会实际上检查布尔值两次,因为没有 volatile
)。鉴于您正在进行同步,比较是在 os_unfair_lock 和调度到队列之间。
这就是说,根据我的经验,这种懒惰在移动应用程序中几乎总是没有根据的。如果变量非常昂贵但可能从未访问过,它实际上只会节省您的时间。有时在大规模并行系统中,能够移动初始化是值得的,但移动应用程序存在于相当有限的内核上,因此通常没有一些额外的内核可以将其分流到。我通常不会继续这样做,除非您已经发现当您的框架用于实时系统时这是一个严重的问题。如果你有,那么我建议在显示此问题的实际用法中针对 os_unfair_lock
分析你的方法。我希望 os_unfair_lock
获胜。
我已经在 class 中实现了我认为的双重检查锁定,以实现线程安全延迟加载。
以防万一你想知道,这是我目前正在研究的DI library。
我说的代码是the following:
final class Builder<I> {
private let body: () -> I
private var instance: I?
private let instanceLocker = NSLock()
private var isSet = false
private let isSetDispatchQueue = DispatchQueue(label: "\(Builder.self)", attributes: .concurrent)
init(body: @escaping () -> I) {
self.body = body
}
private var syncIsSet: Bool {
set {
isSetDispatchQueue.async(flags: .barrier) {
self.isSet = newValue
}
}
get {
var isSet = false
isSetDispatchQueue.sync {
isSet = self.isSet
}
return isSet
}
}
var value: I {
if syncIsSet {
return instance! // should never fail
}
instanceLocker.lock()
if syncIsSet {
instanceLocker.unlock()
return instance! // should never fail
}
let instance = body()
self.instance = instance
syncIsSet = true
instanceLocker.unlock()
return instance
}
}
逻辑是允许并发读取 isSet
,因此对 instance
的访问可以是 运行 从不同线程并行进行的。为了避免竞争条件(这是我不是 100% 确定的部分),我有两个障碍。设置 isSet
时一个,设置 instance
时一个。诀窍是仅在 isSet
设置为 true 后才解锁后者,因此等待 instanceLocker
解锁的线程在 isSet
上被异步写入时第二次被锁定并发调度队列。
我认为我离最终解决方案很近了,但由于我不是分布式系统专家,所以我想确定一下。
此外,使用调度队列不是我的第一选择,因为它让我觉得阅读 isSet
不是超级高效,但同样,我不是专家。
所以我的两个问题是:
- 这是 100% 线程安全的吗?如果不是,为什么?
- 有没有效率更高的 在 Swift? 中执行此操作的方法
IMO,此处正确的工具是 os_unfair_lock
。双重检查锁定的要点是避免完全内核锁定的代价。 os_unfair_lock
规定在无争议的情况下。 "unfair" 的一部分是它不对等待线程作出承诺。如果一个线程解锁,则允许它重新锁定而另一个等待线程没有机会(因此可能会饿死)。实际上,对于一个非常小的关键部分,这是不相关的(在这种情况下,您只是检查一个局部变量是否为 nil)。它是比分派到队列更低级别的原语,队列非常快,但不如 unfair_lock 快,因为它依赖于像 unfair_lock.
final class Builder<I> {
private let body: () -> I
private var lock = os_unfair_lock()
init(body: @escaping () -> I) {
self.body = body
}
private var _value: I!
var value: I {
os_unfair_lock_lock(&lock)
if _value == nil {
_value = body()
}
os_unfair_lock_unlock(&lock)
return _value
}
}
请注意,您在 syncIsSet
上进行同步是正确的。如果您将其视为原语(这在其他双重检查同步中很常见),那么您将依赖 Swift 不承诺的东西(编写 Bools 的原子性和它会实际上检查布尔值两次,因为没有 volatile
)。鉴于您正在进行同步,比较是在 os_unfair_lock 和调度到队列之间。
这就是说,根据我的经验,这种懒惰在移动应用程序中几乎总是没有根据的。如果变量非常昂贵但可能从未访问过,它实际上只会节省您的时间。有时在大规模并行系统中,能够移动初始化是值得的,但移动应用程序存在于相当有限的内核上,因此通常没有一些额外的内核可以将其分流到。我通常不会继续这样做,除非您已经发现当您的框架用于实时系统时这是一个严重的问题。如果你有,那么我建议在显示此问题的实际用法中针对 os_unfair_lock
分析你的方法。我希望 os_unfair_lock
获胜。