Note: phiên bản Tiếng Việt của bài này ở link dưới.
https://duongnt.com/fire-and-forget-vie
Sometimes, we want to start an action and then return right away, without waiting for it to finish, or caring about its result. Such actions are called fire and forget.
In Kotlin, the common way to perform a fire and forget action is by using coroutine. Today, we will look at a few approaches, and see why they do or don’t work.
The TL;DR
Here is an approach that works, but is usually discouraged because it uses GlobalScope.
GlobalScope.launch(Dispatchers.IO) {
// our fire and forget action here
}
Then here is how people usually do it, although in practice it is functionally the same as the first approach.
CoroutineScope(Dispatchers.IO).launch {
// our fire and forget action here
}
And here is an incorrect solution, even though it looks almost the same as the second one.
coroutineScope {
launch(Dispatchers.IO) {
// our fire and forget action here
}
}
Differences between approaches
In all three approaches, the general ideal is the same. We use the launch
coroutine builder to start a coroutine, and kick off the fire and forget action inside that coroutine. Then return right away from the main function without waiting for that coroutine to finish. Let’s see how that plays out in each approach.
Using GlobalScope
Here is the result of our demo code. Notice that we exit from superAwesomeFeature
before finishing exporting metrics.
Start the application
Start doing some work
Finish doing some work
Start exporting metrics
Finish exporting metrics
Stop the application
GlobalScope is a singleton object that implements the CoroutineScope
interface, while setting its context to an empty context.
Now, using GlobalScope is usually discouraged, and for good reasons.
- GlobalScope is not tied to any lifecycle, making it easy to create coroutines that outlive the code that started them, potentially leading to memory leaks.
- Because
GlobalScope.launch
is detached from structured concurrency, handling errors in these coroutines is much more tricky than normal. - Without a proper scope, managing resources is more difficult.
But if we really think about it, the short-comings above are things we either don’t care about or have to accept when launching a fire and forget action. We want our coroutines to keep running even if the code that started them has returned. And we don’t care if they throw an exception, because they are just nice to have.
We do need to have some mechanism to time out a long running coroutine. But overall, I believe fire and forget is one of those rare cases where using GlobalScope is justified.
Using CoroutineScope function
Here is the result of our demo code. Notice that we exit from superAwesomeFeature
before finishing exporting metrics.
Start the application
Start doing some work
Finish doing some work
Start exporting metrics
Finish exporting metrics
Stop the application
The function CoroutineScope is different from the CoroutineScope
interface. It creates an object of type ContextScope
, which is an internal class that implements the CoroutineScope
interface. The launch
coroutine builder then starts a coroutine in this scope, and returns a Job
that we can use to manage and control that coroutine. But we totally ignore that Job
.
We have used a proper CoroutineScope to start our fire and forget action and avoid using the “evil” GlobalScope. Or have we? Although this is how people usually implement fire and forget, functionally it is not much different from using GlobalScope.
- Because we discard the
Job
object returned by the functionlaunch
, it’s not like we can really control the lifecycle of our coroutines. - As we return right after kicking off the coroutines, we still have to pay extra attention to error handling, and timing out long running coroutines.
In practice, I still prefer this approach. If nothing else, it saves us an @OptIn(DelicateCoroutinesApi::class)
command. But make no mistake, this approach is the same as using GlobalScope. It’s just that in this case GlobalScope is the right tool for the job.
Why does coroutineScope not fire and forget?
Here is the result of our demo code. Notice that we have to finish exporting metrics before exiting from superAwesomeFeature
.
Start the application
Start doing some work
Start exporting metrics
Finish exporting metrics
Finish doing some work
Stop the application
This is one thing I don’t like about how coroutine is implemented in Kotlin. The same name, or very similar names, are used for pretty different things. We have CoroutineScope
the interface, CoroutineScope
the function, and now coroutineScope
the suspending function.
The function coroutineScope accepts a suspend block, creates a new scope, and calls the provided block as coroutine(s) with that scope. Then it either returns the result right away if it is already ready. Or more likely, it waits asynchronously (as-is, it yields control of its current thread while waiting) for the block to finish, and finally returns the result from the block as its own result.
If we launch coroutines inside a coroutineScope
block, we have to wait for all coroutines inside that block to finish before we can reach the next line of code. During the wait time, the coroutine thread(s) are not blocked and can perform other tasks, but this does not achieve our fire and forget goal.
Conclusion
In Kotlin, GlobalScope.launch
is often discouraged due to its lack of lifecycle management. But for fire and forget tasks, it can be useful, as tasks need to continue beyond the caller’s lifecycle. Alternatively, CoroutineScope.launch
is more frequently seen, but it is functionally the same if the returned Job
is discarded.