How do I understand thread dispatching in coroutines?

I’m curious about the correct way to make coroutines run in parallel on different threads. Here’s my code using runBlocking, but both coroutines execute on the main thread.

suspend fun main() = runBlocking {
    println("[${Thread.currentThread().name}]")
    
    launch { 
        delay(500)
        println("[${Thread.currentThread().name}] launch #1")
    }
    
    launch {
        delay(500)
        println("[${Thread.currentThread().name}] launch #2")
    }
    
    delay(1000)
}
[main @coroutine#1]
[main @coroutine#2] launch #1
[main @coroutine#3] launch #2

Are both coroutines started with launch executing on the main thread because they inherit the context from runBlocking?

And here’s some code using coroutineScope. In this case, the coroutines launched with launch are executed on the same worker thread.

suspend fun main() = coroutineScope {
    println("[${Thread.currentThread().name}]")
    
    launch { 
        delay(500)
        println("[${Thread.currentThread().name}] launch #1")
    }
    
    launch {
        delay(500)
        println("[${Thread.currentThread().name}] launch #2")
    }
    
    delay(1000)
}
[main]
[DefaultDispatcher-worker-1 @coroutine#2] launch #2
[DefaultDispatcher-worker-1 @coroutine#1] launch #1

Unlike when using runBlocking, why aren’t they executed on the main thread in this case?

Lastly, here’s code executing coroutines using Dispatchers.Default. In this case, the two coroutines run on different threads. (The result is the same whether using runBlocking or coroutineScope.)

suspend fun main() = runBlocking {
    println("[${Thread.currentThread().name}]")
    
    launch(Dispatchers.Default) { 
        delay(500)
        println("[${Thread.currentThread().name}] launch #1")
    }
    
    launch(Dispatchers.Default) {
        delay(500)
        println("[${Thread.currentThread().name}] launch #2")
    }
    
    delay(1000)
}
[main]
[DefaultDispatcher-worker-2 @coroutine#1] launch #1
[DefaultDispatcher-worker-1 @coroutine#2] launch #2

On a multi-core system, when using Dispatchers.Default or Dispatchers.IO, can I guarantee that the two coroutines will always run in parallel on different threads? Conversely, if I don’t specify the Dispatchers, can the coroutines execute on the same thread?

I seem to have asked too many questions. To summarize:

  1. Why do two coroutines launched under runBlocking both execute on the main thread?
  2. Why do two coroutines launched under coroutineScope execute on the same worker thread? And why, unlike when using runBlocking, don’t they execute on the main thread?
  3. When specifying a Dispatcher (like Dispatchers.Default or Dispatchers.IO), can I guarantee that two coroutines will execute in parallel on different threads?

Thank you.

I think you are overthinking this. Dispatcher associated with a coroutine controls its scheduling. Dispatchers are usually backed by a thread pool, but runBlocking by default creates a single-threaded dispatcher, utilizing the thread that called it. And this is pretty much it.

  1. Because by default runBlocking uses a single thread.
  2. We didn’t use runBlocking, so we use Dispatchers.Default which is, well, default. Both coroutines executed in a single thread by accident. They can run in a single thread, they can in two separate threads. If you run your code a second time, you may see other threads. The same with your third example - it used 2 threads in your case, but it could use a single one as well.
  3. Sounds like a XY problem. Often, we need guarantees to run in a single thread, but why do you need guarantees to run in two separate threads? Answer will depend on your specific case. We can for sure create two dispatchers backed by two separate thread pools, but I suspect it doesn’t solve your problem. Also, we can never guarantee parallel execution, even the underlying operating system may not support this.
2 Likes

Thank you :slight_smile:

I was just curious why two coroutines executed within runBlocking were running on the main thread. I was confused because I didn’t consider that runBlocking uses a single thread. Then, when executing CPU-intensive coroutines inside runBlocking, is it the correct approach to improve concurrency by explicitly using withContext with Dispatchers.Default?

Yes, runBlocking is just a simple bridge to coroutines. if we need a more general-use coroutine, we should most probably switch to another dispatcher. We can do it simply by: runBlocking(Dispatchers.Default). Also, if we do suspend fun main(), we rather shouldn’t use runBlocking.

1 Like

From my understanding of coroutines, if you have CPU-intensive tasks, you don’t use coroutines. The point of coroutines is that they can share a single thread. So if you have 5 network operations, you can have 5 coroutines that all share 1 thread. Each coroutine will run, start its request, then while it waits for the request, it pauses, freeing up the thread to be used for another coroutine. But if your code is CPU-bound, then it’ll never be waiting for anything; it just keeps executing until it’s done. Like if you have a recursive function that’s calculating a value, you never hit a natural point to suspend, because you’re never waiting for anything, you just need to run all the calculations.

idk if coroutines could be useful for utilising multiple cores of a CPU, to run multiple bits of CPU-bound code on different cores simultaneously… that might be something to look into.

1 Like

If we have a single task which is single-threaded, CPU-intensive and never suspends, then this is true - we don’t get too much benefits by putting it into a coroutine. However, we often speed up CPU calculations by making them concurrent, and then coroutines make perfect sense. Similarly, as with I/O code, we get benefits of good performance, relatively simple code and structured concurrency.

1 Like

Please consider that even in this case the @Skater901’s considerations are still valid.
You may span a single CPU-bound task on all CPU’s cores, however using a shared dispatcher (ie Dispatchers.Default) can freeze other coroutines for a long time.

The key (and delicate) part is multithreading, you can use a dedicated thread pool or a dedicated dispatcher for these task.
I agree with you that structured concurrency is a big plus.