galvas
02/06/2024, 3:33 PMCoroutineScope
?
val childScope1 = CoroutineScope(this.coroutineContext)
val chileScope2 = coroutineScope {}
Dmitry Khalanskiy [JB]
02/06/2024, 3:34 PMcoroutineScope { this }
?galvas
02/06/2024, 3:41 PMsuspend
blockSam
02/06/2024, 3:42 PMcoroutineScope {}
is a suspending function, and it will wait for all its child coroutines to complete before returning.
• CoroutineScope(this.coroutineContext)
won't wait for anything, so its new coroutines will run in the background. This can be confusing if you're inside a suspending function, because callers expect the suspending function to finish all its work before returning. So you should prefer to use coroutineScope {}
.galvas
02/06/2024, 3:46 PMval childScope = CoroutineScope(parentScope.coroutineContext)
but then I have a public suspend fun doSomething()
which might be called in a coroutine running in the parent scope, but I could do childScope.launch()
and return immediately. So that way parent can continue doing work. Does that seem to make sense?galvas
02/06/2024, 3:47 PMScope
and Context
Sam
02/06/2024, 3:52 PMCoroutineScope
using an existing coroutineContext
from another suspending function or scope is almost never going to be a good idea, and will probably have unintuitive or unexpected results. The CoroutineScope
constructor function is intended for occasional use by classes that manage their own lifecycle and their own coroutine context elements. And as I said, by convention, a suspending function should complete all of its work before returning. If you want a function that does some background work, it should either use its own scope (perhaps owned by its class) or accept an existing coroutine scope as an input (e.g. a receiver).Sam
02/06/2024, 3:52 PMgalvas
02/06/2024, 3:53 PMSam
02/06/2024, 3:53 PMgalvas
02/06/2024, 3:54 PMgalvas
02/06/2024, 3:55 PMgalvas
02/06/2024, 3:56 PMgalvas
02/06/2024, 3:57 PMgalvas
02/06/2024, 3:57 PMSam
02/06/2024, 3:57 PMgalvas
02/06/2024, 3:58 PMgalvas
02/06/2024, 4:01 PMgalvas
02/06/2024, 4:02 PMSam
02/06/2024, 4:11 PMactor
function in the coroutines library, but it's sort of deprecated without replacement, which is a bummer 😞. That said, its moving parts aren't very complicated. You could achieve something pretty similar (if not better) just by making your own channel and coroutine. I'd do it like this:
class MyStateMachine {
private val queue = Channel<Stuff>()
fun postEvent(event: Stuff) {
queue.trySend(event)
}
fun endOfInput() {
queue.close()
}
suspend fun execute() {
queue.consumeEach { message ->
doStuff(message)
}
}
}
val myStateMachine = MyStateMachine().also {
viewModelScope.launch { it.execute() }
}
Sam
02/06/2024, 4:12 PMendOfInput()
closes the channel, which ends the loop and allows the coroutine to terminate—so there's no need for any extra lifecycle management beyond that.streetsofboston
02/06/2024, 4:12 PMcoroutineScope { ... }
inside a suspend fun. That one is basically to allow you to do asynchronous/parallel stuff inside the suspend fun and within the same CoroutineScope as the one that called the suspend fun.galvas
02/06/2024, 5:09 PMgalvas
02/06/2024, 5:11 PMSam
02/06/2024, 5:12 PMgalvas
02/06/2024, 11:11 PMgalvas
02/06/2024, 11:12 PMCoroutineScope
like I wanted to =/
2. It should be safe to create my own CoroutineScope
with something like `CoroutineScope(Dispatchers.Default + Job())`as long as it's independent and only relies on the lifecycle of the class it belongs to.
3. Interactions between scopes have some interesting caveatsgalvas
02/06/2024, 11:43 PMCoroutineScope
using an existing coroutineContext
from another suspending function or scope is almost never going to be a good idea
Can you elaborate a bit? I understand this breaks the expectations from the caller in a simple suspend function, but if I let's say inject the parent scope and use that to make my new one, does that seem like a bad idea?
I'm thinking of something like if I have
val parentJob = Job()
val parentScope = CoroutineScope(Dispatchers.Default + parentJob)
If I do val childScope = _CoroutineScope_(parentScope.coroutineContext)
then they will essentially be the same scope? because the whole context is the same.
But if I do something like this, it feels like it would be closer to what I wanted
val childJob = SupervisorJob.(parentScope.coroutineContext.job)
val childScope = CoroutineScope(parentScope.coroutineContext + childJob)
galvas
02/07/2024, 7:47 AMgalvas
02/07/2024, 7:49 AMval childJob = SupervisorJob(parentScope.coroutineContext.job)
val childScope = CoroutineScope(Dispatchers.Default + childJob)
Sam
02/07/2024, 8:37 AMCoroutineScope
using an existing coroutineContext
from another suspending function or scope is almost never going to be a good idea", I was mostly thinking of this kind of thing:
suspend fun foo() {
val scope = CoroutineScope(coroutineContext) // don't do this
scope.launch { bar() } // this leaks from the function
}
It sounds like what you're describing is more like creating a custom scope directly as a child of another custom scope—that is, without any suspending functions involved. That might be less problematic in terms of encapsulation, but I think it could still be quite confusing for people reading the code. Structured concurrency is useful because it's predictable and consistent, and if you start trying to make up your own rules for how it should work, you lose some of that.Sam
02/07/2024, 8:38 AMSam
02/07/2024, 8:39 AMgalvas
02/07/2024, 8:59 AMChannel
would be receiving external events and spawning a new Coroutine to do the work. Let's considers something like this:
public suspend fun start() {
eventChanel.receiveAsFlow().collect {event ->
This doesn't work because I need to spawn the collection in a new Coroutine, otherwise start() will suspend forever. And I can't just call launch
from a suspend function and assume the caller's scope. Which is good because the convention is that spawning new coroutines needs to be done from a scope. So instead maybe I should do.
val myScope = CoroutineScope(Dispatchers.Default + Job())
public fun start() {
myScope.launch {
eventChanel.receiveAsFlow().collect {event ->
But at this point I fully rely on calling stop()
to cancel myScope()
, which is fine, I think this is the intended design. But this class lives in a ViewModel
, so if that gets cleared, I need to make sure to trigger stop()
manually in a class that is like 4 levels of abstraction deeper.galvas
02/07/2024, 9:01 AMgalvas
02/07/2024, 9:04 AMpublic fun start() {
myScope.launch {
eventChanel.receiveAsFlow().collect {event ->
when(event) {
SomeEvent -> {
// do something
myScope.launch{
// start work in background to make sure I can collect new events
}
}
StopEvent -> {
myScope.cancel()
}
}
}
}
Sam
02/07/2024, 9:09 AMstart()
a suspend function would definitely be my preferred solution. It means you don't need a stop()
function—you just cancel the coroutine that's running the start()
function instead. You're right that this would mean you need to start it in its own coroutine. So, are you able to do that directly in the viewModelScope
, after creating the instance? Something like this:
val myActor = MyActor()
val job = viewModelScope.launch { myActor.start() }
galvas
02/07/2024, 9:12 AMgalvas
02/07/2024, 9:14 AMviewModelScope
to other classes to initialise them.
override fun initialize(scope: CoroutineScope) {
scope.listenToDeviceCallbackEvents()
private fun CoroutineScope.listenToDeviceCallbackEvents() {
Sam
02/07/2024, 9:16 AMFlow.launchIn(scope)
or Channel.produceIn(scope)
. More broadly, we can divide asynchronous functions into two groups:
1. Suspending functions, which do all their work before returning. These have the suspend
modifier and should never leak coroutines.
2. Background tasks, which take a coroutine scope as a parameter (or receiver) and return right away, while starting a new concurrent task. These never have the suspend
modifier.Sam
02/07/2024, 9:17 AMSam
02/07/2024, 9:21 AMJob
, but I would say those are both optional extras)galvas
02/07/2024, 9:23 AMpublic fun startActorAsync(scope: CoroutineScope): Job {
val runningJobs = mutableListOf<Job>()
return scope.launch {
eventChanel.receiveAsFlow().collect { event ->
when (event) {
SomeEvent -> {
// do something
val job = scope.launch {
// start work in background to make sure I can collect new events
}
runningJobs.add(job)
}
StopEvent -> {
runningJobs.forEach{it.cancel()}
}
}
}
}
}
galvas
02/07/2024, 9:33 AMprivate lateinit var myScope: CoroutineScope
public fun startActorAsync(scope: CoroutineScope) {
myScope = CoroutineScope(Dispatchers.Default + Job(scope.coroutineContext.job))
myScope.launch {
eventChanel.receiveAsFlow().collect { event ->
when (event) {
SomeEvent -> {
// do something
myScope.launch {
// start work in background to make sure I can collect new events
}
}
StopEvent -> {
myScope.cancel()
}
}
}
}
}
public fun stopActor() {
if (this::myScope.isInitialized) {
myScope.cancel()
}
}
galvas
02/07/2024, 9:34 AMSam
02/07/2024, 9:36 AMscope.launch()
again—just use launch()
. This will make the new jobs be children of the outer job. Then, when you get the StopEvent
, you can just cancel()
the whole job, and it will automatically cancel its children.
fun startActorAsync(scope: CoroutineScope): Job = scope.launch {
eventChannel.receiveAsFlow().collect { event ->
when (event) {
SomeEvent -> launch { /* start work in background */ }
StopEvent -> cancel()
}
}
}
That's assuming that you need a dedicated StopEvent
. If you can signal the end just by closing the channel instead, things could be even simpler.Sam
02/07/2024, 9:41 AMgalvas
02/07/2024, 9:44 AM