Created
August 18, 2021 13:01
-
-
Save cprice404/22e6c86a7b27e7b7287933613a223b63 to your computer and use it in GitHub Desktop.
Options for coroutines scopes in a library class
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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