3

I am trying to comprehend Task scheduling principles in Monix. The following code (source: https://slides.com/avasil/fp-concurrency-scalamatsuri2019#/4/3) produces only '1's, as expected.

  val s1: Scheduler = Scheduler(
    ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor()),
    ExecutionModel.SynchronousExecution)

  def repeat(id: Int): Task[Unit] =
    Task(println(s"$id ${Thread.currentThread().getName}")) >> repeat(id)

  val prog: Task[(Unit, Unit)] = (repeat(1), repeat(2)).parTupled

  prog.runToFuture(s1)

  // Output:
  // 1 pool-1-thread-1
  // 1 pool-1-thread-1
  // 1 pool-1-thread-1
  // ...

When we add Task.sleep to the repeat method

  def repeat(id: Int): Task[Unit] =
    Task(println(s"$id ${Thread.currentThread().getName}")) >>
      Task.sleep(1.millis) >> repeat(id)

the output changes to

// Output
// 1 pool-1-thread-1
// 2 pool-1-thread-1
// 1 pool-1-thread-1
// 2 pool-1-thread-1
// ...

Both tasks are now executed concurently on a single thread! Nice :) Some cooperative yielding has kicked in. What happenend here exactly? Thanks :)

EDIT: same happens with Task.shift instead of Task.sleep.

Kamil Kloch
  • 333
  • 1
  • 9

2 Answers2

1

I'm not sure if that's the answer you're looking for, but here it goes:

Allthough naming suggests otherwise, Task.sleep cannot be compared to more conventional methods like Thread.sleep.

Task.sleep does not actually run on a thread, but instead simply instructs the scheduler to run a callback after the elapsed time.

Here's a little code snippet from monix/TaskSleep.scala for comparison:

[...]

implicit val s = ctx.scheduler
val c = TaskConnectionRef()
ctx.connection.push(c.cancel)

c := ctx.scheduler.scheduleOnce(
  timespan.length,
  timespan.unit,
  new SleepRunnable(ctx, cb)
)

[...]

private final class SleepRunnable(ctx: Context, cb: Callback[Throwable, Unit]) extends Runnable {

  def run(): Unit = {
    ctx.connection.pop()
    // We had an async boundary, as we must reset the frame
    ctx.frameRef.reset()
    cb.onSuccess(())
  }
}

[...]

During the period before the callback (here: cb) is executed, your single-threaded scheduler (here: ctx.scheduler) can simply use his thread for whatever computation is queued next.

This also explains why this approach is preferable, as we don't block threads during the sleep intervals - wasting less computation cycles.

Hope this helps.

Markus Appel
  • 3,138
  • 1
  • 17
  • 46
  • Thanks for the reponse. This does not quite explain why the first variant produces only 1's, does it? There is no extra 'sleep' work at all ;) I understand the crucial part is that `Task.sleep` / `Task.shift` enforce an async boundary? – Kamil Kloch Sep 11 '19 at 13:23
  • @KamilKloch I think it does. To put it in simple words, in your first example, the first task in the tuple keeps hogging your thread, without ever giving it away for the other task to use. It's an infinite computation after all. – Markus Appel Sep 11 '19 at 13:30
  • Can you explain what you mean with "I understand the crucial part is that Task.sleep / Task.shift enforce an async boundary?"? Yes, both introduce an async boundary. Doesn't it make sense that with only one thread and no async boundary, the first infinite computation will keep running on your thread, leaving no resources for following computations? – Markus Appel Sep 11 '19 at 13:56
  • Yes, agreed on the blocking infinite computation with thread and no async boundary. I do not quite get your explanation with `Task.sleep` yet... Would you agree the crucial part is not a non-blocking sleep but rather an enforced task switch? How would the argument go with `Task.sleep` replaced with `Task.shift`? – Kamil Kloch Sep 12 '19 at 14:19
  • 2
    I wouldn't speak of an *enforced* task switch. It's more of a logically resulting task switch. `Task.sleep` literally tells the scheduler "*please run this callback X milliseconds from now*" and ends. Now, your single-threaded scheduler has nothing to do, expect for running the callback at some point in the future - meaning that the thread is now open for the next queued computation - causing it to run. If you continue this thought you can see how the two `Task`s would naturally start to alternate between each other. Got it? – Markus Appel Sep 12 '19 at 14:26
0

To expand on Markus's answer.

As a mental model (for illustration purpose), you can imagine the thread pool like a stack. Since, you only have one executor thread pool, it'll try to run repeat1 first and then repeat2.

Internally, everything is just a giant FlatMap. The run loop will schedule all the tasks based on the execution model.

What happens is, sleep schedules a runnable to the thread pool. It pushes the runnable (repeat1) to the top of the stack, hence giving the chance for repeat2 to run. The same thing will happen with repeat2.

Note that, by default Monix's execution model will do an async boundary for every 1024 flatmap.

atl
  • 326
  • 1
  • 9