很多 Android 开发者都知道双重检查锁定(Double-Checked Locking,DCL)。
但到了 Kotlin 协程时代,很多人依然下意识地拿起
synchronized单例缓存那套老办法。本文聊聊协程环境下 DCL 的正确实现方式,以及那些最容易被忽略的坑。
一、一个看起来没问题的实现
假设我们有这样一个需求:
- Retrofit 初始化成本较高
- BaseUrl 需要远程获取
- 希望懒加载
- 多个协程并发访问时只初始化一次
很多人第一反应会这样写:
kotlin
object RetrofitClient {
private var retrofit: Retrofit? = null
fun getRetrofit(): Retrofit {
return retrofit ?: synchronized(this) {
retrofit ?: buildRetrofit().also {
retrofit = it
}
}
}
private fun buildRetrofit(): Retrofit {
val baseUrl = runBlocking {
ConfigRepository.fetchRemoteConfig()
}
return Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
}
其中 fetchRemoteConfig 是挂起函数
代码:
- 能编译
- 能运行
- 看起来还是标准 DCL
但问题很大。
二、问题不在 DCL,而在 synchronized
很多人误以为:
text
协程 + synchronized = 安全
实际上:
text
synchronized 保护的是线程
不是协程
更严重的是:
kotlin
runBlocking {
ConfigRepository.fetchRemoteConfig()
}
直接把原本应该挂起的异步逻辑变成了同步阻塞。
此时:
text
协程优势全部消失
等待网络期间:
text
线程被锁住
线程无法释放
如果发生在主线程:
text
ANR
这其实已经退化成传统 Java 并发模型。
三、协程版 DCL 应该长什么样
正确思路:
text
线程锁 → Mutex
阻塞等待 → suspend 挂起
kotlin
object RetrofitClient {
@Volatile
private var retrofit: Retrofit? = null
private val mutex = Mutex()
suspend fun getRetrofit(): Retrofit {
return retrofit ?: mutex.withLock {
retrofit ?: buildRetrofit().also {
retrofit = it
}
}
}
private suspend fun buildRetrofit(): Retrofit {
val baseUrl = ConfigRepository.fetchRemoteConfig()
return Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
}
这才是协程时代的 DCL。
四、为什么需要双重检查
kotlin
suspend fun getRetrofit(): Retrofit {
return mutex.withLock {
retrofit ?: buildRetrofit().also {
retrofit = it
}
}
}
这样不就行了吗?
当然能工作。
但性能不好。
因为:
text
即使已经初始化完成
每次调用仍然要竞争锁
标准 DCL:
kotlin
retrofit ?: mutex.withLock {
retrofit ?: buildRetrofit()
}
分为两层。
第一层:
kotlin
retrofit ?
快速返回。
第二层:
kotlin
mutex.withLock
保证初始化只发生一次。
流程:
text
已初始化
↓
直接返回
未初始化
↓
进入锁
再次检查
仍为空
↓
初始化
这才是 DCL 的本质。
五、为什么 @Volatile 不能省
有人认为:
text
用了 Mutex
就不需要 Volatile
这个说法不严谨。
先看:
kotlin
retrofit ?: mutex.withLock {
retrofit ?: ...
}
注意:
text
第一层读取发生在锁外
也就是说:
kotlin
retrofit ?
根本没有经过 Mutex。
如果没有:
kotlin
@Volatile
private var retrofit: Retrofit? = null
理论上可能发生:
CPU A:
text
retrofit 已写入
CPU B:
text
仍然看到旧值 null
于是:
text
进入 Mutex
虽然最终不会重复构建,
但第一层检查已经失去意义。
因此正确说法应该是:
DCL 场景下需要
@Volatile,不是因为 Mutex 不安全,而是因为第一层读取绕过了 Mutex。
六、Mutex 真正解决了什么问题
Mutex 的价值不是线程安全。
而是:
text
协程安全
例如:
kotlin
mutex.withLock {
buildRetrofit()
}
内部允许:
kotlin
delay()
允许:
kotlin
suspend fun
允许:
kotlin
网络请求
数据库访问
而等待锁期间:
text
协程挂起
线程释放
不会阻塞线程。
这一点和 synchronized 完全不同。
text
synchronized
↓
阻塞线程
Mutex
↓
挂起协程
这才是协程锁最大的价值。
因此实际项目更推荐:
kotlin
object RetrofitProvider {
@Volatile
private var retrofit: Retrofit? = null
private val mutex = Mutex()
suspend fun getRetrofit(): Retrofit {
return retrofit ?: mutex.withLock {
retrofit ?: buildRetrofit().also {
retrofit = it
}
}
}
}
然后:
kotlin
val userApi =
RetrofitProvider
.getRetrofit()
.create(UserApi::class.java)
这样更符合 Retrofit 的设计思想。
总结
协程环境下实现 DCL,需要牢记三个原则:
第一
不要把:
text
synchronized + runBlocking
当成协程方案。
这是线程时代的思维。
第二
标准协程 DCL:
kotlin
@Volatile
private var retrofit: Retrofit? = null
private val mutex = Mutex()
suspend fun getRetrofit(): Retrofit {
return retrofit ?: mutex.withLock {
retrofit ?: buildRetrofit().also {
retrofit = it
}
}
}
第三
记住职责分工:
text
@Volatile
负责可见性
Mutex
负责互斥初始化
suspend
负责非阻塞等待
三者解决的是三个不同问题。
缺少任何一个,都可能让 DCL 变成一个隐藏的并发 Bug。
在协程时代,要把非阻塞挂起放在首位,而不是把线程时代的经验原封不动地搬过来。 真正的协程思维,不仅仅是把把代码改成 suspend,而是从"阻塞线程的思维"转向"挂起协程的思维"。
参考
并发编程的新篇章:以Kotlin协程告别JUC的重锁与死锁风险
Repository 方法设计:suspend 与 Flow 的决选择指南(以朋友圈为例)