scalatimeoutzio

ZIO timeout not timing out even with disconnect


I'm trying to set up something in ZIO to timeout other tasks, but not finding anything which is able to interrupt them reliably.


import zio.*

object MainApp extends ZIOAppDefault {

  def spinny(): String = {
    var x = 0
    var y = 0
    while (true) {
      x = x + 1
      if (x == 0) {
        y = y + 1
        println(s"Looped MaxInt: $y")
      }
    }
    "Unreachable"
  }

  def sleepy(): String = {
    var x = 0
    while (true) {
      x = x + 1
      println(s"Slept another second: $x")
      Thread.sleep(1000)
    }
    "Unreachable"
  }


  def spinnyZIO(): ZIO[Any, Nothing, String] = ZIO.succeed({spinny()})
  def sleepyZIO(): ZIO[Any, Nothing, String] = ZIO.succeed({sleepy()})

  val myApp: ZIO[Any, Nothing, String] =
    for {
      _ <- ZIO.debug("start doing something.")
      //The following will timeout and kill the thread (desired)
      _ <- ZIO.debug("Running sleepy in blocking interrupt.")
      _ <- ZIO.attemptBlockingInterrupt(sleepy())
        .timeout(3.second)
        .orElse(ZIO.succeed(Some("Error during execution")))
      _ <- ZIO.debug("You'll see this (and no more output)")

//      //The following will timeout but the thread keeps going:
//      _ <- ZIO.debug("Running sleepy in with disconnect.")
//      _ <- sleepyZIO()
//        .disconnect
//        .timeout(3.second)
//      _ <- ZIO.debug("You'll see this (but more output)")


//      //The following will timeout iff you add the disconnect, but won't kill the thread
//      _ <- ZIO.debug("Running spinny in blocking interrupt.")
//      _ <- ZIO.attemptBlockingInterrupt(spinny())
//        .disconnect //This line's needed here (but not in the first block!)
//        .timeout(3.second)
//        .orElse(ZIO.succeed(Some("Error during execution")))
//      _ <- ZIO.debug("You'll see this (and no more output)")


      //The following will timeout but the thread keeps going
//      _ <- ZIO.debug("Running spinny with disconnect.")
//      _ <- spinnyZIO()
//        .disconnect
//        .timeout(3.second)
//      _ <- ZIO.debug("You'll see this (and no more output)")

    } yield "All Done"

  def run =
    myApp
}

In the above code, spinny loops forever on the CPU (without sleeping) while sleepy loops forever with 1-second sleeps. spinnyZIO and sleepyZIO just wrap that in ZIO.succeeds. These simulate (user) code outside my control which I want to be able to interrupt after a set period of time. I need to be able to kill the thread (so that it does not starve the system if user code contains some non-returning code.)

There are several problems here:

  1. Only attemptBlockingInterrupt ever kills the other thread, and that is flagged as having significant performance cost.
  2. attemptBlockingInterrupt only works on the non-ZIO methods. Once the code is wrapped in a ZIO (sleepyZIO), I haven't found anything which ever kills it.
  3. Nothing ever terminates spinny() (this may be unavoidable, but it feels like there should be some solution to prevent that thread from running forever...)

Is there another mechanism I'm missing that would let me solve at least some of the problems? Why do none of the mechanisms other than attemptBlockingInterrupt ever kill the underlying thread (even the sleepy one, which I think should be interruptible...)


Solution

  • If you want to create a ZIO task that is blocking a real thread and can be interrupted with a thread interrupt, you should use ZIO.attemptBlockingInterrupt(...). disconnect makes sure that the interruption happens in the background so it doesn't block progress of the rest of your application if interruption is slow, or even impossible if you're trying to interrupt an uninterruptible task. But ZIO does not automatically enable thread interrupts everywhere.

    The scaladoc for attemptBlockingInterrupt also says

    Note that this adds significant overhead. For performance sensitive applications consider using attemptBlocking or attemptBlockingCancelable.

    Code written in ZIO automatically supports cancellation through its own cancellation protocol that is much more principled and efficient than thread interrupts. The only time when you would require thread interrupts is when you are integrating some Java (or "native" scala) library into your ZIO code, and then you should wrap all blocking code into something like attemptBlocking anyway. And if you want the code that you're wrapping to be cancellable through thread interrupts you should use attemptBlockingInterrupt.

    In your example sleepyZIO is simply a bug. You should never wrap blocking impure code in ZIO.succeed.

    And spinnyZIO indeed cannot be terminated. Assuming this represents some kind of long running intensive computation, you can either accept that it's uncancellable. Or else you might rewrite it into small ZIO tasks that you compose together (e.g. every loop iteration can be one task that you call .repeatWhile on). That should allow the ZIO runtime to schedule those computations more fairly with other tasks, and should allow cancellation to happen in between the small tasks. Or you could add checks for thread interrupts into the code and wrap in attemptBlockingInterrupt.