I'm refactoring some Kotlin code that currently launches coroutines on the GlobalScope
to a structured concurrency-based approach. I need to join all of the jobs launched in my code before the JVM exits. My class can be broken down to the following interface:
interface AsyncTasker {
fun spawnJob(arg: Long)
suspend fun joinAll()
}
Usage:
fun main(args: Array<String>) {
val asyncTasker = createAsyncTasker()
asyncTasker.spawnJob(100)
asyncTasker.spawnJob(200)
asyncTasker.spawnJob(300)
asyncTasker.spawnJob(500)
// join all jobs as they'd be killed when the JVM exits
runBlocking {
asyncTasker.joinAll()
}
}
My GlobalScope
-based implementation looks as follows:
class GlobalScopeAsyncTasker : AsyncTasker {
private val pendingJobs = mutableSetOf<Job>()
override fun spawnJob(arg: Long) {
var job: Job? = null
job = GlobalScope.launch(Dispatchers.IO) {
someSuspendFun(arg)
pendingJobs.remove(job)
}
pendingJobs.add(job)
}
override suspend fun joinAll() {
// iterate over a copy of the set as the
// jobs remove themselves from the set when we join them
pendingJobs.toSet().joinAll()
}
}
Clearly, this is not ideal, as keeping track of every pending job isn't very elegant and a remnant of old thread-based coding paradigms.
As a better approach, I'm creating my own CoroutineScope
which is used to launch all children, providing a SupervisorJob
.
class StructuredConcurrencyAsyncTasker : AsyncTasker {
private val parentJob = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + parentJob)
override fun spawnJob(arg: Long) {
scope.launch {
someSuspendFun(arg)
}
}
override suspend fun joinAll() {
parentJob.complete() // <-- why is this needed??
parentJob.join()
}
}
When initially developing this solution, I omitted the call to parentJob.complete()
, which caused join()
to suspend indefinitely. This feels very unintuitive, so I'm looking for confirmation/input whether this is the correct way to solve this kind of problem. Why do I have to manually complete()
the parent job? Is there an even simpler way to solve this?