Skip to content

Instantly share code, notes, and snippets.

@cprice404
Created August 18, 2021 13:01
Show Gist options
  • Save cprice404/22e6c86a7b27e7b7287933613a223b63 to your computer and use it in GitHub Desktop.
Save cprice404/22e6c86a7b27e7b7287933613a223b63 to your computer and use it in GitHub Desktop.
Options for coroutines scopes in a library class
package momento.cacheadmin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.io.Closeable
import java.util.concurrent.atomic.AtomicBoolean
// References:
//
// Read this one before you look at any of this code. It's not about kotlin or coroutines, so
// don't pay too much attention to all of the python/Trio-specific stuff, but it's the best
// explanation I've found about the motivation for structured concurrency, and it was definitely
// an inspiration that the kotlin maintainers leaned on when designing coroutines. It's probably
// a 20 minute read but it's very worthwhile.
//
// * https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/
//
// These are all more on the "optional" side; they are shorter blog posts from Roman Elizarov, the
// lead maintainer / designer for Kotlin language features. They explain a lot of his thinking
// about best practices for working with coroutines.
//
// * https://elizarov.medium.com/blocking-threads-suspending-coroutines-d33e11bf4761
// * https://elizarov.medium.com/coroutine-context-and-scope-c8b255d59055
// * https://elizarov.medium.com/the-reason-to-avoid-globalscope-835337445abc
// * https://elizarov.medium.com/explicit-concurrency-67a8e8fd9b25
// Here we show two subclasses that illustrate different ways we could handle
// coroutine scope in a library. I usually try to avoid inheritance in most cases
// these days, but for the purposes of this example it just DRYs things up a bit and
// makes the differences between our two implementation choices clear.
// This first one uses an encapsulated coroutine scope; by that I mean: we know that
// our base class needs a coroutine scope, so we have to decide whether to expose
// that to the caller or hide it from them. In this case we are hiding it from them,
// and creating a coroutine scope without their knowledge.
//
// Pro: slightly less cognitive burden for the caller.
// Con: we are sacrificing the premise of "structured concurrency"; our
// coroutines that we create in this scope could fail, and there is no way for
// the caller to control what happens in that case, or even to be guaranteed that
// they would know that something failed.
class MyAwesomeAsyncThingyWithEncapsulatedCoroutineScope : ThingyBase(
thingyName = "encapsulated",
thingyCoroutineScope = CoroutineScope(Dispatchers.Default)
)
// In this second approach we require the caller to pass in a coroutine scope, which we
// will use as our parent.
//
// Con: caller is exposed to the implementation detail that we are using coroutines, and
// has to decide what they want the scope to be.
//
// Pro: they now have full control over the life cycle of this coroutine scope; they can
// choose to organize their code such that they will know exactly when we have shut
// down, and be guaranteed that any unhandled errors that happen in our coroutines
// will bubble up to their scope so that they can decide what to do about them.
class MyAwesomeAsyncThingyWithExplicitCoroutineScope(explicitScope: CoroutineScope) : ThingyBase(
thingyName = "explicit",
thingyCoroutineScope = explicitScope,
)
// This base class contains all of the actual coroutine implementation details, given
// a coroutine scope that is passed in to the constructor
abstract class ThingyBase(
private val thingyName: String,
// this constructor param is the major difference between the two subclasses
private val thingyCoroutineScope: CoroutineScope
) : Closeable {
// lateinit vars are pretty gross, would normally avoid but in this
// case we need a way to keep a class-wide reference to the background
// task so that we can join on it when shutting down.
internal lateinit var thingyResult: Deferred<Int>
// this is one way we can communicate to the background tasks about
// when it is time to shut down.
val shutdown = AtomicBoolean(false)
fun start() {
println("IN THINGY($thingyName).START")
thingyResult = thingyCoroutineScope.launchThingyWorkerAsync()
}
override fun close() {
println("IN THINGY($thingyName).CLOSE")
// tell the coroutine it's time to shutdown
shutdown.set(true)
// wait for coroutine shutdown
// NOTE: since this `close` fn is not a `suspend` fn, we
// aren't in a coroutine scope and thus we can't call
// functions like `await` and `cancel` on our background
// task. So we need to do something to get ourselves
// into a coroutine scope. In this case we will use
// `runBlocking`, which will block this thread until
// the code inside the block completes - but that's the
// behavior we want here, we don't want `close` to return
// until the coroutine is shut down.
val theAnswer = runBlocking {
thingyResult.await()
// NOTE that we could also call:
// thingyResult.cancel()
// or, preferably
// thingyResult.cancelAndJoin()
// here, but then we wouldn't get the result back. Also
// note that coroutines will only cancel when they hit a
// suspend point, such as a call to `delay`.
}
println("THINGY($thingyName).CLOSE GOT RESULT: $theAnswer")
}
// The pattern w/coroutines is to put the work for the coroutines
// into suspend fns. The convention is that no suspend fn should
// ever contain blocking code, so callers can assume that calling
// them will not block a thread for a long period of time. This
// fn doesn't block because `delay` is itself a suspend fn that
// tells the scheduler it can park this coroutine if there is
// other work to do.
private suspend fun doThingyWork(): Int {
while (!shutdown.get()) {
println("THINGY($thingyName) COROUTINE WORKER IS DOING SOME STUFF")
// this call to `delay` makes our coroutine `cancel`able, if we
// prefered to use `cancel` instead of `await` on shutdown.
delay(100)
}
println("THINGY($thingyName) COROUTINE WORKER IS DONE, EXITING")
// return value. in this case we don't really need this, so we could have
// just used `launch` to spawn the coroutine instead of `async`.
// Just doing this to illustrate what it would look like if you did care about
// the return value.
return 42
}
// The other convention is that when you want to launch coroutines,
// either via `launch` or `async`, you use an extension function
// on CoroutineScope. These fns are usually short and return
// immediately after spawning the bg tasks. Sometimes they will
// return an array or map of background tasks as opposed to just
// a single one like we are doing here.
private fun CoroutineScope.launchThingyWorkerAsync(): Deferred<Int> {
// Often in a launcher like this you would see just `async { ... }`
// or `launch { ... }`. Here we are using the signature that allows
// us to pass in a specific dispatcher. This is important because
// the coroutine scope we are using was passed in to our constructor,
// so we don't know what it is. If it's `runBlocking`, then doing
// an `async { ... }` here without specifiying a dispatcher would result
// in us launching the coroutine into the same runBlocking dispatcher
// that we were called from. That is a single-threaded dispatcher that
// is already tied up waiting for `runBlocking` to complete, so our
// coroutine would never get scheduled. You can try it out by commenting
// out this line and replacing it with `return async {`, and then running
// the "explicit" subclass.
return async(Dispatchers.IO) {
doThingyWork()
}
}
}
fun runThingy(thingyProvider: () -> ThingyBase) {
// this `use` syntax works because of Closeable
thingyProvider().use { thingy ->
thingy.start()
val numLoops = 10
repeat(numLoops) { i ->
println("Main thread doing stuff (${i + 1}/$numLoops)")
Thread.sleep(1000)
}
}
}
fun main() {
// Here the caller doesn't have to know anything about coroutine scopes, but
// cedes control over the life cycle of the nested coroutines
runThingy { MyAwesomeAsyncThingyWithEncapsulatedCoroutineScope() }
// Here the caller must create a coroutinescope (almost always via runBlocking)
// and pass it in to us, but in exchange they know 100% for sure that when their
// `runBlocking` stanza exist, all of our coroutines are shut down one way or another.
runBlocking {
runThingy { MyAwesomeAsyncThingyWithExplicitCoroutineScope(this) }
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment