Change default for global vs child coroutines by scoping coroutine builder (structured concurrency)
See original GitHub issueBackground 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:
- Created 5 years ago
- Reactions:49
- Comments:63 (39 by maintainers)
Top GitHub Comments
We plan to have this done before
kotlinx.coroutines
reaches 1.0.Standalone builders are already deprecated and will be removed in the follow-up release after initial feedback.
There are builders such as
currentScope
andcoroutineScope
and explicit mentioning in the documentation thatGlobalScope
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 areCoroutoneScope
by default.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.Separation of concerns, they have different semantics.
CoroutineContext
is a bag of properties, attached to a coroutine and stored in continuation (socoroutineContext
intrinsic works). Every implementor has to implement three methods (fold
,minusKey
,get
) which are irrelevant to the scope abstraction. Additional downside is a signatures likeCoroutineContext.async(context: CoroutineContext)
, which are completely misleading.CoroutineContext
is a general-purpose stdlib abstraction, whileCoroutineScope
has a well-defined meaning specific tokotlinx.coroutines
, additional documentation and meaningful name. For example, you are observing that something is aCoroutineContext
. Does it have a job in it? Does it make sense to calllaunch
on it, will anyone handle an exception? Who is a lifecycle owner?CoroutineScope
makes it at least more explicit if not obvious.