So I was working through the Structured Concurrency-Chapter of the new edition of Kotlin in Action a...
s
So I was working through the Structured Concurrency-Chapter of the new edition of Kotlin in Action and I played a bit around with cancelation. I have to say, I'm surprised to see that the
doCpuHeavyWork
function is executed 3 times in this example (instead of 2 times):
Copy code
fun main() {
    runBlocking{
        val job = launch{
            repeat(5) {
                yield()
                doCpuHeavyWork()
            }
        }
        delay(600.milliseconds)
        job.cancel()
    }
}

suspend fun doCpuHeavyWork(): Int {
    println("I'm doing work!")
    var counter = 0
    val endTime=System.currentTimeMillis() + 500
    while (System.currentTimeMillis()<endTime) {
        counter++
    }
    println("I'm done working!")
    return counter
}
I guess, it's an interaction with Kotlin Playground running single-threaded and
yield
probably checking if it's
CoroutineContext
has been deactivated first and then giving up control to other coroutiens (in this case to the Coroutine canceling the repeat-job), yet the result still feels still borderline buggy!? You can run the code on Playground: https://pl.kotl.in/l1TetTLSH Bye the way, you can "fix" the code by adding another
yield
after/infornt of the
yield
.
s
Well,
runBlocking
is always single-threaded, and indeed I can reproduce this on my machine too. I agree, it's strange! It seems like the third
yield()
call is resuming immediately, even though, at that point, the parent coroutine should be eligible to resume instead, having completed its
delay()
. I know that
yield()
isn't exactly required to be deterministic, but I would still have expected that if any other coroutine is eligible to resume, the dispatcher would prefer that one over the one that called
yield()
. 🤔
Did some digging. It seems the
EventLoop
used by
runBlocking
maintains a separate delay queue and event queue. On each iteration of the loop, any delayed continuations that have finished their delay get moved from the delay queue to the back of the event queue. So in this case, the sequence of events is: •
delay()
has expired, and is now at the front of the delay queue. This continuation is the one which will call `cancel()`; let's call it task A. •
yield()
adds its own continuation to the event queue. Let's call it task B. It's now the only thing in the event queue. Now it yields control to the dispatcher, as designed. • Dispatcher checks the delay queue, and moves task A from the front of the delay queue to the back of the event queue. The event queue now contains B, A. • Dispatcher resumes task B, because it's at the front of the event queue 😬. The double-
yield()
fixes it because it now adds itself to the back of the event queue again, while task A is already in the queue.
A more minimal reproduction:
Copy code
fun main() = runBlocking {
  launch {
    delay(1)
    println("This should be first")
  }
  Thread.sleep(1000)
  yield()
  println("This should be second")
}
It should only affect
runBlocking
, since other dispatchers don't use the event loop like that
👍 1
But I would still call it a bug. Nice find!
😊 1
Will you create an issue for it or shall I? 😄
s
Hi @Sam , you did way more work than I figuring out the details, so I would leave the honour of opening a bug ticket to you, if you want!? (Otherwise I'll do it 🤷‍♂️)
s
Okay, will do! I'll share a link here once I do, probably won't be today though
s
perfect 👍
s
so the bugfix seems to have been merged to the develop branch https://github.com/Kotlin/kotlinx.coroutines/pull/4136 but there's no mention of which version of Kotlin this will be in 🤔 Does this mean it'll be in the next version of Kotlin?