Coroutine scope functions
This is a chapter from the book Kotlin Coroutines. You can find it on LeanPub or Amazon.
Imagine that in a suspending function you need to concurrently get data from two (or more) endpoints. Before we explore how to do this correctly, let's see some suboptimal approaches.
Approaches that were used before coroutine scope functions were introduced
The first approach is calling suspending functions from a suspending function. The problem with this solution is that it is not concurrent (so, if getting data from one endpoint takes 1 second, a function will take 2 seconds instead of 1).
To make two suspending calls concurrently, the easiest way is by wrapping them with async
. However, async
requires a scope, and using GlobalScope
is not a good idea.
GlobalScope
is just a scope with EmptyCoroutineContext
.
If we call async
on a GlobalScope
, we will have no relationship to the parent coroutine. This means that the async
coroutine:
- cannot be cancelled (if the parent is cancelled, functions inside async still run, thus wasting resources until they are done);
- does not inherit a scope from any parent (it will always run on the default dispatcher and will not respect any context from the parent).
The most important consequences are:
- potential memory leaks and redundant CPU usage;
- the tools for unit testing coroutines will not work here, so testing this function is very hard.
This is not a good solution. Let's take a look at another one, in which we pass a scope as an argument:
This one is a bit better as cancellation and proper unit testing are now possible. The problem is that this solution requires this scope to be passed from function to function. Also, such functions can cause unwanted side effects in the scope; for instance, if there is an exception in one async
, the whole scope will be shut down (assuming it is using Job
, not SupervisorJob
). What is more, a function that has access to the scope could easily abuse this access and, for instance, cancel this scope with the cancel
method. This is why this approach can be tricky and potentially dangerous.
In the above code, we would like to at least see Tweets, even if we have a problem fetching user details. Unfortunately, an exception on getFollowersNumber
breaks async
, which breaks the whole scope and ends the program. Instead, we would prefer a function that just throws an exception if it occurs. Time to introduce our hero: coroutineScope
.
coroutineScope
coroutineScope
is a suspending function that starts a scope. It returns the value produced by the argument function.
Unlike async
or launch
, the body of coroutineScope
is called in-place. It formally creates a new coroutine, but it suspends the previous one until the new one is finished, so it does not start any concurrent process. Take a look at the below example, in which both delay
calls suspend runBlocking
.
The provided scope inherits its coroutineContext
from the outer scope, but it overrides the context's Job
. Thus, the produced scope respects its parental responsibilities:
- inherits a context from its parent;
- waits for all its children before it can finish itself;
- cancels all its children when the parent is cancelled.
In the example below, you can observe that "After" will be printed at the end because coroutineScope
will not finish until all its children are finished. Also, CoroutineName
is properly passed from parent to child.
In the next snippet, you can observe how cancellation works. A cancelled parent leads to the cancellation of unfinished children.
Unlike coroutine builders, if there is an exception in coroutineScope
or any of its children, it cancels all other children and rethrows it. This is why using coroutineScope
would fix our previous "Twitter example". To show that the same exception is rethrown, I changed a generic Error
into a concrete ApiException
.
This all makes coroutineScope
a perfect candidate for most cases when we just need to start a few concurrent calls in a suspending function.
As we've already mentioned, coroutineScope
is nowadays often used to wrap a suspending main body. You can think of it as the modern replacement for the runBlocking
function:
The function coroutineScope
creates a scope out of a suspending context. It inherits a scope from its parent and supports structured concurrency.
To make it clear, there is practically no difference between the below functions, except that the first one calls getProfile
and getFriends
sequentially, where the second one calls them simultaneously.
coroutineScope
is a useful function, but it’s not the only one of its kind.
Coroutine scope functions
There are more functions that create a scope and behave similarly to coroutineScope
. supervisorScope
is like coroutineScope
but it uses SupervisorJob
instead of Job
. withContext
is a coroutineScope
that can modify coroutine context. withTimeout
is a coroutineScope
with a timeout. Each of those functions will be better explained in the following parts of this chapter. For now, I just want you to know there are such functions because if there is a group of similar functions, it makes sense that it should have a name. So how should we name this group? Some people call them "scoping functions", but I find this confusing as I am not sure what is meant by "scoping". I guess that whoever started using this term just wanted to make it different from "scope functions" (functions like let
, with
or apply
). It is not really helpful as those two terms are still often confused. This is why I decided to use the term "coroutine scope functions". It is longer but should cause
fewer misunderstandings, and I find it more correct. Just think about that: coroutine scope functions are those that are used to create a coroutine scope in suspending functions.
On the other hand, coroutine scope functions are often confused with coroutine builders, but this is incorrect because they are very different, both conceptually and practically. To clarify this, the table below presents the comparison between them.
Coroutine builders (except for runBlocking ) | Coroutine scope functions |
---|---|
launch , async , produce | coroutineScope , supervisorScope , withContext , withTimeout |
Are extension functions on CoroutineScope . | Are suspending functions. |
Take coroutine context from CoroutineScope receiver. | Take coroutine context from suspending function continuation. |
Exceptions are propagated to the parent through Job . | Exceptions are thrown in the same way as they are from/by regular functions. |
Starts an asynchronous coroutine. | Starts a coroutine that is called in-place. |
Now think about runBlocking
. You might notice that it looks like it has more in common with coroutine scope functions than with builders. runBlocking
also calls its body in-place and returns its result. The biggest difference is that runBlocking
is a blocking function, while coroutine scope functions are suspending functions. This is why runBlocking
must be at the top of the hierarchy of coroutines, while coroutine scope functions must be in the middle.
withContext
The withContext
function is similar to coroutineScope
, but it additionally allows some changes to be made to the scope. The context provided as an argument to this function overrides the context from the parent scope (the same way as in coroutine builders). This means that withContext(EmptyCoroutineContext)
and coroutineScope()
behave in exactly the same way.
The function withContext
is often used to set a different coroutine scope for part of our code. Usually, you should use it together with dispatchers, as will be described in the next chapter.
You might notice that the way
coroutineScope { /*...*/ }
works very similar to async with immediateawait
:async { /*...*/ }.await()
. AlsowithContext(context) { /*...*/ }
is in a way similar toasync(context) { /*...*/ }.await()
. The biggest difference is thatasync
requires a scope, wherecoroutineScope
andwithContext
take the scope from suspension. In both cases, it’s better to usecoroutineScope
andwithContext
, and avoidasync
with immediateawait
.
supervisorScope
The supervisorScope
function also behaves a lot like coroutineScope
: it creates a CoroutineScope
that inherits from the outer scope and calls the specified suspend block in it. The difference is that it overrides the context's Job
with SupervisorJob
, so it is not cancelled when a child raises an exception.
supervisorScope
is mainly used in functions that start multiple independent tasks.
If you use async
, silencing its exception propagation to the parent is not enough. When we call await
and the async
coroutine finishes with an exception, then await
will rethrow it. This is why if we want to truly ignore exceptions, we should also wrap await
calls with a try-catch block.
In my workshops, I am often asked if we can use withContext(SupervisorJob())
instead of supervisorScope
. No, we can't. When we use withContext(SupervisorJob())
, then withContext
is still using a regular Job
, and the SupervisorJob()
becomes its parent. As a result, when one child raises an exception, the other children will be cancelled as well. withContext
will also throw an exception, so its SupervisorJob()
is practically useless. This is why I find withContext(SupervisorJob())
pointless and misleading, and I consider it a bad practice.
withTimeout
Another function that behaves a lot like coroutineScope
is withTimeout
. It also creates a scope and returns a value. Actually, withTimeout
with a very big timeout behaves just like coroutineScope
. The difference is that withTimeout
additionally sets a time limit for its body execution. If it takes too long, it cancels this body and throws TimeoutCancellationException
(a subtype of CancellationException
).
The function withTimeout
is especially useful for testing. It can be used to test if some function takes more or less than some time. If it is used inside runTest
, it will operate in virtual time. We also use it inside runBlocking
to just limit the execution time of some function (this is then like setting timeout
on @Test
).
Beware that withTimeout
throws TimeoutCancellationException
, which is a subtype of CancellationException
(the same exception that is thrown when a coroutine is cancelled). So, when this exception is thrown in a coroutine builder, it only cancels it and does not affect its parent (as explained in the previous chapter).
In the above example, delay(1500)
takes longer than withTimeout(1000)
expects, so it throws TimeoutCancellationException
. The exception is caught by launch
from 1, and it cancels itself and its children, so launch
from 2. launch
started at 3 is also not affected.
A less aggressive variant of withTimeout
is withTimeoutOrNull
, which does not throw an exception. If the timeout is exceeded, it just cancels its body and returns null
. I find withTimeoutOrNull
useful for wrapping functions in which waiting times that are too long signal that something went wrong. For instance, network operations: if we wait over 5 seconds for a response, it is unlikely we will ever receive it (some libraries might wait forever).
Connecting coroutine scope functions
If you need to use functionalities from two coroutine scope functions, you need to use one inside another. For instance, to set both a timeout and a dispatcher, you can use withTimeoutOrNull
inside withContext
.
Additional operations
Imagine a case in which in the middle of some processing you need to execute an additional operation. For example, after showing a user profile you want to send a request for analytics purposes. People often do this with just a regular launch
in the same scope:
However, there are some problems with this approach. Firstly, this launch
does nothing here because coroutineScope
needs to await its completion anyway. So if you are showing a progress bar when updating the view, the user needs to wait until this notifyProfileShown
is finished as well. This does not make much sense.
The second problem is cancellation. Coroutines are designed (by default) to cancel other operations when there is an exception. This is great for essential operations. If getProfile
has an exception, we should cancel getName
and getFriends
because their response would be useless anyway. However, canceling a process just because an analytics call has failed does not make much sense.
So what should we do? When you have an additional (non-essential) operation that should not influence the main process, it is better to start it on a separate scope. Creating your own scope is easy. In this example, we create an analyticsScope
.
For unit testing and controlling this scope, it is better to inject it via a constructor:
Starting operations on an injected scope object is common. Passing a scope clearly signals that such a class can start independent calls. This means suspending functions might not wait for all the operations they start. If no scope is passed, we can expect that suspending functions will not finish until all their operations are done.
Summary
Coroutine scope functions are really useful, especially since they can be used in any suspending function. Most often they are used to wrap the whole function body. Although they are often used to just wrap a bunch of calls with a scope (especially withContext
), I hope you can appreciate their usefulness. They are a very important part of the Kotlin Coroutines ecosystem. You will see how we will use them through the rest of the book.