Home > OS >  How to avoid using Kotlin Coroutines' GlobalScope in a Spring WebFlux controller that performs
How to avoid using Kotlin Coroutines' GlobalScope in a Spring WebFlux controller that performs

Time:10-18

I have a Rest API that is implemented using Spring WebFlux and Kotlin with an endpoint that is used to start a long running computation. As it's not really elegant to just let the caller wait until the computation is done, it should immediately return an ID which the caller can use to fetch the result on a different endpoint once it's available. The computation is started in the background and should just complete whenever it's ready - I don't really care about when exactly it's done, as it's the caller's job to poll for it.

As I'm using Kotlin, I thought the canonical way of solving this is using Coroutines. Here's a minimal example of how my implementation looks like (using Spring's Kotlin DSL instead of traditional controllers):

import org.springframework.web.reactive.function.server.coRouter

// ...

fun route() = coRouter {
    POST("/big-computation") { request: ServerRequest ->
        val params = request.awaitBody<LongRunningComputationParams>()
        val runId = GlobalResultStorage.prepareRun(params);
        coroutineScope {
            launch(Dispatchers.Default) {
                GlobalResultStorage.addResult(runId, longRunningComputation(params))
            }
        }
        ok().bodyValueAndAwait(runId)
    }
}

This doesn't do what I want, though, as the outer Coroutine (the block after POST("/big-computation")) waits until its inner Coroutine has finished executing, thus only returning runId after it's no longer needed in the first place.

The only possible way I could find to make this work is by using GlobalScope.launch, which spawns a Coroutine without a parent that awaits its result, but I read everywhere that you are strongly discouraged from using it. Just to be clear, the code that works would look like this:

POST("/big-computation") { request: ServerRequest ->
    val params = request.awaitBody<LongRunningComputationParams>()
    val runId = GlobalResultStorage.prepareRun(params);
    GlobalScope.launch {
        GlobalResultStorage.addResult(runId, longRunningComputation(params))
    }
    ok().bodyValueAndAwait(runId)
}

Am I missing something painfully obvious that would make my example work using proper structured concurrency or is this really a legitimate use case for GlobalScope? Is there maybe a way to launch the Coroutine of the long running computation in a scope that is not attached to the one it's launched from? The only idea I could come up with is to launch both the computation and the request handler from the same coroutineScope, but because the computation depends on the request handler, I don't see how this would be possible.

Thanks a lot in advance!

CodePudding user response:

Maybe others won't agree with me, but I think this whole aversion to GlobalScope is a little exaggerated. I often have an impression that some people don't really understand what is the problem with GlobalScope and they replace it with solutions that share similar drawbacks or are effectively the same. But well, at least they don't use evil GlobalScope anymore...

Don't get me wrong: GlobalScope is bad. Especially because it is just too easy to use, so it is tempting to overuse it. But there are many cases when we don't really care about its disadvantages.

Main goals of structured concurrency are:

  • Automatically wait for subtasks, so we don't accidentally go ahead before our subtasks finish.
  • Cancelling of individual jobs.
  • Cancelling/shutting down of the service/component that schedules background tasks.
  • Propagating of failures between asynchronous tasks.

These features are critical for providing a reliable concurrent applications, but there are surprisingly many cases when none of them really matter. Let's get your example: if your request handler works for the whole time of the application, then you don't need both waiting for subtasks and shutting down features. You don't want to propagate failures. Cancelling of individual subtasks is not really applicable here, because no matter if we use GlobalScope or "proper" solutions, we do this exactly the same - by storing task's Job somewhere.

Therefore, I would say that the main reasons why GlobalScope is discouraged, do not apply to your case.

Having said that, I still think it may be worth implementing the solution that is usually suggested as a proper replacement for GlobalScope. Just create a property with your own CoroutineScope and use it to launch coroutines:

private val scope = CoroutineScope(Dispatchers.Default)

fun route() = coRouter {
    POST("/big-computation") { request: ServerRequest ->
        ...
        scope.launch {
            GlobalResultStorage.addResult(runId, longRunningComputation(params))
        }
        ...
    }
}

You won't get too much from it. It won't help you with leaking of resources, it won't make your code more reliable or something. But at least it will help keep background tasks somehow categorized. It will be technically possible to determine who is the owner of background tasks. You can easily configure all background tasks in one place, for example provide CoroutineName or switch to another thread pool. You can count how many active subtasks you have at the moment. It will make easier to add graceful shutdown should you need it. And so on.

But most importantly: it is cheap to implement. You won't get too much, but it won't cost you much neither, so why not.

  • Related