Exploring Kotlin Coroutines Dispatchers: A Look at Dispatchers.Main.immediate

shy.yi.labs on 2024-03-16

Exploring Kotlin Coroutines Dispatchers: A Look at Dispatchers.Main.immediate

Photo by Hyundai Motor Group on Unsplash

When I first started learning Kotlin Coroutines and its dispatchers, I was more in the mindset of learning how to use multi-thread in Kotlin, and Dispatchers being just a way to mark the right thread for the job. But a couple more years in as a full-time Android developer, this partial understanding of Kotlin Coroutines Dispatchers soon met its limits. So today, I would like to lead you deep into Kotlin Coroutines Dispatchers myself through Dispatchers.Main.immediate as a prominent example.

The Basics: Dispatching Coroutines to the Right Thread.

As we all know, when launching a new coroutine, the dispatcher dispatches the given coroutine to the right thread (in this case the Main thread). Typically this is done using the message queue and as a result, the order in which coroutines are run depends on the order they were queued.

fun main() {
  print(1)
  CoroutineScope(Dispatchers.Main).launch {
    print(2)
  }
  print(3)
}

/* output: 132 */

In the above example, initially, the main thread is occupied by the main() function. When launch() is called this coroutine is sent to the message queue, waiting for the main thread to be ready to handle another message. So “2” won’t be printed after printing “1” and will have to wait until main() finishes. As main() prints “3” and finishes, the launched coroutine is picked up by the looper to run, thus finally printing “2”.

To dispatch or not to dispatch

However, even before all this dispatching, the dispatcher is responsible for determining whether a dispatch should happen in the first place by providing the method isDispatchNeeded(). In the case of skipping the dispatch, that coroutine skips the message queue and is run immediately.

fun main() {
  print(1)
  CoroutineScope(Dispatchers.Main.immediate).launch {
    print(2)
  }
  print(3)
}

/* output: 123 */

Since Dispatchers.Main.immediate returns false for isDispatchNeeded() while on the main thread, the coroutine runs sequentially, just like any other function would, without being sent to the message queue.

Dispatching on resume

Additionally, every time a coroutine resumes from a suspend state it also must run the process of dispatching (including checking whether or not to dispatch at all) again to continue running. While this may seem of little importance, it can lead to results that are very hard to understand without it.

@OptIn(ExperimentalCoroutinesApi::class)// for Channel.isEmpty
fun main() {
  val channel = Channel<Int>()
  CoroutineScope(Dispatchers.Main).launch {
    val job = launch {
      for (i in channel) {
        println(i)
      }
    }

    yield()// to let the above coroutine launch and suspend at the for loop.

    channel.send(1)

    job.cancel()
    println("${channel.isEmpty}")
  }
}

/*
output:
true
*/

Channels are known to buffer the sent value until there is a receiver to receive. But in contrast to our belief, the channel.isEmpty returned true without the value “1” ever printed by the for loop on the channel. Is Channel to blame for this unexpected result? Interestingly no.

You see, in this case, the channel has successfully delivered the value to the waiting coroutine. However, since the waiting coroutine was using Dispatchers.Main (following its parent scope) it had to be dispatched to the message queue with that value. And while it was waiting to be run, the coroutine scope of that coroutine was canceled resulting in the coroutine not running even when it was out of the queue.

Then, how about Dispatchers.Main.immediate?

@OptIn(ExperimentalCoroutinesApi::class)// for Channel.isEmpty
fun main() {
  val channel = Channel<Int>()
  CoroutineScope(Dispatchers.Main.immediate).launch {
    val job = launch {
      for (i in channel) {
        println(i)
      }
    }

    yield()// to let the above coroutine launch and suspend at the for loop.

    channel.send(1)

    job.cancel()
    println("${channel.isEmpty}")
  }
}

/* 
output:
1
true
*/

Since Dispatchers.Main.immediate does not require a dispatch while in the main thread, when the suspended for loop was called to resume, it was able to run right where it left off.

This all may seem like a magic, how “println(i)” was able to run before “job.cancel()”. But at the same time when you look at suspend function as just a callback function with an optional thread dispatch between, it will not be so hard to understand how it was able to run the way it did.

One more queue

In spite of Dispatchers.Main.immediate skipping the message queue when possible, there is one more queue that must be considered. Those coroutines that do not need a dispatch are queued and handled by the unconfined event loop. So although it does not require an additional message for the looper, it must follow the order of the unconfined queue.

fun main() {
  val scope = CoroutineScope(Dispatchers.Main.immediate)
  
  print(1)
  scope.launch {
    print(2)
    launch {
      print(3)
    }
    launch {
      print(4)
    }
    print(5)
  }
  print(6)
}

/* output: 125346 */

Such queue and event loop is well documented in Dispatchers.Unconfined’s document. But it’s a detail that is hard to understand and easy to look over when first learning Kotlin Coroutines. And for those who are wondering why there is such a queue, according to the Dispatchers.Unconfined’s document, this queueing process is in place to avoid stack overflow.

Conclusion

In summary, Kotlin Coroutine Dispatchers do much more than just mark the thread for the coroutine to run in. Dispatchers are an important part of understanding how coroutines are run which can affect the whole outcome. Although it would be best not to depend on such race conditions, I hope this lesson gave you the knowledge needed to cope in such cases when met.