Constructing a coroutine scope
This is a chapter from the book Kotlin Coroutines. You can find it on LeanPub or Amazon.
In previous chapters, we've discussed all the components needed to construct a proper scope. Now it is time to summarize this knowledge and see how it is typically used.
CoroutineScope factory function
CoroutineScope
is an interface with a single property coroutineContext
.
Therefore, we can make a class implement this interface and just directly call coroutine builders in it.
However, this approach is not very popular. On one hand, it is convenient; on the other, it is problematic that in such a class we can directly call other CoroutineScope
methods like cancel
or ensureActive
. Even accidentally, someone might cancel the whole scope, and coroutines will not start anymore. Instead, we generally prefer to hold a coroutine scope as an object in a property and use it to call coroutine builders.
The easiest way to create a coroutine scope object is by using the CoroutineScope
factory function1. It creates a scope with provided context (and an additional Job
for structured concurrency if no job is already part of the context).
Constructing a background scope
When you need to start an asynchronous coroutine, you need a scope. Using a scope from a suspending function bounds this coroutine to the caller coroutine. If you want to start a new process, you need a custom scope. This scope is typically injected via the constructor, and we typically name it scope
or backgroundScope
.
So, how should we construct such a scope? Typically, we want it to:
- have a specified dispatcher;
- include
SupervisorJob
to not cancel all coroutines started on this scope if one fails; - optionally some
CoroutineExceptionHandler
to set custom logging, to respond with proper error codes, or to send dead letters2.
Regarding the dispatcher, the most popular options are:
Dispatchers.Default
, which is a great choice if we are sure that we do not make any blocking calls;Dispatchers.IO
with a limited number of threads, which is a good choice if we might have some blocking calls. This option can be both used if we plan to use this scope for blocking calls, and if we do not need that, but we are worried that in some coroutines we might accidentally make some blocking calls (then we pay a price of having more threads than we need, but an accidental blocking call will cause less harm);- a dispatcher made from
Executors
, which is a good choice if you want to configure your threads in ways that are not possible with Kotlin Coroutines dispatchers (like setting priorities, names, type, etc.).
Here is an example configuration for a Spring Boot application. We use SupervisorJob
, a custom dispatcher with a limited number of threads, and we set a custom exception handler to log all unhandled exceptions.
A similar scope can also be used to start coroutines for each request in a web server. Though, that is typically done by the framework we use (both Spring Boot and Ktor have their own ways to start coroutines for each request).
Of course, a scope can also be created in the class that needs it. Dependency injection is not mandatory, but it is very useful for testing and for making the code more modular. Here is an example of how to construct a scope in a class:
Constructing a scope on Android
When we construct a scope on Android, we typically want to:
- use
Dispatchers.Main.immediate
as the default dispatcher; - use
SupervisorJob
to not cancel all coroutines if one fails; - cancel all coroutines when the view or view model is destroyed;
- probably use some
CoroutineExceptionHandler
to set default ways to handle exceptions.
This is what a scope construction in a base view model might look like:
In modern Android applications, instead of defining your own scope, you can also use viewModelScope
(needs androidx.lifecycle:lifecycle-viewmodel-ktx
version 2.2.0
or higher) or lifecycleScope
(needs androidx.lifecycle:lifecycle-runtime-ktx
version 2.2.0
or higher). How they work is nearly identical to what we've just constructed: they use Dispatchers.Main.immediate
and SupervisorJob
, and they cancel the job when the view model or lifecycle owner gets destroyed.
Using viewModelScope
and lifecycleScope
is convenient and recommended if we do not need any special context as a part of our scope (like CoroutineExceptionHandler
). This is why this approach is chosen by most Android applications.
Summary
This chapter showed how to construct a coroutine scope in different environments. Such a scope can be used to start coroutines and manage their lifecycle. We've seen that the most common way to construct a scope is by using the CoroutineScope
factory function. We've learned, that we should use SupervisorJob
to not cancel all coroutines if one fails. We can set a custom dispatcher and a custom exception handler to handle exceptions. We can also cancel all coroutines by calling cancelChildren
on the context. Finally, we've learned that in Android applications we can use viewModelScope
and lifecycleScope
.
A function that looks like a constructor is known as a fake constructor. This pattern is explained in Effective Kotlin Item 32: Consider factory functions instead of secondary constructors.
This is a popular microservices pattern that is used when we use a software bus, like in Apache Kafka.