Instructions

Coroutine Scopes

In the previous lesson, you used a scope to start a coroutine. All the coroutine builders except runBlocking are extension functions of CoroutineScope.

The CoroutineScope holds a CoroutineContext. The CoroutineContext is a set of elements that define the behavior of the coroutines started in it. The most popular elements of the context are:

  • Job: The handle to cancel the coroutine and check its state.
  • CoroutineDispatcher: The provider of threads on which the coroutine runs.
  • CoroutineExceptionHandler: The handler for uncaught exceptions.
  • CoroutineName: The name of the coroutine for debugging purposes.

They’re all optional. But the CoroutineScope always has a Job in its context. If there’s no job provided, a new one is created. The exception is GlobalScope. It lasts as long as the process is running and isn’t cancelable. The Kotlin Coroutines library documentation doesn’t recommend using GlobalScope because of potential memory leaks. As such, it won’t be a part of this course.

There are two kinds of Jobs, the plain Job and the SupervisorJob. In the latter, the child coroutines can fail without canceling the parent coroutine and affecting other sibling coroutines. For a plain Job, the failure of the child coroutine cancels the parent coroutine and all its other children. This isn’t what you usually want in applications.

The scope is a way to control the lifetime of a coroutine. It acts as a container for the coroutine’s elements. When the scope gets canceled, all the coroutines running in that scope get canceled along with it. The canceled scope can’t start new coroutines.

You can create your own CoroutineScope. You can use a CoroutineScope() function that takes a CoroutineContext as an argument. There’s also a MainScope() function that creates a scope with the Main dispatcher and the SupervisorJob as part of the context.

But, you’ll more often use the predefined scopes bound to the lifecycle of Android entities. For example, the Activity, Fragment, ViewModel, or the composable functions. Those scopes are canceled when their respective entities end their lives.

You don’t have to worry about canceling them. In the case of custom scopes, you have to cancel them manually. If you forget to do it, the coroutines that started in that scope will continue running, which may lead to memory leaks or other problems. For example, if there’s a reference to the Activity in the coroutine, it may prevent the Activity from being garbage-collected after it gets destroyed. Or, imagine a coroutine inside a ViewModel that fetches data from the network. If the ViewModel gets destroyed, processing the fetched data doesn’t make sense. It won’t be displayed anywhere. Such a coroutine will only consume resources and drain the battery.

It’s also possible to create a nested coroutine scope inside a coroutine or a suspending function using the coroutineScope() or the supervisorScope() functions. This may be useful when you want to use the coroutine builders to start multiple nested child coroutines. For example, to perform several network requests in parallel, wait for all of them to finish, and then process the results. Take a look at the following example:

suspend fun fetchAndProcessData() = coroutineScope { 
  val deferred1 = async { fetchData1() }
  val deferred2 = async { fetchData2() }
  val data = awaitAll(deferred1, deferred2)
  processData(data)
}

Without the nested scope, you won’t be able to use the async builders. That’s because they’re extension functions on the CoroutineScope, and the suspend function doesn’t provide any scope itself. Both child coroutines fetching data can run in parallel. It depends on the dispatcher if they actually do. The awaitAll function suspends the coroutine until all the async coroutines finish successfully or as soon as one of them fails.

In the case of the coroutineScope function, the failure of one of the children’s coroutines cancels the parent. If one of the fetchData functions fails, the entire coroutine fails, and the other one running fetchData gets canceled.

But in the case of the supervisorScope function, the failure of one of the children’s coroutines doesn’t cancel the parent. The awaitAll function will still fail, but before its invocation, you can access the results of the successful children. For example, if the fetchData1 first succeeded and the fetchData2 failed later, you can still access the data fetched by the fetchData1 call using the deferred1 variable.

The entire coroutineScope or supervisorScope function invocations suspends until the lambda coroutine finishes.

Kotlin Coroutines follows a principle of structured concurrency. There are several rules to follow to achieve that. Firstly, all the coroutines are bound to some scope. You can’t start a coroutine without a scope. launch and async builders are extension functions on the CoroutineScope. runBlocking is a top-level function that provides it’s own scope. If you start another coroutine from within a coroutine, they’ll form a parent-child hierarchy.

If you cancel the parent, it will also cancel all the children. New children won’t start from a canceled parent. If one of the children throws an uncaught exception, the effect depends on the job of the parent scope. In the case of a regular Job, the failure will cancel the parent and its other children and grandchildren and so on. If the parent uses a SupervisorJob, a failure won’t affect the parent and other siblings of the failed coroutine. Look at the following cheat sheet:

Structured concurrency Every coroutine starts in a scope Other siblings may be canceled or not Coroutines form a hierarchy Parents wait for children Canceling the parent cancels all the children Errors in children propagate to parents 1 2 3 6 5 4

Dispatching Coroutines

The dispatcher is a mechanism that determines which thread the coroutine runs on. The dispatcher is like a thread pool. There are several predefined dispatchers in the Kotlin coroutines library:

  • Default: The default dispatcher for CPU-bound tasks.
  • IO: The dispatcher for Input/Output tasks like network requests or database operations.
  • Main: The dispatcher for Android’s main thread.
  • Unconfined: The dispatcher that doesn’t impose any thread - the coroutine starts on the current thread and resumes on the thread that resumes it.

Default Dispatcher

As the name suggests, the Default dispatcher is used by coroutines builders if you specify no particular dispatcher and if the CoroutineScope you use to create coroutines doesn’t have a dispatcher. Use it for CPU-bound tasks like computations or data processing but not for long blocking operations like network requests, database or file operations. The Default dispatcher is backed by a shared pool of threads. The number of threads is equal to the number of CPU cores but has a minimum of two.

IO Dispatcher

The IO dispatcher is suitable for Input/Output tasks like network requests, filesystem, or database operations. How do such operations differ from CPU-bound tasks? IO operations are usually blocking. Reading from the disk or network takes time, a lot of time. And you usually have to read the data in one go. The CPU is much faster than the disk or network. It needs to wait for the data to arrive or depart. The CPU can do other work during that time. The IO dispatcher uses a pool of threads that grows on demand. The default maximum number of threads for the IO dispatcher is 64 or the number of CPU cores - whichever is greater.

Note that the threads performing IO operations are doing nothing most of the time. So, it’s safe to spawn more threads than the number of CPU cores. Such waiting threads do consume memory but don’t consume CPU cycles. So, there needs to be a limit on the number of threads but it can be much higher than in the case of the Default dispatcher. Note that some threads may be common for the IO and Default dispatchers. There’s nothing special in the threads themselves.

Main Dispatcher

The Main dispatcher is the dispatcher for the main (UI) thread. It always provides the same thread. On Android platforms, it’s the Android main thread.

Unconfined Dispatcher

The Unconfined dispatcher is the dispatcher that doesn’t impose any thread. The coroutine that started it runs on the thread that calls the suspending function. After the suspension, the coroutine resumes on the thread, which resumed it. Those two threads may be different. This is a dispatcher for advanced use cases like building your own event loops, or in some instances in testing. It isn’t a part of this course.

Switching Dispatchers

You can switch dispatchers in a coroutine. The withContext function is the way to do it. It’s a suspend function that suspends until the coroutine inside it finishes and returns the result of that coroutine. Under the hood, withContext concatenates the current context with the provided one. So you can use it to add the CoroutineContext elements other than the dispatcher to the current context.

If the context in the argument of the withContext function contains the dispatcher, the coroutine gets dispatched to that dispatcher. The withContext is useful when you want to trigger the blocking operation from the Main dispatcher, perform that operation on the IO dispatcher and then return to the Main dispatcher to display the result. See the following example:

suspend fun fetchAndDisplayData() {
  val data = withContext(Dispatchers.IO) { fetchData() }
  displayData(data)
}

The code in the lambda passed to the withContext may run immediately or may be postponed because some other coroutine gets dispatched before it. It can also dispatch the coroutine to the same dispatcher that you call the function in.

There is one exception to immediate and postponed dispatch strategies. It’s the Main.immediate dispatcher. It doesn’t postpone the execution of the coroutine if it’s already running on the main thread. The coroutine continues execution immediately without any dispatching. It may be useful in some advanced use cases when you care about the order of the operations. The Main.immediate dispatcher is useful in some cases when using Jetpack Compose, but isn’t a part of this course.

As for withContext, it’s technically considered to be one of the coroutine builder functions. But unlike all the other builders, it doesn’t create a new coroutine itself. It just combines the CoroutineContext elements. launch, runBlocking and async are true coroutine builders as they also create new coroutines. And withContext is mainly used to bridge between threads and return values.

See forum comments
Download course materials from Github
Previous: Introduction Next: Demo