Kotlin Coroutines 101: Part 1

Asynchronous programming is one of the most important parts of Android development. This is part 1 of a multi-part article.

Michael Carr
Bilue Product Design & Technology Blog
6 min readFeb 1, 2024

--

Have you ever had a device (any device) freeze or lock up while you were using it? How long did you try waiting or pressing inputs before getting frustrated and giving up? Personally, I’d have lost patience after about 5 seconds and give up after about 10 seconds. This is undoubtedly frustrating for users. In fact, the Android system shows an ANR (Application Not Responding) dialog with the option to close an offending app if it hasn’t respond to an input after 5 seconds.

So, as Android developers, how do we avoid locking up the UI when there are network calls, database queries, and intensive CPU tasks that can take anywhere from half a second to possibly minutes? First of all, we have to understand threading on Android.

Understanding Threading

When we talk about locking up the UI, what we actually mean is blocking the main thread. When an app is launched, the system creates a thread for it, called the main thread. This thread’s primary responsibilities are handling input events and drawing the UI. If we do too much processing or waiting on this thread, then the thread can’t react to input events or draw the UI in a timely manner, so the user experience suffers.

How do we execute the complicated function without interrupting the main thread?

To avoid blocking the main thread, we simply have to call these long running tasks from another thread. The current recommended way to do this on Android in Kotlin is using Coroutines. Before we jump in, we need to learn about dispatchers.

Dispatchers

Dispatchers are arguably the most important element of a coroutine. A dispatcher determines which thread(s) are used for coroutine execution. We can create our own dispatcher, but Kotlin provides four ready-made dispatchers which should cover 99% of our use-cases. They are:

  • Dispatchers.Main
  • Dispatchers.Default
  • Dispatchers.IO
  • Dispatchers.Unconfined

Main Dispatcher

The Main Dispatcher is restricted to the Main thread only

The main dispatcher is restricted to only using the main thread and is useful for performing anything UI related or small tasks that don’t block the main thread. Since we’re talking about concurrency, it might seem a bit weird to have a dispatcher that only runs in the thread we just mentioned that we are trying not to block. This makes more sense once you understand two things:

  • Changes to the UI can only be made from the main thread.
  • We can easily switch dispatchers using coroutines.

Default Dispatcher

The default dispatcher has access to a pool of threads equal to the number of physical cpu cores on the device. This is useful because when we have work that is limited only by the CPU (e.g. media transcoding, bitmap manipulation, file compression), we’re not wasting resources switching between threads.

Default dispatcher on a device with a 4 core CPU

IO Dispatcher

The IO dispatcher usually defaults to a pool of 64 threads. Additional threads in this pool are created or shutdown on demand. Conceptually, Dispatchers.IO is backed by an unlimited pool of threads. This is great for multiple network or storage operations where the CPU has to wait before continuing. Imagine you have a device with an 4 core CPU and you have to fire off 8 network calls at once. With Dispatchers.IO, you can fire off all 8 network calls before you even get a response from one of them.

IO Dispatcher on a device with any number of CPU cores

Unconfined Dispatcher

The unconfined dispatcher defaults to whichever thread the caller thread is using, and then can switch threads at the first suspension point. Using Dispatchers. Unconfined is not recommended unless you have a very specific use case. You can read more about it here.

In this situation, the coroutine is launched from the main thread, and then when work #1 hits a suspension point, it switches threads

Now that we are aware of which dispatcher(s) to use, there’s one more thing we should understand before we create a coroutine.

Scopes

So, how do we start a coroutine? First of all, we have to use a scope. A coroutine scope is a construct that defines the lifetime and context of a coroutine. Think of it as a coroutine lifecycle manager. There are a few ways to do this.

  • GlobalScope
  • MainScope
  • Creating our own CoroutineScope
  • ViewModelScope
  • LifecycleScope

GlobalScope is a CoroutineScope that is operating on the whole application lifetime. Think of it as a fire and forget scope. Anything running inside this scope will not keep the application alive. It also means that while the application is in the background, these threads may still run but are not guaranteed to be running. It is marked with the annotation @DelicateCoroutinesApi and is not recommended due to its limited circumstantial use.

Like GlobalScope, MainScope also suffers from the same issue around cancellation as GlobalScope. The difference between these two is that MainScope runs on Dispatchers.Main by default, and GlobalScope runs on Dispatchers.Default.

To avoid inadvertently creating memory leaks, we can create our own Coroutine scope. We do this using the CoroutineScope constructor. It takes just one parameter: CoroutineContext

CoroutineContext can be made up of to 3 parts:

  • A dispatcher
  • A job
  • A coroutine exception handler

Let’s set aside those parts for now, and create a scope just like this:

val scope = CoroutineScope(EmptyCoroutineContext)

We use EmptyCoroutineContext because we don’t want to specify any of those parts for this example.

Now, to start a coroutine, we simply use launch.

scope.launch {
// We are in a coroutine!
}

Now we can call asynchronous functions such as delay.

scope.launch {
println("Waiting for 1 second...")
delay(1000)
println("We waited for 1 second!")
}
14:14:17.035  com.example.myapplication  Waiting for 1 second...
14:14:18.038 com.example.myapplication We waited for 1 second!

Hooray! We delayed a log message for a second without interrupting the main thread.

There are a couple of problems with this approach. One of the problems is that we’ve basically just recreated GlobalScope.

@DelicateCoroutinesApi
public object GlobalScope : CoroutineScope {
/**
* Returns [EmptyCoroutineContext].
*/
override val coroutineContext: CoroutineContext
get() = EmptyCoroutineContext
}

That means we’re going to have the same problem that GlobalScope has: memory leaks. So if our coroutine looked like this:

scope.launch {
while (true) {
delay(1000)
println("Waited for 1 second!")
}
}

Putting the app into the background would not stop this executing. Destroying the Activity/Fragment/ViewModel that launched this coroutine would not stop this executing. Only when the app is killed would this coroutine stop.

So how can we prevent the coroutine from continuing to run when we don’t want it to run? We simply cancel it.

scope.cancel()

Nice! So we just create our own scopes and cancel them on onCleared() in ViewModels and onDestroyView() in Fragments right? Wrong!

ViewModelScope and LifecycleScope

Another problem with the above approach is that we’re adding extra boilerplate code to ViewModels/Fragments/Activities. Thankfully, Android provides us with two scopes which handle cancelling our coroutines automatically. We can use them like so:

For Fragments/Activities or classes implementing LifecycleOwner:

lifecycleScope.launch {
// suspend functions go here!
}

For ViewModels:

viewModelScope.launch {
// suspend functions go here!
}

Now when we run the following coroutine inside a fragment, it will be cancelled when the fragment is destroyed.

lifecycleScope.launch {
while (true) {
delay(1000)
println("Waited for 1 second!")
}
}

Both of these scopes will by default use Dispatchers.Main. How do we choose our own dispatcher though? Just like CoroutineScope, launch also takes CoroutineContext as a parameter. Since a dispatcher is part of a CoroutineContext, we can do the following:

viewModelScope.launch(Dispatchers.IO) {
// We just chose our own dispatcher!
}

In the next part, we’ll discuss the other parts of CoroutineContext, how to cancel coroutines manually without cancelling the whole scope, how to switch threads, why lifecycleScope and viewModelScope both default to the main thread, and more.

--

--