synchronized + runBlocking Is Not Coroutine-Safe DCL
Many Android developers know about Double-Checked Locking (DCL).
But in the Kotlin coroutine era, many still instinctively reach for the old
synchronizedsingleton caching approach.This article discusses the correct way to implement DCL in a coroutine environment, and the pitfalls that are most easily overlooked.
1. An Implementation That Looks Fine
Suppose we have this requirement:
- Retrofit initialization is expensive
- The BaseUrl needs to be fetched remotely
- We want lazy loading
- When multiple coroutines access it concurrently, it should only initialize once
Many people's first instinct is to write it like this:
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()
}
}
where fetchRemoteConfig is a suspend function
The code:
- Compiles
- Runs
- Looks like standard DCL
But it has a big problem.
2. The Problem Isn't DCL, It's synchronized
Many people mistakenly think:
Coroutine + synchronized = Safe
In reality:
synchronized protects threads
not coroutines
Even more seriously:
runBlocking {
ConfigRepository.fetchRemoteConfig()
}
This directly turns asynchronous logic that should be suspended into synchronous blocking.
At this point:
All coroutine advantages disappear
While waiting for the network:
The thread is locked
The thread cannot be released
If this happens on the main thread:
ANR
This has essentially regressed to the traditional Java concurrency model.
3. What a Coroutine-Style DCL Should Look Like
The correct approach:
Thread lock → Mutex
Blocking wait → suspend
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()
}
}
This is the DCL for the coroutine era.
4. Why Double-Checking Is Needed
suspend fun getRetrofit(): Retrofit {
return mutex.withLock {
retrofit ?: buildRetrofit().also {
retrofit = it
}
}
}
Wouldn't this work?
Of course it works.
But the performance is poor.
Because:
Even after initialization is complete
Every call still contends for the lock
Standard DCL:
retrofit ?: mutex.withLock {
retrofit ?: buildRetrofit()
}
Has two layers.
First layer:
retrofit ?
Fast return.
Second layer:
mutex.withLock
Ensures initialization happens only once.
Flow:
Already initialized
↓
Return directly
Not initialized
↓
Enter lock
Check again
Still null
↓
Initialize
This is the essence of DCL.
5. Why @Volatile Cannot Be Omitted
Some people think:
Using Mutex
means Volatile is unnecessary
This statement is not rigorous.
First, look at:
retrofit ?: mutex.withLock {
retrofit ?: ...
}
Note:
The first-layer read happens outside the lock
That is to say:
retrofit ?
Does not go through the Mutex at all.
Without:
@Volatile
private var retrofit: Retrofit? = null
Theoretically, this could happen:
CPU A:
retrofit has been written
CPU B:
Still sees the old value null
Thus:
Enters the Mutex
Although it ultimately won't build twice,
the first-layer check has already lost its meaning.
Therefore, the correct statement should be:
In a DCL scenario,
@Volatileis needed not because Mutex is unsafe, but because the first-layer read bypasses the Mutex.
6. What Problem Mutex Really Solves
The value of Mutex is not thread safety.
It is:
Coroutine safety
For example:
mutex.withLock {
buildRetrofit()
}
Internally allows:
delay()
Allows:
suspend fun
Allows:
Network requests
Database access
And while waiting for the lock:
The coroutine suspends
The thread is released
Does not block the thread.
This is completely different from synchronized.
synchronized
↓
Blocks thread
Mutex
↓
Suspends coroutine
This is the greatest value of a coroutine lock.
Therefore, for real projects, this is more recommended:
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
}
}
}
}
Then:
val userApi =
RetrofitProvider
.getRetrofit()
.create(UserApi::class.java)
This aligns better with Retrofit's design philosophy.
Summary
To implement DCL in a coroutine environment, remember three principles:
First
Do not treat:
synchronized + runBlocking
as a coroutine solution.
This is thread-era thinking.
Second
Standard coroutine DCL:
@Volatile
private var retrofit: Retrofit? = null
private val mutex = Mutex()
suspend fun getRetrofit(): Retrofit {
return retrofit ?: mutex.withLock {
retrofit ?: buildRetrofit().also {
retrofit = it
}
}
}
Third
Remember the division of responsibilities:
@Volatile
Responsible for visibility
Mutex
Responsible for mutual exclusion during initialization
suspend
Responsible for non-blocking waiting
These three solve three different problems.
Missing any one of them can turn DCL into a hidden concurrency bug.
In the coroutine era, prioritize non-blocking suspension instead of porting thread-era experience verbatim. True coroutine thinking is not just changing code to suspend, but shifting from a "blocking threads" mindset to a "suspending coroutines" mindset.
References
Why Java Locks Can't Lock Kotlin Coroutines?
The Four Red Lines of Android Data Layer Design: Why They Must Be Upheld and How to Implement Them
Android Interview Series: Where Should runBlocking Actually Be Used?
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Non-blocking suspension