跪拜 Guibai
← All articles
Android

synchronized + runBlocking Is Not Coroutine-Safe DCL

By 潜龙勿用之化骨龙 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Android projects that mix `synchronized` with `runBlocking` for lazy singleton initialization risk main-thread ANRs and lose the throughput benefits of structured concurrency. Switching to `Mutex`-based DCL keeps initialization safe without blocking threads, which matters anywhere a coroutine calls into a suspend function during object creation.

Summary

A common pattern for lazy initializing a Retrofit client with a remotely fetched base URL uses `synchronized` and `runBlocking` inside a DCL check. That combination compiles and runs but is fundamentally broken in a coroutine environment. `synchronized` protects threads, not coroutines, and `runBlocking` forces an asynchronous suspend function to block the calling thread until completion.

The correct approach replaces the thread lock with a `Mutex` and makes the entire initialization path a suspend function. A `Mutex.withLock` block suspends the coroutine instead of blocking the thread, so other work can proceed while waiting for the network call or the lock itself. The outer null check still needs `@Volatile` because that read happens outside the mutex; without it, a CPU core can see a stale null even after another core has written the instance.

Each piece solves a distinct problem: `@Volatile` guarantees visibility across cores, `Mutex` provides mutual exclusion during initialization, and `suspend` keeps the wait non-blocking. Dropping any one of them turns the DCL into a latent concurrency bug that may only surface under load or on specific hardware.

Takeaways
`synchronized` serializes threads, not coroutines; a coroutine blocked inside a `synchronized` block still holds the monitor and prevents other threads from entering.
`runBlocking` inside `synchronized` turns an asynchronous suspend call into a synchronous blocking call, freezing the current thread until the network or DB operation finishes.
On Android's main thread, `runBlocking` + `synchronized` can produce an ANR because the UI thread is blocked waiting for I/O.
A coroutine-safe DCL uses `Mutex.withLock` instead of `synchronized`, keeping the wait suspending and non-blocking.
The outer null check in DCL reads the field outside the mutex, so `@Volatile` is still required to prevent a CPU core from seeing a stale null value.
`@Volatile` handles visibility, `Mutex` handles mutual exclusion, and `suspend` handles non-blocking waiting — all three address separate concerns.
Omitting `@Volatile` won't cause double initialization under a mutex, but it defeats the fast-path check and forces every caller to contend for the lock.
Conclusions

Many developers treat `synchronized` as a universal concurrency primitive and assume it composes safely with coroutines, but the two models operate at different levels of abstraction — one blocks OS threads, the other suspends logical tasks.

The `runBlocking` escape hatch is often cargo-culted from test code or main-function wrappers into production paths where it silently destroys the non-blocking guarantees that coroutines are meant to provide.

Coroutine DCL is not just a syntax swap; it forces a mental model shift from 'block this thread until ready' to 'suspend until ready and let the thread do other work,' which affects how timeouts, cancellation, and error propagation are designed.

The `@Volatile` requirement in coroutine DCL reveals a subtlety that even experienced Kotlin developers miss: the fast-path read is lock-free, so it needs the same memory-visibility guarantees as any other concurrent read.

Concepts & terms
Double-Checked Locking (DCL)
A lazy initialization pattern that checks a field outside a lock first, and only acquires the lock if the field is null, then checks again inside the lock before creating the instance. It avoids lock contention after initialization while preventing duplicate creation.
Mutex (kotlinx.coroutines.sync)
A coroutine-based mutual exclusion primitive. `Mutex.withLock` suspends the calling coroutine while waiting for the lock instead of blocking the underlying thread, allowing the thread to execute other coroutines.
@Volatile
A Kotlin annotation that marks a field so that writes are immediately visible to all threads/CPU cores. In DCL, it ensures the outer null check sees the most recent value written by another core, preventing unnecessary lock entry.
runBlocking
A coroutine builder that bridges blocking and non-blocking worlds by running a new coroutine and blocking the current thread until it completes. It is meant for main functions and tests, not for production code paths that should remain non-blocking.
From the discussion

A brief correction points out that the core concept is non-blocking suspension, not just suspension in general.

The key property of coroutine suspension is its non-blocking nature, which `runBlocking` violates.
Featured comments
用户27910156679

Non-blocking suspension

See top comments, translated →
Source: juejin.cn ↗ Google Translate ↗ Backup ↗