1

Scoping functions in Kotlin Coroutines

 2 years ago
source link: https://kt.academy/article/cc-scoping-functions
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

206_scoping_functions.jpg

Scoping functions in Kotlin Coroutines

This is a chapter from the book Kotlin Coroutines. You can find Early Access on LeanPub.

Imagine that in a suspending function you need to concurrently get data from two (or more) endpoints. Before we explore how to do that correctly, let's see some suboptimal approaches.

Approaches before scoping functions

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, the function will take 2 seconds instead of 1).

xxxxxxxxxx
// Data loaded sequentially, not simultaneously
suspend fun getUserProfile(): UserProfileData {
    val user = getUserData()
    val notifications = getNotifications()
    return UserProfileData(
        user = user,
        notifications = notifications,
    )
}

To make two suspending calls concurrently, the easiest way is by wrapping them with async. Although async requires a scope and using GlobalScope is not a good idea.

xxxxxxxxxx
// DON'T DO THAT
suspend fun getUserProfile(): UserProfileData {
    val user = GlobalScope.async { getUserData() }
    val notifications = GlobalScope.async {
        getNotifications()
    }
    return UserProfileData(
        user = user.await(),
        notifications = notifications.await(),
    )
}

GlobalScope is just a scope with EmptyCoroutineContext.

xxxxxxxxxx
public object GlobalScope : CoroutineScope {
    override val coroutineContext: CoroutineContext
        get() = EmptyCoroutineContext
}

If we call async on a GlobalScope, we will have no relationship to the parent coroutine. It means:

  • it cannot be canceled (if the parent would be canceled, functions inside async would still be running, wasting resources until they are done),
  • it is not inheriting 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 unnecessary calculations,
  • the tools for unit testing coroutines will not work here, and so testing this function is very hard.

This is not a good solution. Let's take a look at another one, in which we are passing scope as an argument:

xxxxxxxxxx
// DON'T DO THAT
suspend fun getUserProfile(
    scope: CoroutineScope
): UserProfileData {
    val user = scope.async { getUserData() }
    val notifications = scope.async { getNotifications() }
    return UserProfileData(
        user = user.await(),
        notifications = notifications.await(),
    )
}
// or
// DON'T DO THAT
fun CoroutineScope.getUserProfile(): UserProfileData {
    val user = async { getUserData() }
    val notifications = async { getNotifications() }
    return UserProfileData(
        user = user.await(),
        notifications = notifications.await(),
    )
}

This one is a bit better, as cancellation and proper unit testing are now possible. The problem is that it requires passing this scope from function to function. Also, such functions can cause unwanted side effects on the scope - for instance, if there would be an exception in one async, the whole scope would 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.

xxxxxxxxxx
data class Details(val name: String, val followers: Int)
data class Tweet(val text: String)
fun getFollowersNumber(): Int =
    throw Error("Service exception")
suspend fun getUserName(): String {
    delay(500)
    return "marcinmoskala"
}
suspend fun getTweets(): List<Tweet> {
    return listOf(Tweet("Hello, world"))
}
suspend fun CoroutineScope.getUserDetails(): Details {
    val userName = async { getUserName() }
    val followersNumber = async { getFollowersNumber() }
    return Details(userName.await(), followersNumber.await())
}
fun main() = runBlocking {
    val details = try {
        getUserDetails()
    } catch (e: Error) {
        null
    }
    val tweets = async { getTweets() }
    println("User: $details")
    println("Tweets: ${tweets.await()}")
}
// Only Exception...
Target platform: JVMRunning on kotlin v.1.5.31

In the above code, we would like to see tweets, if we have a problem calculating user details. Apparently, an exception in async broke the whole scope and ended the program. Instead, we would prefer a function that in case of an exception just throws it. Time to introduce our hero: coroutineScope.

coroutineScope

coroutineScope is a provided suspending function that starts a scope. It returns a value produced by the argument function (most often lambda expression).

xxxxxxxxxx
suspend fun <R> coroutineScope(
    block: suspend CoroutineScope.() -> R
): R

Unlike async or launch, scoping functions do not really create new coroutines. Their code block is called in-place. When they are suspended, we suspend a coroutine on which this scoping function is called. Take a look at the below example - both delay calls suspend runBlocking.

xxxxxxxxxx
fun main() = runBlocking {
    val a = coroutineScope {
        delay(1000)
        10
    }
    println("a is calculated")
    val b = coroutineScope {
        delay(1000)
        20
    }
    println(a) // 10
    println(b) // 20
}
// (1 sec)
// a is calculated
// (1 sec)
// 10
// 20
Target platform: JVMRunning on kotlin v.1.5.31

The provided scope inherits its coroutineContext from the outer scope, but overrides the context's Job. This way, the produced scope respects parental responsibilities:

  • inherits a context from its parent,
  • awaits for all children before it can finish itself,
  • cancels all its children, when the parent is canceled.

In the below example you can observe that "After" will be printed on the end because coroutineScope will not finish until all its children are finished. Also, CoroutineName is properly passed from parent to child.

xxxxxxxxxx
suspend fun longTask() = coroutineScope {
    launch {
        delay(1000)
        val name = coroutineContext[CoroutineName]?.name
        println("[$name] Finished task 1")
    }
    launch {
        delay(2000)
        val name = coroutineContext[CoroutineName]?.name
        println("[$name] Finished task 2")
    }
}
fun main() = runBlocking(CoroutineName("Parent")) {
    println("Before")
    longTask()
    println("After")
}
// Before
// (1 sec)
// [Parent] Finished task 1
// (1 sec)
// [Parent] Finished task 2
// After
Target platform: JVMRunning on kotlin v.1.5.31

In the next snippet, you can observe how cancellation works. Canceled parent leads to unfinished child cancellation.

xxxxxxxxxx
suspend fun longTask() = coroutineScope {
    launch {
        delay(1000)
        val name = coroutineContext[CoroutineName]?.name
        println("[$name] Finished task 1")
    }
    launch {
        delay(2000)
        val name = coroutineContext[CoroutineName]?.name
        println("[$name] Finished task 2")
    }
}
fun main(): Unit = runBlocking {
    val job = launch(CoroutineName("Parent")) {
        longTask()
    }
    delay(1500)
    job.cancel()
}
// [Parent] Finished task 1
Target platform: JVMRunning on kotlin v.1.5.31

Unlike coroutine builders, if there is an exception in coroutineScope or any of its children, it cancels 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.

xxxxxxxxxx
data class Details(val name: String, val followers: Int)
data class Tweet(val text: String)
class ApiException(
    val code: Int,
    message: String
) : Throwable(message)
fun getFollowersNumber(): Int =
    throw ApiException(500, "Service unavailable")
suspend fun getUserName(): String {
    delay(500)
    return "marcinmoskala"
}
suspend fun getTweets(): List<Tweet> {
    return listOf(Tweet("Hello, world"))
}
suspend fun getUserDetails(): Details = coroutineScope {
    val userName = async { getUserName() }
    val followersNumber = async { getFollowersNumber() }
    Details(userName.await(), followersNumber.await())
}
fun main() = runBlocking<Unit> {
    val details = try {
        getUserDetails()
    } catch (e: ApiException) {
        null
    }
    val tweets = async { getTweets() }
    println("User: $details")
    println("Tweets: ${tweets.await()}")
}
// User: null
// Tweets: [Tweet(text=Hello, world)]
Target platform: JVMRunning on kotlin v.1.5.31

This all makes coroutineScope a perfect candidate for most cases when we just need to start a few concurrent calls in a suspending function.

xxxxxxxxxx
suspend fun getUserProfile(): UserProfileData =
    coroutineScope {
        val user = async { getUserData() }
        val notifications = async { getNotifications() }
        UserProfileData(
            user = user.await(),
            notifications = notifications.await(),
        )
    }

As we've already mentioned, coroutineScope is nowadays often used to wrap suspending main body. You can think of it as the modern replacement for the runBlocking function:

xxxxxxxxxx
suspend fun main(): Unit = coroutineScope {
    launch {
        delay(1000)
        println("World")
    }
    println("Hello, ")
}
// Hello
// (1 sec)
// World
Target platform: JVMRunning on kotlin v.1.5.31

The function coroutineScope creates a scope out of a suspending context. It inherits a scope from its parent and supports structured concurrency. It is a useful function, but not alone of its type. Other similar functions we need to know are withContext and supervisorScope. Those, and other functions that are making a scope, but are not coroutine builders, are called scoping functions.

withContext

The function withContext is similar to coroutineScope, which additionally allows making some changes on the scope. The context provided as an argument to this function is added to the context from the parent scope (the same way as in coroutine builders). This means that withContext(EmptyCoroutineContext) and coroutineScope() behave exactly the same way.

xxxxxxxxxx
fun CoroutineScope.log(text: String) {
    val name = this.coroutineContext[CoroutineName]?.name
    println("[$name] $text")
}
fun main() = runBlocking(CoroutineName("Parent")) {
    log("Before")
    withContext(CoroutineName("Child 1")) {
        delay(1000)
        log("Hello 1")
    }
    withContext(CoroutineName("Child 2")) {
        delay(1000)
        log("Hello 2")
    }
    log("After")
}
// [Parent] Before
// (1 sec)
// [Child 1] Hello 1
// (1 sec)
// [Child 2] Hello 2
// [Parent] After
Target platform: JVMRunning on kotlin v.1.5.31

Function withContext is often used to set a different coroutine scope for part of our code. Most often together with dispatchers, that will be described in the next chapter.

xxxxxxxxxx
launch(Dispatchers.Main) {
    view.showProgressBar()
    withContext(Dispatchers.IO) {
        fileRepository.saveData(data)
    }
    view.hideProgressBar()
}

You might notice, that the way how coroutineScope { /*...*/ } works is very similar to async with immediate async { /*...*/ }.await(). Also withContext(context) { /*...*/ } is in a way similar to async(context) { /*...*/ }.await(). The biggest difference is that async requires scope, where coroutineScope and withContext take it from suspension. In both cases prefer coroutineScope and withContext, and avoid async with immediate await.

supervisorScope

The function supervisorScope also behaves a lot like coroutineScope - creates a CoroutineScope that inherits from the outer scope, and calls the specified suspend block with this scope. The difference is that it overrides context's Job with SupervisorJob, so it is not canceled when a child raises an exception.

xxxxxxxxxx
fun main() = runBlocking {
    println("Before")
    supervisorScope {
        launch {
            delay(1000)
            throw Error()
        }
        launch {
            delay(2000)
            println("Done")
        }
    }
    println("After")
}
// Before
// Exception...
// Done
// After
Target platform: JVMRunning on kotlin v.1.5.31

supervisorScope is mainly used in functions that start multiple independent tasks.

xxxxxxxxxx
suspend fun notifyAnalytics(actions: List<UserAction>) =
    supervisorScope {
        actions.forEach { action ->
            launch {
                notifyAnalytics(action)
            }
        }
    }

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 long time behaves just like coroutineScope. The difference is that withTimeout additionally sets time limit for its body execution. If it takes too long, it cancels this body, and throws TimeoutCancellationException (subtype of CancellationException).

xxxxxxxxxx
import kotlinx.coroutines.*
suspend fun test(): Int = withTimeout(1500) {
    delay(1000)
    println("Still thinking")
    delay(1000)
    println("Done!")
    42
}
suspend fun main(): Unit = coroutineScope {
    try {
        test()
    } catch (e: TimeoutCancellationException) {
        println("Cancelled")
    }
    delay(1000) // Extra timeout does not help,
    // `test` body was cancelled
}
// Still thinking
// Cancelled
Target platform: JVMRunning on kotlin v.1.5.31

Function withTimeout is especially useful for testing. It can be used to both test if some function takes more or less than some time. If it is used inside runBlockingTest, it will operate on virtual time. We also use it inside runBlocking to just limit excecution time of some function (it is then like setting timeout on @Test).

xxxxxxxxxx
// will not start, because runBlockingTest requires kotlinx-coroutines-test, but you can copy it to your project
import kotlinx.coroutines.*
import kotlinx.coroutines.test.runBlockingTest
import org.junit.Test
class Test {
    @Test
    fun testTime2() = runBlockingTest {
        withTimeout(1000) {
            // something that should less than 1000
            delay(900) // virtual time
        }
    }
    @Test(expected = TimeoutCancellationException::class)
    fun testTime1() = runBlockingTest {
        withTimeout(1000) {
            // something that should take more than 1000
            delay(1100) // virtual time
        }
    }
    @Test
    fun testTime3() = runBlocking {
        withTimeout(1000) {
            // normal test, that should not take too long
            delay(900) // really waiting 900 ms
        }
    }
}

Beware, that since withTimeout throws TimeoutCancellationException, that is a subtype of CancellationException. So when this exception is thrown in a coroutine builder, it only cancels it, without affecting its parent.

xxxxxxxxxx
import kotlinx.coroutines.*
suspend fun main(): Unit = coroutineScope {
    launch {
        launch { // cancelled by its parent
            delay(2000)
            println("Will not be printed")
        }
        withTimeout(1000) { // we cancel launch
            delay(1500)
        }
    }
    launch {
        delay(2000)
        println("Done")
    }
}
// (2 sec)
// Done
Target platform: JVMRunning on kotlin v.1.5.31
  1. delay(1500) takes longer that withTimeout(1000) expects, so it throws TimeoutCancellationException.
  2. The exception is caught by launch from line 2, and it cancels itself and its children, so also launch from line 3.
  3. launch from line 11 is not affected.

A less aggressive variant of withTimeout is withTimeoutOrNull. It does not throw an exception. In case of exceeded timeout, it just cancels its body and returns null. I find withTimeoutOrNull useful to wrap functions in which too long waiting time signalize that something went wrong. For instance network operations - if we wait over 5 seconds for response, it is unlikely we will ever receive it.

xxxxxxxxxx
import kotlinx.coroutines.*
class User()
suspend fun fetchUser(): User {
    // Runs forever
    while (true) { yield() }
}
suspend fun getUserOrNull(): User? =
    withTimeoutOrNull(1000) {
        fetchUser()
    }
suspend fun main(): Unit = coroutineScope {
    val user = getUserOrNull()
    println("User: $user")
}
// (1 sec)
// User: null
Target platform: JVMRunning on kotlin v.1.5.31

Summary

Scoping 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 see their usefulness. They are a very important part of Kotlin Coroutines ecosystem. You will see how we will use them through the rest of the book.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK