question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Change default for global vs child coroutines by scoping coroutine builder (structured concurrency)

See original GitHub issue

Background and definitions

Currently coroutine builders like launch { ... } and async { ... } start a global coroutine by default. By global we mean that this coroutine’s lifetime is completely standalone just like a lifetime of a daemon thread and outlives the lifetime of the job that had started it. It is terminated only explicitly or on shutdown of the VM, so the invoker had to make extra steps (like invoking join/await/cancel) to ensure that it does live indefinitely.

In order to start a child coroutine a more explicit and lengthly invocation is needed. Something like async(coroutineContext) { ... } and async(coroutineContext) { ... } or async(parent=job) { ... }, etc. Child coroutine is different from a global coroutine in how its lifetime is scoped. The lifetime of child coroutine is strictly subordinate to the lifetime of its parent job (coroutine). A parent job cannot complete until all its children are complete, thus preventing accidental leaks of running children coroutines outside of parent’s scope.

Problem

This seems to be a wrong default. Global coroutines are error-prone. They are easy to leak and they do not represent a typical use-case where some kind of parallel decomposition of work is needed. It is easy to miss the requirement of adding an explicit coroutineContext or parent=job parameter to start a child coroutine, introducing subtle and hard to debug problems in the code.

Consider the following code that performs parallel loading of two images and returns a combined result (a typical example of parallel decomposition):

suspend fun loadAndCombineImage(name1: String, name2: String): Image {
    val image1 = async { loadImage(name1) }
    val image2 = async { loadImage(name2) }
    return combineImages(image1.await(), image2.await())
}

This code has a subtle bug in that if loading of the first image fails, then the loading of the second one still proceeds and is not cancelled. Moreover, any error that would occur in the loading of the second image in this case would be lost. Note, that changing async to async(coroutineContext) does not fully solve the problem as it binds async loading of images to the scope of the larger (enclosing) coroutine which is wrong in this case. In this case we want these async operations to be children of loadAndCombineImage operation.

For some additional background reading explaining the problem please see Notes on structured concurrency, or: Go statement considered harmful

Solution

The proposed solution is to deprecate top-level async, launch, and other coroutine builders and redefine them as extension functions on CoroutineScope interface instead. A dedicated top-level GlobalScope instance of CoroutineScope is going to be defined.

Starting a global coroutine would become more explicit and lengthly, like GlobalScope.async { ... } and GlobalScope.launch { ... }, giving an explicit indication to the reader of the code that a global resource was just created and extra care needs to be taken about its potentially unlimited lifetime.

Starting a child coroutine would become less explicit and less verbose. Just using async { ... } or launch { ... } when CoroutineScope is in scope (pun intended) would do it. In particular, it means that the following slide-ware code would not need to use join anymore:

fun main(args: Array<String>) = runBlocking { // this: CoroutineScope 
    val jobs = List(100_000) { 
        launch {
            delay(1000)
            print(".")
        }
    }
    // no need to join here, as all launched coroutines are children of runBlocking automatically
}

For the case of parallel decomposition like loadAndCombineImage we would define a separate builder function to capture and encapsulate the current coroutine scope, so that the following code will work properly in all kind of error condition and will properly cancel the loading of the second image when loading of the first one fails:

suspend fun loadAndCombineImage(name1: String, name2: String): Image = coroutineScope { // this: CoroutineScope 
    val image1 = async { loadImage(name1) }
    val image2 = async { loadImage(name2) }
    combineImages(image1.await(), image2.await())
}

Additional goodies

Another idea behind this design is that it should be easy to turn any entity with life-cycle into an entity that you could start coroutines from. Consider, for example, some kind of an application-specific activity that is launch some coroutines but all of those coroutines should be cancelled when the activity is destroyed (for example). Now it looks like this:

class MyActivity {
    val job = Job() // create a job as a parent for coroutines
    val backgroundContext = ... // somehow inject some context to launch coroutines
    val ctx = backgroundContext + job // actual context to use with coroutines

    fun doSomethingInBackground() = launch(ctx) { ... }
    fun onDestroy() { job.cancel() }    
}

The proposal is to simply this pattern, by allowing an easy implementation of CoroutineScope interface by any business entities like the above activity:

class MyActivity : CoroutineScope {
    val job = Job() // create a job as a parent for coroutines
    val backgroundContext = ... // somehow inject some context to launch coroutines
    override val scopeContext = backgroundContext + job // actual context to use with coroutines

    fun doSomethingInBackground() = launch { ... } // !!! 
    fun onDestroy() { job.cancel() }    
}

Now we don’t need to remember to specify the proper context when using launch anywhere in the body of MyActivity class and all launched coroutines will get cancelled when lifecycle of MyActivity terminates.

Issue Analytics

  • State:closed
  • Created 5 years ago
  • Reactions:49
  • Comments:63 (39 by maintainers)

github_iconTop GitHub Comments

10reactions
elizarovcommented, Aug 6, 2018

We plan to have this done before kotlinx.coroutines reaches 1.0.

4reactions
qwwdfsadcommented, Sep 10, 2018

any code living in a suspend function without an explicit scope will still need to pass the coroutineContext to get structured concurrency behavior.

Standalone builders are already deprecated and will be removed in the follow-up release after initial feedback.

Or, for brevity, people will get into the habit of just always using the global scope builders, and then we’ve got the same problem we started with.

There are builders such as currentScope and coroutineScope and explicit mentioning in the documentation that GlobalScope should not be used generally. We can’t prevent API abuse, but at least we can make it less error-prone for the users. Next step will be to provide more extension points such as Android activities which are CoroutoneScope by default.

I can see one argument where using CoroutineScope as the receiver makes some sense - since the scope defines the lifetime of a coroutine, you don’t want to be able to make a bunch of coroutines from suspend function that are children of whatever coroutine the function happens to be called from, without being conscious of/explicit about which scope should actually be the parent

This is one of the benefits. Moreover, the recommended (“best practices” which eventually will be followed unconsiously 😃) way to provide a scope is coroutineScope, which doesn’t allow to ignore exceptions, having global forgotten jobs etc. Another one is that coroutine scope is provided, users don’t have to think “oh, and here I have to pass my activity’s Job”, everything starts to work automatically.

what is the reasoning behind deciding to use CoroutineScope as the receiver for coroutine builders instead of just using the current coroutineContext?

Separation of concerns, they have different semantics. CoroutineContext is a bag of properties, attached to a coroutine and stored in continuation (so coroutineContext intrinsic works). Every implementor has to implement three methods (fold, minusKey, get) which are irrelevant to the scope abstraction. Additional downside is a signatures like CoroutineContext.async(context: CoroutineContext), which are completely misleading.

CoroutineContext is a general-purpose stdlib abstraction, while CoroutineScope has a well-defined meaning specific to kotlinx.coroutines, additional documentation and meaningful name. For example, you are observing that something is a CoroutineContext. Does it have a job in it? Does it make sense to call launch on it, will anyone handle an exception? Who is a lifecycle owner? CoroutineScope makes it at least more explicit if not obvious.

Read more comments on GitHub >

github_iconTop Results From Across the Web

Coroutines basics - Kotlin
Coroutines follow a principle of structured concurrency which means that new coroutines can be only launched in a specific CoroutineScope which ...
Read more >
Everything you need to know about kotlin coroutines - Medium
The two most important building blocks to create/start/run new coroutines are coroutine scope and coroutine builders.
Read more >
Job and children awaiting in Kotlin Coroutines - Kt. Academy
The other three important consequences of structured concurrency depend fully on the Job context. Furthermore, Job can be used to cancel coroutines, ...
Read more >
Kotlin Coroutines - Baihu Qian 钱柏湖
It consists of suspending function and coroutine builder. ... Coroutine scope is responsible for the structure and parent-child relationships between ...
Read more >
Coroutines (Part III) – Structured Concurrency and Cancellation
All children coroutines delegate handling of their exceptions to their parent coroutine, which also delegates to the parent, and so on until ...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found