Skip to content

Customizable coroutine behaviour on scope termination #1065

@fvasco

Description

@fvasco

Currently this library allows to define how a coroutine have to start (using start parameter) and its dependency fail behavior (using jobs).
According to structured concurrency it assumes that all sub-coroutines have to join on scope termination.

In my experience this decision does not fit all use case, for some async or produce it is preferable to cancel a coroutine instead of join.
So I wish to consider a parameter to customize the coroutine's behavior on scope termination.

This proposal does not conflict with structured concurrency.

In the example code

    val secret: String? = null
    val userDeferred = async(start = CoroutineStart.LAZY) { loadUser() }

    if (secret != null) {
        require(secret == userDeferred.await().secret)
    }

the strusctured concurrency impose the implicit join of all children.

    val secret: String? = null
    val userDeferred = async(start = CoroutineStart.LAZY) { loadUser() }
    try {
        if (secret != null) {
            require(secret == userDeferred.await().secret)
        }
    } finally {
        userDeferred.join()
    }

So the try-finally is implicit, but unfortunately the first example hangs indefinitely, moreover we MUST write the code

    val secret: String? = null
    val userDeferred = async(start = CoroutineStart.LAZY) { loadUser() }
    try {
        if (secret != null) {
            require(secret == userDeferred.await().secret)
        }
    } finally {
        userDeferred.cancel()
    }

Structured concurrency should avoid to handle join manually.

Proposal

My proposal is to consider a parameter to customize the behavior on scope termination, ie: default, join, cancel.

Ie:

    val secret: String? = null
    val userDeferred = async(start = CoroutineStart.LAZY, onScopeTermination = ScopeTermination.CANCEL) { loadUser() }
    try {
        if (secret != null) {
            require(secret == userDeferred.await().secret)
        }
    } finally {
        userDeferred.join()
    }

Alternatives

This issue was already solved for ReceiveChannel using the consume function, we can copy the same block for Deferred.

inline fun <E, R> Deferred<E>.consume(block: (Deferred<E>) -> R): R {
    var cause: Throwable? = null
    try {
        return block(this)
    } catch (e: Throwable) {
        cause = e
        throw e
    } finally {
        @Suppress("DEPRECATION")
        cancel(cause)
    }
}

So the first example becomes:

    val secret: String? = null
    async(start = CoroutineStart.LAZY) { loadUser() }.consume { userDeferred ->
        if (secret != null) {
            require(secret == userDeferred.await().secret)
        }
        userDeferred.cancel()
    }

We have to introduce a nested block, similar to use function that mimic the try-with-resource block.

Future improvements:

Introducing a new termination behavior allow us to define a similar API to avoid the nested block

fun CoroutineScope.cancelOnScopeTermination(job: Job) {
    launch(start = CoroutineStart.LAZY, onScopeTermination = ScopeTermination.JOIN) {
        job.cancel()
    }
}
    val secret: String? = null
    val userDeferred = async(start = CoroutineStart.LAZY) { loadUser() }
    cancelOnScopeTermination(userDeferred)

    if (secret != null) {
        require(secret == userDeferred.await().secret)
    }

This requires an extra allocation and miss of exception handling, but I consider scope finalizers a future enhancement.

What do you think about?

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions