DEV Community

Igor Artamonov
Igor Artamonov

Posted on

Performance of Lock in Coroutines

Recently, while having a work conversation, I heard a thesis from a colleague that “In Kotlin coroutines you have to use Kotlin’s Mutex to synchronize modification of an object from different threads,” which seems to be a common misconception, and here I want to explain why it’s not always right. With the numbers.

So, basically, I see that it’s common to think: “if you use coroutines and inside those coroutines you have a shared object which you modify from multiple coroutines, then to synchronize the access to it you have to use Kotlin’s Mutex

The docs imply that, and also they say that you should never have blocking code in your coroutines.
But this “blocking code” is a very confusing term. It’s supposed to mean that this code wastes CPU on doing nothing (i.e. on waiting) for a time long enough to run something else during this time.

So a lock is definitely a blocking code, right? Sometimes. But it doesn’t automatically mean you should avoid using it.
Because the key point is “long enough.” Which means that the waiting time is longer than switching the context to another operation like copying blocks of stack/memory, which in turn also assumes polluting the CPU caches, possibly switching to another core, etc.

All of those are comparably slow operations (from 1ns to access data in the current CPU L1 cache, to hundreds and thousands of nanoseconds to copy the memory to another place). Note, though, that it’s still a hundred times faster than an IO operation.

So the rule of thumb is to use coroutines for IO operations, and standard primitives for non-IO operations. Unless you have some very specific case. And a shared object access is nothing like that, because system locks are very fast and memory efficient.

And, btw, even to switch a coroutine context you would probably rely on a lock or two somewhere inside the coroutines library, so a JVM lock is unavoidable most likely.

Okay, enough theory and assumptions. Let’s benchmark it and see if it makes sense.

I have a very simple idea of a code that:

  • fully utilizes CPU while while executing many short and independent tasks
  • no IO
  • have an object to synchronize the state between threads

The “task” is calculating a SHA3 hash of the input which is the current task index and the thread index. Each one goes in its own thread. It’s a fully deterministic operation and does the exact same amount of work regardless of the order of execution.
And as a synchronization step I compare it to the last known value and choose the lowest value.

The code for the task:

fun cpuTask(i: Int, rail: Int): String {
    val sha3 = SHA3.Digest256()
    return Hex.toHexString(sha3.digest("CPU/$rail/$i".toByteArray()))
}
Enter fullscreen mode Exit fullscreen mode

Called from:

class State {
    var current: String? = null

    fun update(next: String) {
        if (current == null || current!! > next {
           current = next
        }
    }
}

val state = State()
val lock = ReentrantLock()

runBlocking {
    //
    // run 25K threads (coroutines)
    for (rail in 1..25_000) {
        launch(Dispatchers.Default) {
            var prev: String? = null

            //
            // Execute a CPU bound task 1K times in each thread
            // resulting in ~10 updates to the shared state
            for (i in 1..1000) {
                //
                // Run a task
                val next = cpuTask(i, rail)

                //
                // Check if it's better for the current thread.
                // Only in this example, to avoid too many locks. 
                // Version with "Always" suffix doesn't have this check
                if (prev == null || prev > next) {
                    prev = next

                    //
                    // Update the shared state
                    // Here it's a JVM Lock, but there is a version with Mutex
                    lock.withLock {       
                         state.update(next)
                    }

                }
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

There are two ways to have the synchronization though. One is to always compare with the shared value (the benchmarks with the “Always” suffix).
Another one is having a two-step process, first compare with the best value for the current thread, and only the best is compared to the “shared best.” This way dramatically decreases the amount of locks, and having both ways allows us to compare the effect of lock.

I implemented the same logic in different ways to compare the performance:

  • Coroutines + Java Lock
  • Coroutines + Coroutines Mutex
  • Plain Java Threads + Java Lock
  • Reactor with No Lock
  • Plain Java Threads with No Lock

The last one is a base number to compare the others, because it’s supposed to be the most optimal code.

I ran this code on the same machine with nothing running on it except the code, using Ubuntu 22.04 / Intel i7 8 cores / OpenJDK Java 21. I ran 25,000 tasks (in parallel limited to the CPU count) each doing 1,000 calculations. I ran each 12 times in random order, so the benchmark should not be significantly affected by some preexisting state of the memory, JVM warm-up, or similar.

The first part is the basic benchmarks:


Image description

Here we see the “Threads No Locks,” which is our main basis, with 5620 ms.

And the other standard JVM and Reactor-based implementations that are not very different from each other. They are +100ms to execute, which is lower than the Standard Deviation, so we can’t really say they are much slower, but they are clearly in the same bucket. It’s safe to say that on average all of them have the same performance.

The code above doesn't do much locking because it selects the best per tread before locking. Now what if we always lock the state and compare inside?

Image description

Examples with the “Always” prefix demonstrate the effect of the unnecessary locks (i.e., when each value is compared to the global value).
 Compared to that, we see that the optimal code as in the previous screen is 25%+ faster.

And now the Coroutines Mutex instead of JVM Lock:

Image description

Now we see that the Coroutines Mutex doubles the time of the execution. It’s not just a minor performance issue.

Note, it’s an “optimal” implementation that doesn’t use Mutex too often. Like the very first example with Lock.

What if we always synchronize with the Mutex? Will it be the same 25% loss of performance like with the others?


Image description

Oh, no!!! It actually makes it 5 times slower than the previous! Or almost 10 times slower than the JVM Lock.

This is the cost of using a coroutine primitive for a non-IO operation.

The final table looks like this:

Image description

In summary:

  • Use coroutines and related, including Mutex, only for IO-bound tasks;
  • Use Mutex if you lock over other coroutines (i.e., you call a suspend function inside the lock), but if you do everything right you would not have non-IO coroutines anyway
  • Use standard JVM primitives, including Lock, for CPU/Memory-bound tasks with no coroutines inside the lock
  • And of course avoid having long tasks under a lock, regardless of the type of lock

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

🌶️ Newest Episode of Leet Heat: A Game Show For Developers!

Contestants face rapid-fire full stack web dev questions. Wrong answers? The spice level goes up. Can they keep cool while eating progressively hotter sauces?

View Episode Post

DEV is partnering to bring live events to the community. Join us or dismiss this billboard if you're not interested. ❤️