跪拜 Guibai
← Back to the summary

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 synchronized singleton 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:

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:

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, @Volatile is 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

A New Chapter in Concurrent Programming: Bidding Farewell to JUC's Heavy Locks and Deadlock Risks with Kotlin Coroutines

Why Java Locks Can't Lock Kotlin Coroutines?

A Guide to Repository Method Design: Choosing Between suspend and Flow (Using a Moments Feed as an Example)

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?

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

用户27910156679

Non-blocking suspension