Launching fire-and-forget coroutines from non-suspend functions using CoroutineScope

532 Views Asked by At

I have a Kotlin application where we are unable to use runBlocking() from an app entry point (like main). I've found that I can do the following to do a fire-and-forget asynchronous operation, but I'm unclear on whether this is a good practice -- I see this in the docs:

Do not replace GlobalScope.launch { ... } with CoroutineScope().launch { ... } constructor function call. The latter has the same pitfalls as GlobalScope.

But I've also seen places where people seem to be recommending this approach. Here's an example of the approach:

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

val scope = CoroutineScope(Dispatchers.IO)

fun doStuff(): String {
    val response = "all done"
    println("before fireAndForget")

    fireAndForget()

    println("after fireAndForget")
    return response
}

fun fireAndForget() {
    scope.launch {
        makeSomeExpensiveCall()
    }
}

fun makeSomeExpensiveCall() {
    // In reality, make a blocking database call, etc. without coroutines, reactive, etc.
    Thread.sleep(100)
    println("after sleep")
}

fun main() {
    val response = doStuff()
    Thread.sleep(200)
    println(response)
}

The above prints the following, which is what I expect. I just don't know if it's safe to do this.

before fireAndForget
after fireAndForget
after sleep
all done

EDIT:

The below is a bit more realistic version of what I'm trying to do. I'm still unsure of whether the scope is being set up and used correctly.

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch

class Controller {
    val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

    fun saveDataToDatabase(): String {
        saveRecord()

        println("before saveAuditDataWithoutBlocking")
        saveAuditDataWithoutBlocking()
        println("after saveAuditDataWithoutBlocking")

        return "Save succeeded"
    }

    private fun saveRecord() {
        Thread.sleep(10)
    }

    private fun saveAuditDataWithoutBlocking() {
        // Write more data to a database, but do not block caller
        val job = scope.launch {
            saveAuditInfoBlocking()
        }

        job.invokeOnCompletion { throwable ->
            throwable?.let {
                println("Exception in fireAndForget: $throwable")
            }
        }

        // Is there something else I need to do?
    }

    private fun saveAuditInfoBlocking() {
        Thread.sleep(100)
        println("Finished slow write of audit data")
    }
}

fun main() {
    val controller = Controller()
    println("Calling saveDataToDatabase()")
    val response = controller.saveDataToDatabase()
    println("Received response in main(): $response")
    Thread.sleep(200)
    println("main() finished")
}

Output:

Calling saveDataToDatabase()
before saveAuditDataWithoutBlocking
after saveAuditDataWithoutBlocking
Received response in main(): Save succeeded
Finished slow write of audit data
main() finished
1

There are 1 best solutions below

9
Tenfour04 On

GlobalScope's documentation says the purpose for which it is OK to use:

Global scope is used to launch top-level coroutines which are operating on the whole application lifetime and are not cancelled prematurely.

The reason you usually want to avoid it is that in most applications, almost everything has some scope to it that is limited. For instance, if you are doing some action on some screen of your app, you want to cancel that action when you move to a different screen so you aren't leaking resources.

But if you are doing something that should last for the lifetime for the app, such that you can't possibly be hanging onto some resource in your coroutine for longer than you want to, then GlobalScope is appropriate.

You have to be really careful though. Consider this psuedo-code:

GlobalScope.launch {
    val url = viewLayout.someTextField.textEnteredByUser
    someApi.downloadFileToAppResourceDirectory(url) // a cancellable suspend function
}

This is a task that you never want to be cancelled because you want to keep downloading the file even if the user navigates away from this screen, so you think, this is a job for GlobalScope! But the problem is that you have referenced a screen element viewLayout in your coroutine, so a reference to the entire layout of this screen is captured by the coroutine that will outlive your screen. This is a memory leak.

Since it is so easy to create memory leaks with GlobalScope, they annotate it with @DelicateCoroutinesApi to discourage its use. So then, some naïve users create a one-off CoroutineScope to run a single coroutine (i.e. CoroutineScope().launch { ... }). This avoids the warning from @DelicateCoroutinesApi without changing the behavior it's warning you about.

Incidentally, the way to fix the code above would be to move the first line out of the coroutine so the url alone is captured by the coroutine.


The code you quoted is a little weird. They have created a CoroutineScope that:

  1. Will die the first time any of its children coroutines have a failure, and thereafter never be usable again. Not something I would combine with a "fire-and-forget" example. It's more like "maybe fire and forget". SupervisorScope() would be more appropriate.
  2. There's not much point in creating a CoroutineScope if you aren't going to manage its life, that is, cancel() it when its associated scope is destroyed. GlobalScope can be used instead for less convoluted code unless you truly want the behavior mentioned in the point above.

Since it shows some made-up pointless task that doesn't hang onto resources, it doesn't really matter that it's never cancelled, but it could just as well have been a GlobalScope.

The documentation you quoted is talking about creating a CoroutineScope with the constructor call CoroutineScope(), and immediately calling launch on it without first storing it in a property so you can cancel it at the appropriate time. This code example is not exactly what they're talking about, although it's similar since they create a CoroutineScope and then never call cancel() on it.