Max Kim
Art and science of writing good code

Art and science of writing good code

Things every Kotlin Developer should know about Coroutines. Part 2: CoroutineScope.

Things every Kotlin Developer should know about Coroutines. Part 2: CoroutineScope.

Max Kim's photo
Max Kim
·Dec 14, 2021·

11 min read

Subscribe to my newsletter and never miss my upcoming articles

Listen to this article

Table of contents

In my experience, CoroutineScope is one of the less understood parts of the coroutines library, even though it is one of the most important. In Android, we are spoiled with coroutine scopes kindly provided to us by lifecycle libraries, but for many developers, things quickly get confusing as soon as there is a need to create a custom CoroutineScope, or if they need to write coroutines in an environment that doesn't provide a pre-defined scope.

That said, by the end of this article, you will know everything you need to use the CoroutineScope comfortably and correctly.

What is a CoroutineScope

Declaration

First, we have to understand what a CoroutineScope is. There is no better place to look for the answers than its declaration:

public interface CoroutineScope {
    public val coroutineContext: CoroutineContext
}

As you can see, it is a very simple interface, but there is quite a lot to unpack here.

A CoroutineScope is just a simple wrapper for a CoroutineContext. In a technical sense, it is nothing more than a CoroutineContext, and the only reason it exists and has a different name is to differentiate the intended purpose of the two.

You can read more about this in Roman Elizarov's blog post on the topic. I like how in the beginning of his post he gives an example of this concept:

Different uses of physically near-identical things are usually accompanied by giving those things different names to emphasize the intended purpose. Depending on the use, seamen have a dozen or more words for a rope though it might materially be the same thing.

So why do we need to have this separate CoroutineScope?

Intended purpose of the CoroutineScope

Coroutines are very cheap and we are encouraged to create them as we see fit. We can launch coroutines in different ways from many different places. Some might complete fast, and some might take a long time. Some might have multiple child coroutines of their own. And any of them can potentially fail.

In the early days of the coroutines library, we would launch coroutines in a fire-and-forget manner. However, if we would try to manage all those asynchronously running coroutines in an application that has components with a limited lifecycle, for example, a UI screen that could be closed by the user and should cancel all its work, the complexity of coroutine management would grow exponentially.

Therefore, at one point in its development, the coroutines library had a major ideology shift called Structured Concurrency. It aimed for all coroutines to be launched in a defined scope, which would allow for an easy way to manage cancellation, exception handling and not allow resource leaking.

And that is the intended purpose of a CoroutineScope - to enforce structured concurrency.

We will focus on structured concurrency and how to practically comply with its principles in Part 3 of this series. For now, however, there is still much to discuss about the CoroutineScope itself.

Coroutine launchers

There are two ways to launch a new coroutine - with either launch or async function. Let's quickly look at their declaration.

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job
public fun <T> CoroutineScope.async(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> T
): Deferred<T>

As much as I would like to dissect every parameter and inner workings of these functions, I will not bore you with that. The main takeaway, which is essential to know and understand, is that these are extension functions on the CoroutineScope interface.

That is why we cannot launch a new coroutine without a scope.

We will return to this concept throughout this article.

Creating your own scopes

Scope builder functions

There are two types of scope builder functions - suspending and non-suspending. Since we can only call suspending functions from a coroutine, we cannot use them to create our top-level scopes. Still, they are very useful, and we will discuss them later.

To create a top-level scope we can use one of the following builder functions:

  • MainScope()
  • CoroutineScope(context: CoroutineContext)

Notice that they are functions - in Kotlin, we often use the pascal case in function names if they build and return an object. That makes for a cleaner and more readable API:

// this is a function that returns an instance
// of CoroutineScope, not a constructor
val mainScope = MainScope()

The main difference between these two scopes is that the MainScope() uses Dispatchers.Main for its coroutines, making it perfect for UI components, and the CoroutineScope() uses Dispatchers.Default by default.

Another difference is that CoroutineScope() takes in a CoroutineContext as a parameter.

If you have read Part 1 of this series, you should be familiar with CoroutineContext and how to use it.

Here are some examples:

val scope1 = CoroutineScope(SupervisorJob())

val scope2 = CoroutineScope(SupervisorJob() + Dispatchers.IO)

val scope3 = CoroutineScope(SupervisorJob() + CoroutineName("MyScope"))

Note that we are using SupervisorJob instead of Job. It is an important concept regarding error handling, which we will discuss in-depth in Part 4 of this series.

There are a couple of things to consider when creating a scope using the CoroutineScope() function.

The context parameter is mandatory. That said, if you don't pass a Job element, a default Job() will be created since a scope must have a Job to enforce structured concurrency. Also, if we don't pass a coroutine dispatcher, Dispatchers.Default will be used.

However, given that not every developer knows the inner workings of these functions, I find it convenient and more readable to define the context of my scopes explicitly:

// even though adding Dispatchers.Default is not necessary,
// I find it more readable to state every element
// of the context explicitly
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

When using the MainScope() function, however, we don't need to specify the context - it automatically creates a context with a SupervisorJob and a Dispatchers.Main:

public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)

If you need to add an Element to the MainScope you can use the plus operator function, just like we would with a CoroutineContext:

val mainScope = MainScope() + CoroutineName("MainScope")

Legacy convention

When CoroutineScope was first introduced into the coroutines library, the official documentation described another way to create scopes. And although the documentation was since updated to recommend a more readable and straightforward way to create scopes as described above, you might still stumble across the legacy way in some codebase.

The legacy way recommended creating coroutine scopes by implementing the CoroutineScope interface:

// standard implementation
class MyComponent : CoroutineScope {
    override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default
}
// implementation using delegation
class MainActivity : AppCompatActivity(), CoroutineScope by MainScope()

However, you should always use the non-legacy approach in your code, which is more straightforward to use.

Suspending scoped concurrency

Suspending scope builders

We are not done with scope builder functions just yet. We have looked at non-suspending ways to create coroutine scopes, but as I have mentioned, there are also suspending ones.

There are two, to be exact:

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

There is also a third function that you are already familiar with, which is very similar:

public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T

Although the implementation of withContext() is similar to that of coroutineScope()(but a bit more complicated), its intended purpose is to switch context within a suspending function, not to provide a scope. It might seem confusing, just as with CoroutineScope being the same thing as CoroutineContext, but once again - the intended purpose is the important part.

That said, we will not discuss the withContext() function and focus on the two functions that explicitly provide a scope, even though technically you can do the same things with withContext(), since all these functions take block: suspend CoroutineScope.() -> T as an argument and allow launching new coroutines inside this block.

With that out of the way, let's take a look at a practical example.

Why do we need suspending scope builders

As you now know, the coroutine launcher functions launch and async are extension functions on the CoroutineScope. We cannot launch new coroutines outside of a coroutine scope. That also means we cannot launch new coroutines from suspending functions since they are regular functions that undergo a "minor" transformation during compilation.

However, there are certainly use cases where we would like to do that.

Here is an example:

val dataUrls = listOf("url1", "url2", "url3")

fun main() {
    runBlocking {
        val time = measureTimeMillis {
            downloadAllData(dataUrls)
                .forEach(::println)
        }
        println("Done in: $time ms")
    }
}

suspend fun downloadAllData(urls: List<String>): List<Data> {
    return urls.map { url -> downloadData(url) }
}

suspend fun downloadData(url: String): Data {
    return withContext(Dispatchers.IO) {
        delay(100)
        Data("I am data from: $url")
    }
}

data class Data(val content: String)

Output:
Data(content=I am data from: url1)
Data(content=I am data from: url2)
Data(content=I am data from: url3)
Done in: 329 ms

Predictably, we got our data downloaded sequentially in about 300ms. Of course, we could add async to each call of downloadData in our top-level coroutine, but that would be detrimental to the quality and reusability of our code. We can do better than that.

With the help of suspending scope builders, we can load all our data concurrently inside the downloadAllData function:

suspend fun downloadAllData(urls: List<String>): List<Data> {
    return coroutineScope {
        urls.map { url -> async { downloadData(url) } }
            .map { deferred -> deferred.await() }
    }
}

Output:
Data(content=I am data from: url1)
Data(content=I am data from: url2)
Data(content=I am data from: url3)
Done in: 129 ms

An important thing to note about suspending scope builders is that they return only when all their child coroutines have finished. This enforces structured concurrency and adheres to one of the main ideologies of the coroutine library - that suspending functions execute sequentially, just like the regular code. So in the example above, even though we launch multiple new coroutines inside the downloadAllData function, it will only return when all the work is done, without leaking coroutines. We will talk more about it in the best practices section.

The supervisorScope() is the same as coroutineScope(), but uses a SupervisorJob for its context instead of a Job. We will discuss everything there is to know about using Job and SupervisorJob in Part 4 of this series dedicated to error handling.

A few words about GlobalScope

This article would not be complete if we didn't mention the infamous GlobalScope. It had confused so many developers that in Kotlin 1.5 JetBrains marked it with @DelicateCoroutinesApi, which requires an opt-in to use it.

There are a couple of things you need to know about the GlobalScope, which should be easier to understand now that you know how scopes work and why we need them.

First of all, the GlobalScope doesn't adhere to the structured concurrency principle, which, as you now know, is a big deal in coroutines. It doesn't have any Job tied to it, and it is running during the whole lifetime of an application.

It might have its uses, but to use the GlobalScope correctly, you have to manage every coroutine launched inside it manually, keeping track of their jobs and not forgetting to .join() those jobs to other coroutines if your use case requires it.

You have to know what you are doing when using the GlobalScope, and even then, you might still introduce some subtle bugs if your attention slips.

So the recommendation is to avoid it altogether.

You can read more about this topic in this post by Roman Elizarov.

Conventions and Best Practices

We have already discussed quite a few examples of using the CoroutineScope correctly. However, there are still some things that you should keep in mind.

First of all, as we have mentioned earlier - do not ever launch new unbounded coroutines from a suspending function. Nothing should be left running in the background when a suspending function returns.

You should never write code like this:

suspend fun launchWorker() {
    ... // any suspending code
    CoroutineScope(Dispatchers.IO).launch { 
        doWork()
    }
}

Even if you keep a job handle to this new coroutine - this implementation is very smelly. It will make your code unpredictable, and you will break the structured concurrency principle.

If you need to write a function that launches a new coroutine, the convention is to use a regular extension function on the CoroutineScope - just like the launch and async functions.

The correct way to implement the example above would be:

fun CoroutineScope.launchWorker() {
    launch {
        // switch the dispatcher inside doWork() if needed
        // as we have discussed in Part 1
        doWork()
    }
}

Now you can launch your custom coroutines safely inside the context of a scope.

This convention also follows the pattern that launching a new coroutine returns immediately while suspending functions return only after all the work is completed.

Another best practice for creating your own CoroutineScope is to keep it bound to the lifecycle of some component. For example, in Android, we have viewModelScope attached to the ViewModel, and lifecycleScope attached to the LifecycleOwner. As soon as these components are destroyed, their scopes are immediately cancelled, preventing leaking coroutines.

This is a good practice regardless of the platform. You might have an application-level CoroutineScope, which might not need explicit cancelling, but for any other use case, you should limit the lifecycle of your scopes:

class MyComponent() {
    val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    ...
    fun onDestroy() {
        ...
        scope.cancel()
    }
}

Lastly, it is more clean and readable to use extension properties and functions like isActive, cancel(), and ensureActive() on your CoroutineScope not on the CoroutineContext even though they are the same thing. "Cancelling a scope" is immediately apparent, while "cancelling a context" occasionally might raise some questions just because of the naming.

Conclusion

You might have noticed a trend that despite having a very flexible API, the coroutines library has a set of simple, straightforward recommendations and practices, which make using coroutines much less intimidating, especially when starting out.

Hopefully, after reading this article, using the CoroutineScope will be smooth sailing.

In Part 3, we will dive deeper into the practical side of structured concurrency. This more advanced subject will nicely round up our discussion about CoroutineContext and CoroutineScope before moving on to meatier topics like exception handling.

See you next time.

Your friend,

Max

Did you find this article valuable?

Support Max Kim by becoming a sponsor. Any amount is appreciated!

Learn more about Hashnode Sponsors
 
Share this