kotlinx.coroutines: `runBlocking` should let go of CPU token before parking the thread

What do we have now?

runBlocking parks the thread holding the CPU-token if it happens on a thread of Dispatchers.Default.

What should be instead?

runBlocking should let go of the CPU-token before parking, and “re-acquire” the token before un-parking (it should be un-parked in state where the token is already held by it).

Why?

The current solution is just “don’t use runBlocking” followed by “at least don’t use runBlocking inside Dispatchers.Default”, which is not a solution. This does not work in real-life scenarios, especially with large mixed codebases like IJ. In IJ we’ve tried to leverage #3439 but the approach is stillborn because it causes #3982 but on multi-threaded scale, and we even didn’t start to tackle the thread locals which leak from outer thread into inner tasks.

In other similar scenarios (FJ’s managed block), another thread is spawned to compensate for the blocked one. On JVM this is the most viable approach to this day. It’s better to spawn an extra thread, which might do some other work later on or just die after timeout, and to risk OOME, than to have a starvation deadlock.

About this issue

  • Original URL
  • State: open
  • Created 7 months ago
  • Reactions: 2
  • Comments: 16 (10 by maintainers)

Commits related to this issue

Most upvoted comments

But with this approach there will be no guarantee that the parallelism is equal to CPU count

That’s another big thing I don’t understand, yes. Do you actually need that guarantee?

Quoting your initial message:

In other similar scenarios (FJ’s managed block), another thread is spawned to compensate for the blocked one. On JVM this is the most viable approach to this day. It’s better to spawn an extra thread, which might do some other work later on or just die after timeout, and to risk OOME, than to have a starvation deadlock.

I get a strong impression that you’re okay with utilizing extra threads to resolve deadlocks.

it will be eventually unblocked, and the system will proceed.

It can realistically take several seconds or more. If you’re okay with spawning extra threads, this would be a good time to do that, no?

On the other hand, runBlocking, which switches to the same dispatcher, causes starvation, and there is no way of exiting this state.

I think I see this point, thank you.

There is a blocking API with no constraints.

And then this blocking API with no constraints starts to be unconditionally run on Dispatchers.Default?

Here’s another possible way to perform this migration:

interface CoolExtension {
  fun computeStuff(): Any
  suspend fun computeStuffS(): Any {
    throw SuspendImplNotProvided()
  }
}

internal class SuspendImplNotProvided(): Exception()

suspend fun CoolExtension.doComputeStuff() {
  try {
    withContext(Dispatchers.Default) {
      computeStuffS()
    }
  } catch (e: SuspendImplNotProvided) {
    withContext(Dispatchers.IO) {
      computeStuff() // can potentially block, so using the IO dispatcher
    }
  }
}