Skip to content

Introduce lazyAsync #4423

Open
Open
@CLOVIS-AI

Description

@CLOVIS-AI

Use case

I often happen to want to declare a suspending computation meant to be executed later, but do not have access to a scope at that moment.

If I had access to a scope, I could use async(start = LAZY) (or a remplacement as described in #4202). However, in these situations, I do not have access to a scope.

As a first example, see this thread in KotlinLang. The user is attempting to initialize a data structure, which requires an async operation. However, that async operation doesn't need to happen right now, it could happen on first use. A simplified version of the problem is:

val health = Health(
    runBlocking {  // ⚠ probably shouldn't use runBlocking here
        redis.setUpPing()
    }
)

This example could be rewritten:

val heatlh = Health(
    lazyAsync {
        redis.setUpPing()
    }
)

Another example can be found in the declaration of database indexes or other such metadata. It would be great to be able to write:

class MyRepository(
    private val database: Database
) {
    init {
        database.ensureIndex("a") { … }  // ⚠ can't suspend here
        database.ensureIndex("b") { … }
    }

    suspend fun findOneById(id: String) { … }
}

With this proposal, this example could be rewritten as:

class MyRepository(
    private val database: Database
) {
    val indexes = lazyAsync {  // will execute in the scope of the first coroutine to await it
        database.ensureIndex("a") { … }
        database.ensureIndex("b") { … }
    }

    suspend fun findOneById(id: String) {
        indexes.await()
        //
    }
}

Over the years I've seen many examples that could be boiled down to either of the two scenarii described above.

The Shape of the API

Simple implementation:

// Identical as CoroutineScope.async, but:
//  • doesn't have a receiver
//  • doesn't have a coroutineStart parameter, since it is always lazy
fun <T> lazyAsync(
    coroutineContext: CoroutineContext = EmptyCoroutineContext,
    block: suspend CoroutineScope.() -> Unit,
): Deferred<T> = LazyDeferredImpl(coroutineContext, block)

private class LazyDeferredImpl(
    private val additionalContext: CoroutineContext,
    private val initializer: suspend CoroutineScope.() -> T,
) : Deferred<T> {
    private var value: Any? = null
    private var initialized: Boolean = false
    private val lock = Mutex()
    
    override suspend fun await(): T {
        // Short path
        if (initialized) return value

        lock.withLock {
            if (initialized) return value
            value = withContext(additionalContext) { initializer() }
            initialized = true
        }

        return value
    }

    //
}

I'm sure there are many ways to optimize the implementation, this one is merely an example. Looking at the existing codebase, probably null-ing the initializer after it has run, using some kind of atomic reference for the value with a val UNINITIALIZED = Any() special value instead of having a Mutex, probably?

I don't have a particular attachment to the name lazyAsync. Maybe another name can be more useful, I don't know.

Prior Art

The Shared concept from the Prepared library (I'm the author) is essentially the same thing. A Shared value is a test fixture that is declared once globally, and can be used within multiple tests; its initializer is suspend and only runs the first time the shared value is awaited, after which its result is cached for all further usages.

This class allows declaring suspending test fixtures (e.g. shared { MongoClient.connect() }) and reusing them between many tests (even though they have different CoroutineContext which we cannot access at declaration-time) with the guarantee that it will only be initialized once.

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