Description
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.