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 on it.
However, this approach is not very popular because although it is convenient it is problematic that we can directly call other CoroutineScope
methods like cancel
or ensureActive
in such a class. Even accidentally, someone might cancel the whole scope, therefore 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 the 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 binds 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
so all coroutines started in this scope are not cancelled if one fails; - optionally, include 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 will not make any blocking calls;Dispatchers.IO
, which is a great choice if we plan to block threads, only remember to uselimitedParallelism
on services.Dispatchers.IO.limitedParallelism(n)
, which is a great choice if you plan to block threads, or you don’t plan this, but you want to be prepared if some developer blocks threads by accident (this option is nearly as CPU-efficient asDispatchers.Default
, but it is much safer as accidental thread-blocking costs us much less);- 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, types, etc.).
Here is an example configuration for a Spring Boot application. We use SupervisorJob
and 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 backend application. However, this is typically done by the framework we use (both Spring Boot and Ktor have their own ways of starting 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 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 prevent all coroutines being cancelled if one fails; - cancel all coroutines when the view or view model is destroyed;
- probably use a
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 lifecycle-viewmodel-ktx
from androidx.lifecycle
version 2.2.0
or higher) or lifecycleScope
(needs lifecycle-runtime-ktx
from androidx.lifecycle
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 don't 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 has shown how to construct a coroutine scope in different environments. Such a scope can be used to start coroutines and manage their lifecycles. 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 prevent all coroutines being cancelled 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
in the context. Finally, we've learned that we can use viewModelScope
and lifecycleScope
in Android applications.
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.