Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] Async API exploration and minimal proposal #99

Open
joshgoebel opened this issue May 1, 2021 · 33 comments
Open

[RFC] Async API exploration and minimal proposal #99

joshgoebel opened this issue May 1, 2021 · 33 comments

Comments

@joshgoebel
Copy link
Contributor

joshgoebel commented May 1, 2021

I was playing around with fibers and wanted to start to build something like an async framework but realized we may be missing just the tiniest infrastructure to make that possible. Following is the larger context - skip to just the end if you'd like my proposal.

Here is the idea I started with:

var s = SlowService.new()
var a = Task.run { s.printTimeDots() }
var b = Task.run { s.loadFiles() }
var c = Task.run { s.loadGraphics() }
Task.await([a,b,c])

I would like to just put those tasks into the scheduler and then wait for them to all complete asynchronously.

The only core thing missing seems to be Scheduler.add(fiber). You can add new functions to the Scheduler, but you cannot ask it to resume the current Fiber later. Perhaps you could nest the transfer inside a wrapper function insider yet another Fiber (the one add creates) - but ugh... that sounds sounds like pain for no reason.

We need a way to be able to insert the current fiber into the queue of Fibers eligible for resumption later. This isn't super helpful on it's own but typically what you would do is first insert a Fiber ahead of you - so that Fiber would run and then when it slept you would be next to resume control, ie:

// schedule some function
Scheduler.add(fn)
// schedule myself
Scheduler.add(Fiber.current)
// transfer control to the scheduled function (fn)
Scheduler.runNextScheduled_()

So let's add that, it's a 4 line patch to static add(_):

  static add(callable) {
    // v--- ADD
    if (callable is Fiber) {
      __scheduled.add(callable)
      return
    }
    // ^--- ADD
    __scheduled.add(Fiber.new {
      callable.call()
      runNextScheduled_()
    })
  }

Perfect, now we can build basic Async support on this alone.

class Async {
  static waitForOthers() {
    Scheduler.add(Fiber.current)
    Fiber.suspend()
  }
  static run(fn) {
    Scheduler.add(fn)
    Scheduler.add(Fiber.current)
    Scheduler.runNextScheduled_()
  }
}

You'll see run looks like exactly what we described above. wait just inserts us at the end of the scheduling queue and then suspends (trusting the Scheduler to resume us later when runNextScheduled_() is called). This assumes the function we are calling will do so at some point (use any of the async IO calls, Timer.sleep, etc.)

And on then top of this foundation you can build higher level abstractions. Given the following small Task class the sample code at the very beginning now works.

class Task {
    static run(fn) { Task.new(fn).run() }
    construct new(fn) { _fn = fn }
    isRunning { !_isDone }
    run() {
        Async.run {
            _fn.call()
            _isDone = true
        }
        return this
    }
    static await(list) {
        while(true) {
            if (list.any { |task| task.isRunning }) {
                Async.waitForOthers()
            } else {
                break
            }
        }
    }
}

The Proposal

Minimally

  • Add support for add(fiber) to the Scheduler API
  static add(callable) {
    if (callable is Fiber) {
      __scheduled.add(callable)
      return
    }
    // ...
  }

This is the foundational thing needed for building these patterns on top of the existing Scheduler. If this was added then I think many types of async patterns could be explored outside the scope of the CLI to see what works best.

If we take it a step further

  • Add an Async class to wrap up common async patterns
  • or consider adding these methods to Scheduler directly
class Async {
  static waitForOthers() {
    Scheduler.add(Fiber.current)
    Fiber.suspend()
  }
  static run(fn) {
    Scheduler.add(fn)
    Scheduler.add(Fiber.current)
    Scheduler.runNextScheduled_()
  }
}

Peripheral

I think higher level abstractions - such as the Task class shown here could be provided by libraries outside of the CLI Core - again all depending on how minimal we wish to keep CLI.

CC @ChayimFriedman2 Related to our prior discussion.

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 1, 2021

And so it seems you can indeed do it now in a dirty way by wrapping the fiber transfer inside a new fiber inside a function:

  static addSelfToScheduler() {
    var fiber_current = Fiber.current
    Scheduler.add( Fn.new {
        fiber_current.transfer()
    })
  }

Impressive but seems needlessly complex, prone to error, and potentially harder to debug - when it would be so easy to just make the tiny patch to Scheduler to allow this to happen without all the extra layers.

Working implementation using the above hack as a library:

@ChayimFriedman2
Copy link

I'm not sure I understand your idea well, but I'm afraid you don't understand the scheduler.

You don't ask the scheduler to resume the current fiber. It does it automatically when it can. You just add() fibers to be executed when the current fiber is paused.

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 2, 2021

but I'm afraid you don't understand the scheduler.

That's always possible, but I'm honestly fairly certain I truly do here. :) Let me explain my understanding and you can point out where you see errors, if any.

You don't ask the scheduler to resume the current fiber. It does it automatically when it can. You just add() fibers to be executed when the current fiber is paused.

Well, we may be getting hung up on what "automatic" means... there is no actual magic here - it's all just C and Wren code working together - asking the Scheduler to do things... Fiber's don't pause and resume themselves (well sorta with call, but we're not using Fiber.call). I wonder if that's the confusion?

For simplicity we'll assume only the main Fiber exists when we start, that we're running a script from the console... For a C foreign dispatch here is what happens:

  • You call a foreign function that seems "magically async" (Directory.list() just to take one example)
  • Wren calls the foreign C function (passing it args AND the current fiber)
  • The foreign C function saves the Fiber reference and places a C callback onto the UV event loop (which will be dispatched later when IO is finished, etc)
  • Control returns to Wren
  • Scheduler.runNextScheduled_() is called, which calls Fiber.suspend (because we have no other Fibers to queue)
  • The Wren VM now stops completely

Docs for Fiber.suspend():

Pauses the current fiber, and stops the interpreter. Control returns to the host application.

At this point things are dead stop. The only thing still "running" [quietly] is the UV event loop inside C. The VM is not running. So how do we get unstuck? That C callback I mentioned earlier... it finally finishes... this kicks of another sequence of events: (see scheduler.c)

  • The C callback fires and this C code is ultimately run
  schedulerResume(freeRequest(request), true);
  schedulerFinishResume();

That pushes any necessarily return arguments and then calls one of (passing it the Fiber saved earlier):

  • resume_(_)
  • resume_(_,_)
  • resumeError_(_,_)

And what does resume do? It calls fiber.transfer(), transferring control back (finally) to the original fiber (the one waiting for that Directory.list). You don't see all this because it's hidden behind the C API. But this is all done exactly by asking the scheduler to resume the current fiber. It's not done by pushing a Fiber onto the queue though, it's done by holding the reference inside C until the callback returns.


So what I'm talking about here is doing this same thing almost 100% in Wren. IE, we're not going to call any foreign C functions, we're just calling other Wren functions (who then hopefully call async C functions, but that will only return control to them not to us)... we need to get control back to ourselves.

Lets look at my example again step by step:

// schedule some function
Scheduler.add(fn)

We schedule a function... if the queue is empty that means that this will be the very next thing the Scheduler runs when runNextScheduled_ is called.

Lets say we SKIP the critical next step as you're suggesting:

// schedule myself
// Scheduler.add(Fiber.current) // comment this out!
// transfer control to the scheduled function (fn)
Scheduler.runNextScheduled_()

The Scheduler transfers control to "some function" by calling __scheduled.removeAt(0).transfer() ... the function may return, or it may idle and call Scheduler.runNextScheduled_() but there is nothing else on the queue... so the VM will stop... eventually control will return and the function will finish... and then nothing will happen.

Control is never returned to the "caller". You may be missing the nuance of Fiber.transfer vs Fiber.call... control does not "bounce back" automatically when using transfer - there is no "call stack".


So we fix this as shown earlier:

    Scheduler.add(fn)
    Scheduler.add(Fiber.current)
    Scheduler.runNextScheduled_()
  1. We add the function, 2. we add ourselves, 3. we let scheduler do it's thing. The function will run and then whenever the function idles (or finishes) control will return to us. If the function is still running (it might be waiting for IO, etc) then we need to check that and if so put ourselves back on the queue again and then suspend. Eventually the scheduler will wake us up again - and we keep checking if the function has finished or not.

Eventually it will and we can resume primary execution. This is what my Task.await loop does:

    static await(list) {
        while(true) {
            if (list.any { |task| task.isRunning }) {
                Async.waitForOthers()
            } else {
                break
            }
        }
    }

Every time the main fiber (the one running Task.await) wakes it has to check the status of all the functions... if any are running it goes right back to sleep (putting itself back on the run queue)... eventually all functions will be completely and break will fire and await will return.

You can test all this on the current CLI right now just by trying my async-task library and mucking around with it.

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 2, 2021

The whole Scheduler system as written now truly "hides" a key part of the scheduling [state] in C callbacks... making it much harder to follow I think.

If I was to rewrite the whole thing to be clearer I might actually have Thread objects or something and the scheduler would manage those instead... so it would always have 100% "visibility" to all threads... Threads could be marked as busy (waiting for IO, etc - ie, not runnable) BUT still always tracked by the Scheduler... the C callback's "resume" process would then simply mark the thread as unbusy and once again eligible for execution so that the next call to runNextScheduled_ would pick it up and resume it.

At any point you could then print out the list of "suspended" threads - you could then have some sort of "Task manager" Fiber running monitoring everything. This is impossible currently... because once a Fiber is handed to C is "disappears" and the Scheduler loses track of it until C reminds it again.

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 2, 2021

You just add() fibers to be executed when the current fiber is paused.

You had me thinking about this statement for a moment. This is completely true, but it's only a single possibly way of using the scheduler. This would require your current fiber to go into a tight (or loose) sleep loop - either waking up far more often than is necessary OR not waking up often enough. That's the problem I'm solving, that's what the scheduler should do for us.

Here is a tiny example of the problem you can run:

import "scheduler" for Scheduler
import "timer" for Timer

// our background process 
var f = Fn.new {
    Timer.sleep(5000) // pretend this is a file download
    System.print("Function done")
}
Scheduler.add(f)
Scheduler.runNextScheduled_()
// control is never returned to us after the function finishes

System.print("Will you ever see this? No.")

What we really want to do is "wait until the function finishes, then let me know"... I solved this inside the Scheduler.


Here is what I believe you're pointing out/suggesting:

import "scheduler" for Scheduler
import "timer" for Timer

// our background process 
var f = Fn.new {
    Timer.sleep(5000) // pretend this is a file download
    System.print("Function done")
}
Scheduler.add(f)
while(true) {
   Time.sleep (1000) // sleep for 1000 ms, giving our download time to run
   // check some status to see if the function is done
   // if done break
}

System.print("Will you ever see this? YES.")

So here we sleep, wake up every second, and check the status of the download... solving the problem outside the scheduler.

  • if the download finishes VERY quickly we're stuck waiting a whole extra second, even if the download only took 20ms
  • if we wake up every 20ms then we're wastefully checking the download potentially 100s of times when it can't possibly be finished yet (it may not have even been woken up a single time, perhaps it's hung waiting for a network connection, etc.)

This approach seems less optimal because it's kind of "building a tiny scheduler" with Timer.sleep(20) or Timer.sleep(1000). But that's the schedulers job.

The two approaches may look quite similar (the loops), and they are - but a key difference is:

  • With putting the current fiber back in the queue we entirely let the Scheduler control waking us up (likely when it's actually the correct time to wake up)
  • With "loop/sleep" we're taking control away from the Scheduler, doing it's job for it, possibly waking up at entirely random times with no relation to what else is going on with the other Fibers.

@ChayimFriedman2
Copy link

ChayimFriedman2 commented May 3, 2021

I was finally able to grasp it all! Thanks. I did understand the scheduler, but not your code.

However, I've still got some critiques.

  • It interferes with Scheduler.add(). Example:
var task = Task.run {
  Timer.sleep(1000)
  System.print("Task")
}
Scheduler.add { System.print("Non-task") }
Task.await([task])

Will wait, print "Task" then "Non-task" instead of immediately printing "Non-task", waiting, then printing "Non-task".
The problem is that Async.waitForOthers(), called from Task.await(), will suspend the fiber, waiting for an I/O event to resume it. The scheduler's queue will contain the "Non-task", but it was not suspended because of I/O event and so cannot be resumed. If we were calling Scheduler.runNextScheduled_() this would be solved, but we would have an another problem of an infinite transfer-back into the current fiber.

  • Task is, ultimately, Fiber. Splitting them makes no sense. In the same manner, Async can be unified into Scheduler.
  • You cannot use the value returned from the tasks.

I suggest, instead, adding the following little piece of code to Scheduler. I'm still not sure how to deal with fiber errors, we probably want another methods for that. Also, it would be nice if we could cancel the no-longer-wanted fibers in any(). But as a draft, that's good.

class Scheduler {
  // ...
  static all(tasks) {
    if (__scheduled == null) __scheduled = []

    var callingFiber = Fiber.current
    var results = List.filled(tasks.count, null)
    var fibers = []
    for (i in 0...tasks.count) {
      fibers.add(Fiber.new {
        results[i] = tasks[i].call()
        fibers[i] = null
        callingFiber.transfer()
      })
    }
    __scheduled.addAll(fibers)
    while (fibers.any {|fiber| fiber != null }) runNextScheduled_()
    return results
  }

  static any(tasks) {
    if (__scheduled == null) __scheduled = []

    var callingFiber = Fiber.current
    var result = null
    var done = false
    var fibers = tasks.map {|task| Fiber.new {
      var myResult = task.call()
      if (!done) {
        done = true
        result = myResult
        callingFiber.transfer()
      }
    } }
    __scheduled.addAll(fibers)
    runNextScheduled_()
    return result
  }
}
import "scheduler" for Scheduler
import "timer" for Timer

var a = Fn.new {
  Timer.sleep(5000)
  System.print("Task a")
  return "a"
}
var b = Fn.new {
  Timer.sleep(1000)
  System.print("Task b")
  return "b"
}
var c = Fn.new {
  Timer.sleep(3000)
  System.print("Task c")
  return "c"
}
System.print(Scheduler.all([a, b, c]))

// Waits a second, then prints "b"
// Waits two more seconds, then prints "c"
// Waits two more seconds, then prints "a"
// Prints "[a, b, c]"

@joshgoebel
Copy link
Contributor Author

Your broadening the discussion a bit I think, which is fine. I was arguing (at a minimum) for a single small primitive to be added to Scheduler to allow people to build more complex schedulers on top of the existing system. :-) My async system was just one such small example of what's possible. The existing scheduler is indeed quite limited in so many ways and I'd love to replace it with something nicer, but I'm not sure that needs to be in core or CLI core. The isn't what I was suggesting here.

It interferes with Scheduler.add()

This is a bug, not a problem with the approach itself. My first implementation just didn't consider this. As you point out all that's needed is an additional call to Scheduler.next()... I don't think an infinite loop is actually that hard to handle. The problem is we have no thread driving the scheduler itself so before we suspend need to first ALWAYS make sure all Fibers are in the wait state...

If you open an issue against https://github.com/joshgoebel/wren-async-task/blob/main/async-task.wren with a small example case and I'll see if I can fix the bug for you. :-)

The scheduler's queue will contain the "Non-task", but it was not suspended because of I/O event and so cannot be resumed.

It'll get resumed the next time ANY IO event fires of course... the issue is how quickly it will be resumed, which is indeed a bug.

Task is, ultimately, Fiber. Splitting them makes no sense. In the same manner, Async can be unified into Scheduler.

I see what you mean, but I think a Task is a lot more than a Fiber. How much is it depends on what a user is trying to do. Fibers are the low-level concurrency primativees Wren exposes. A Task is higher level abstraction and might include:

  • timing statistics
  • dependencies/relationships to other tasks
  • state information

I think there is value in the low-level Fiber primitives being quite basic and then allowing much more complex task management built on to of that. I was showing one such simple way to build an async/await system. I'm not sure we need to lock users into one true way. If we're "stuck" with the Scheduler in the CLI (for some time) because it's too hard-wired into the C code to easily replace entirely (via a binary or non-binary library) then I'd like to make sure it supports "just enough" to allow people to build multiple different scheduling systems on top of it.

Then lets see what people build, and may the best implementation win (if it's even decided we need a single winner).

You cannot use the value returned from the tasks.

Oh you mean there isn't a way to get a return value? That could easily be added to the API.

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 3, 2021

Your implementation looks like one viable possibility for building out the higher level pieces. It indeed solves "more of the problem" than mine did because I think you had more of the problem in mind. :-) I think your implementation also proves my larger point here. I see you use:

 __scheduled.addAll(fibers)

With the one change I suggest your implementation also could be built entirely outside the Scheduler.

At a minimum - if we do nothing else - we need a clean way to directly add a fiber (not just a function) to the scheduler run queue. I'd be happy if we just agreed on that one thing for starters. :-)

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 3, 2021

I'm still not sure how to deal with fiber errors

I purposely omitted errors from the discussion. I'd like to see some different approaches. I imagine dealing with errors very much depends on the use case.

  • In case cases perhaps a crash of the VM is what you truly want (in which case you need nothing, this is the default behavior)
  • In other cases perhaps that is NEVER what you want. (but then the question is what to do about errors, presumably the "owner" thread would check in and take some action)
  • In some cases, perhaps the crashing processes should be automatically restarted

With #3 though we rapidly get into supervision, supervision trees, etc... (the stuff Elixir and Erlang are amazing at) which are things that you could build out on TOP of lower-level primitives. They aren't things I think we need to support in CLI or CLI core on day one. I suppose #3 is really just a variant of #2.

Also, it would be nice if we could cancel the no-longer-wanted fibers

I'd love to tackle this. This is the one thing that may require further changes to Scheduler itself. (vs being able to be built on top)... I would think it involves passing process handles around, not Fibers. So when we sleep a task inside C, C gets a handle to the process (not the fiber)... when it resumes it passes the scheduler the process handle... at that point the scheduler could go "oh wait, this process has been killed/requested to kill" and simply not resume the fiber and lose the reference - which at some point should make it be GCed.

And (I have no idea about this part) theoretically if C has a list of process handles (that are waiting callbacks in UV) you could have Scheduler call down into C to say "kill this handle" and perhaps? in some? cases C could simply remove the callback from the UV event loop. This might very much depend on what kind of callback it was though, etc... one imagines there are fewer side effects to cancelling a timer (wake me up in 15) than cancelling a download operation thats' right in the middle of a bunch of network IO.

@ChayimFriedman2
Copy link

ChayimFriedman2 commented May 3, 2021

This is a bug, not a problem with the approach itself. My first implementation just didn't consider this. As you point out all that's needed is an additional call to Scheduler.next()... I don't think an infinite loop is actually that hard to handle.

IMO it is a problem with the approach itself. I was unable to find a way to fix it.

The problem is we have no thread driving the scheduler itself so before we suspend need to first ALWAYS make sure all Fibers are in the wait state...

I don't understand.

It'll get resumed the next time ANY IO event fires of course... the issue is how quickly it will be resumed, which is indeed a bug.

Yup, this is what I meant.

I see what you mean, but I think a Task is a lot more than a Fiber. How much is it depends on what a user is trying to do. Fibers are the low-level concurrency primativees Wren exposes. A Task is higher level abstraction and might include:

  • timing statistics
  • dependencies/relationships to other tasks
  • state information

I'm not sure about "dependencies/relationships to other tasks" (what you mean). About the others, they can, and probably should, be external to the scheduler. This is something to have an external library for. But in your code, Task is not that.

I think there is value in the low-level Fiber primitives being quite basic and then allowing much more complex task management built on to of that. I was showing one such simple way to build an async/await system. I'm not sure we need to lock users into one true way. If we're "stuck" with the Scheduler in the CLI (for some time) because it's too hard-wired into the C code to easily replace entirely (via a binary or non-binary library) then I'd like to make sure it supports "just enough" to allow people to build multiple different scheduling systems on top of it.

Then lets see what people build, and may the best implementation win (if it's even decided we need a single winner).

I don't see this is as a contradiction to what I said. Quite the opposite. The CLI provides the most basic async functionalities - running fibers in parallel, and people can build whatever abstractions they want, or need, on top of it.

Oh you mean there isn't a way to get a return value? That could easily be added to the API.

You are right. As opposed to the other points, this one is really something that can be integraed easily into your solution (in fact, before I built my solution, I hacked this into yours). I just wanted to note it.

Your implementation looks like one viable possibility for building out the higher level pieces. It indeed solves "more of the problem" than mine did because I think you had more of the problem in mind. :-) I think your implementation also proves my larger point here. I see you use:

 __scheduled.addAll(fibers)

With the one change I suggest your implementation also could be built entirely outside the Scheduler.

At a minimum - if we do nothing else - we need a clean way to directly add a fiber (not just a function) to the scheduler run queue. I'd be happy if we just agreed on that one thing for starters. :-)

I see this as no more than an optimization. You can easily add a fiber to the scheduler now, with Scheduler.add { fiber.call() }.

I purposely omitted errors from the discussion. I'd like to see some different approaches. I imagine dealing with errors very much depends on the use case.

  • In case cases perhaps a crash of the VM is what you truly want (in which case you need nothing, this is the default behavior)
  • In other cases perhaps that is NEVER what you want. (but then the question is what to do about errors, presumably the "owner" thread would check in and take some action)
  • In some cases, perhaps the crashing processes should be automatically restarted

With #3 though we rapidly get into supervision, supervision trees, etc... (the stuff Elixir and Erlang are amazing at) which are things that you could build out on TOP of lower-level primitives. They aren't things I think we need to support in CLI or CLI core on day one. I suppose #3 is really just a variant of #2.

I didn't mean generally "errors in the scheduler". That's nice to have, but not very important. What I meant is combination of successful and failed fibers in Scheduler.all() and Scheduler.any(). For example, should any() fail if the first completed fiber fails or wait for the first successful one? In js we have bunch of Promise methods to deal with different situations. However, as I said, this can be easily built on the top of all() and any().

I'd love to tackle this. This is the one thing that may require further changes to Scheduler itself. (vs being able to be built on top)... I would think it involves passing process handles around, not Fibers. So when we sleep a task inside C, C gets a handle to the process (not the fiber)... when it resumes it passes the scheduler the process handle... at that point the scheduler could go "oh wait, this process has been killed/requested to kill" and simply not resume the fiber and lose the reference - which at some point should make it be GCed.

Yeah, I've though about something like that.

one imagines there are fewer side effects to cancelling a timer (wake me up in 15) than cancelling a download operation thats' right in the middle of a bunch of network IO.

I don't fear this. Cancelling a network request is not worse, or better, than cancelling file I/O. The thing I do fear of is uncompleted code. For example, what if we do have to operations that one of them need to succeed (any()):

  • Read something from the network.
  • Read something from a file.

What if we finished the network request (and thus cancelling the second fiber) after opening the file, but before closing it? It would not be closed until the process finished. That makes me fear we need a whole another set of methods for uncancellable fibers, and that starts to become an exponential number of methods (errors x uncancellable).

Conclusion

I understand you think that this does not need to be in the CLI. But I do not agree. The scheduler is only halfway completed without this. Even if this can be coded into an external library (and it cannot, because Scheduler.runNextScheduled_() is (rightfully) private), it shouldn't. These are the most basic pieces of async stuff: the ability to run things in parallel.

@joshgoebel
Copy link
Contributor Author

but we would have an another problem of an infinite transfer-back into the current fiber.

I think this requires some small amount of support in Scheduler to do nicely. But it wouldn't have to be super complex:

  static addSelfToScheduler() {
    Scheduler.add(Fiber.current, {skipTicks: 1 })
  }

The second argument being how many Scheduler "ticks" to skip... and a sentinel would have to be added to the queue so that the scheduler could tell when it had "completed" a full loop of all processes. So we would add ourselves to the current run loop but then when our tick count is > 0 we'd skip calling transfer and instead move us to the very END of the urn list (after the sentinel)... so we'd only be woken up the next time thru (after some other processes had woken the scheduled from sleep). IE, resolving the infinite loop issue.

I should note that your solution still goes much further by integrating everything (intentionally I assume). You're building task/parent dependencies into the callbacks themselves, where-as I'm not. So you only wake up the "parent" when a child completes, where-as my simple approach wakes the parent many times. (the suggestion here would only wake it once per scheduler tick)

These are all tradeoffs. I think the system I proposed is simpler and more modular. I think it's easier to understand each of the small components in isolation (though perhaps this depends on one's programming background?). The bug you found could be quite annoying, but if that were resolved I think both of these approaches would work well in real-life for many uses cases.

Also note one could add dependencies to the Scheduler (at the data level)... without baking them into the loops or callbacks themselves. IE using it might look something like

    static await(list) {
      list.each { |task| Threads.addDependency(Threads.current, task) }
      while (true) { 
        if (list.any { |task| task.isRunning }) {
          aSync.waitForDependents()
        }
      }
    }

This difference here again still being we're woken each time AFTER a dependent thread runs on the queue (which can be super useful in a lot cases, just not this example)... for example, one could imagine a system where we aren't just blindly waiting but where we are tracking the progress of 10 different downloads and printing the progress to the screen constantly.

That's why I was trying to focus on smaller building blocks - that you could solve many types of problems with. I think you're trying to solve a narrower problem (and hence doing it better because your solution is focusing on ONLY that problem).

@joshgoebel
Copy link
Contributor Author

For example adding a progress callback (to track the status of those downloads) to my system would be a single LOC:

    static await(list, progressCallback) {
      while (true) { 
        if (list.any { |task| task.isRunning }) {
          if (progressCallback != null) progressCallback.call()
          aSync.waitForDependents()
        }
      }
    }

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 3, 2021

Also the original problem could be solved another way with an addAndStart API:

var task = Task.run {
  Timer.sleep(1000)
  System.print("Task")
}
Scheduler.addAndStart { System.print("Non-task") }
Task.await([task])

That might even be the better default behavior, though I could imagine arguments on both sides. Since we don't have true concurrency there may be cases where this ordering really matters.

@joshgoebel
Copy link
Contributor Author

Do you have any thoughts on the specifics of my actual minimal proposal here:

Add support for add(fiber) to the Scheduler API

If there any harm in making it possible to do that without the extra fiber/fn hoops you have to jump thru now to accomplish it?

@ChayimFriedman2
Copy link

but we would have an another problem of an infinite transfer-back into the current fiber.

I think this requires some small amount of support in Scheduler to do nicely. But it wouldn't have to be super complex:

  static addSelfToScheduler() {
    Scheduler.add(Fiber.current, {skipTicks: 1 })
  }

The second argument being how many Scheduler "ticks" to skip... and a sentinel would have to be added to the queue so that the scheduler could tell when it had "completed" a full loop of all processes. So we would add ourselves to the current run loop but then when our tick count is > 0 we'd skip calling transfer and instead move us to the very END of the urn list (after the sentinel)... so we'd only be woken up the next time thru (after some other processes had woken the scheduled from sleep). IE, resolving the infinite loop issue.

My head doesn't grasp it. Sorry.

@joshgoebel
Copy link
Contributor Author

I think (I'm not an expert, but I know some things) that real OS schedulers have the idea of a loop thru the FULL process list... the scheduler interrupts periodically (since modern OSes are preemptive) and the loops over all the processes to see if they need some time on the CPU or not... then rinse repeat. But this "infinite" bug can't happen because you're just processing the task queue literally each "tick". It's impossible for a process to be called twice.

We could of course build that... but right now we just have a simple queue... so you could do the same thing by adding a "STOP" opcode to the queue. Say here is the queue:

[
run task 1
run task 2
run task 3
run await
]

The problem is we get to the end and remove await, but await runs and adds itself back, infinite loop. So what you do instead. Before await runs:

[
run await,
TICK_END
]

After await runs:

[
TICK_END
run await,
]

Scheduler.runNextScheduled_() is called, removes TICK_END and immediately suspends (nothing more to do this tick). Your queue then looks like:

[
TICK_END
]

This might be a terrible way to actually do it though and it might be simpler to just have a List and use each. :-) It was just the first thing I thought of. :)

@ChayimFriedman2
Copy link

These are all tradeoffs. I think the system I proposed is simpler and more modular. I think it's easier to understand each of the small components in isolation (though perhaps this depends on one's programming background?). The bug you found could be quite annoying, but if that were resolved I think both of these approaches would work well in real-life for many uses cases.

I don't agree. Your system is indeed easier to work with, for the programmer of the CLI. For the user, all it wants is to run something in parallel. That's it. And I'm not sure yours being more modular; js will prove which have (almost) exactly the same system as mine.

Furthermore, IMHO, mine is more natural: you wake up when you need to. If I want some task to finish, I want to wake up when it finished. That's it.

Also note one could add dependencies to the Scheduler (at the data level)... without baking them into the loops or callbacks themselves. IE using it might look something like

    static await(list) {
      list.each { |task| Threads.addDependency(Threads.current, task) }
      while (true) { 
        if (list.any { |task| task.isRunning }) {
          aSync.waitForDependents()
        }
      }
    }

This difference here again still being we're woken each time AFTER a dependent thread runs on the queue (which can be super useful in a lot cases, just not this example)... for example, one could imagine a system where we aren't just blindly waiting but where we are tracking the progress of 10 different downloads and printing the progress to the screen constantly.

What's the point?

That's why I was trying to focus on smaller building blocks - that you could solve many types of problems with. I think you're trying to solve a narrower problem (and hence doing it better because your solution is focusing on ONLY that problem).

Indeed. But smaller building blocks have a problem: they're small. And I'm afraid yours are too small. I prefer larger building blocks, especially when I'll probably never want to get deeper (try to describe a situation only your smaller blocks can solve).

For example adding a progress callback (to track the status of those downloads) to my system would be a single LOC:

    static await(list, progressCallback) {
      while (true) { 
        if (list.any { |task| task.isRunning }) {
          if (progressCallback != null) progressCallback.call()
          aSync.waitForDependents()
        }
      }
    }

You're calling the progress callback after each I/O event? What's the point? It will probably have just one for one network request. You probably want to call it each X time. Here's how I would do it:

class FiberProgress {
  static withProgress(fiber, callback, progressTime) {
    return Fn.new {
      return Scheduler.any([
        fiber,
        Fn.new {
          while (true) {
            Timer.sleep(progressTime)
            callback.call()
          }
        },
      ])
    }
  }
}
var result = FiberProgress.withProgress(
  Fn.new { Network.get("https://google.com") },
  Fn.new { System.print("Progress...") },
  1000
)

Also the original problem could be solved another way with an addAndStart API:

var task = Task.run {
  Timer.sleep(1000)
  System.print("Task")
}
Scheduler.addAndStart { System.print("Non-task") }
Task.await([task])

That might even be the better default behavior, though I could imagine arguments on both sides. Since we don't have true concurrency there may be cases where this ordering really matters.

It does not solve the bug, and I also don't see value in this addition: Scheduler.add() is meant to use, for instance, when firing a fiber for each incoming request in a server. You still need to get your stuff done.

Do you have any thoughts on the specifics of my actual minimal proposal here:

Add support for add(fiber) to the Scheduler API

If there any harm in making it possible to do that without the extra fiber/fn hoops you have to jump thru now to accomplish it?

I don't see value in it, but I see a lot of hard. You can ruin the scheduler completely with that.

@ChayimFriedman2
Copy link

ChayimFriedman2 commented May 4, 2021

We could of course build that... but right now we just have a simple queue... so you could do the same thing by adding a "STOP" opcode to the queue. Say here is the queue:

[
run task 1
run task 2
run task 3
run await
]

The problem is we get to the end and remove await, but await runs and adds itself back, infinite loop. So what you do instead. Before await runs:

[
run await,
TICK_END
]

After await runs:

[
TICK_END
run await,
]

Scheduler.runNextScheduled_() is called, removes TICK_END and immediately suspends (nothing more to do this tick). Your queue then looks like:

[
TICK_END
]

A simpler way will just to check if we have items in the queue. If yes, runNextScheduled_(). If not, Fiber.suspend(). This is both simpler and more performant. But both of those solutions requires support from the scheduler.

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 4, 2021

I don't agree. Your system is indeed easier to work with, for the programmer of the CLI. For the user...

It's equally easy for just a user. We both have the exactly same API. Task.await() The end user who only cares about the highest level abstraction only need focus on the highest level abstraction.

Furthermore, IMHO, mine is more natural: you wake up when you need to. If I want some task to finish, I want to wake up when it finished. That's it.

That provides no way for users to track tasks as they progress or update the UI, etc... as I already pointed out.

What's the point?

There are two use cases (just off the top of my head) here:

  • do NOTHING until all tasks complete
  • do something while all tasks are running (show progress, etc)

The code you showed only does one thing, on purpose. The code I showed easily allows either (by adding a single callback). What I'm suggesting is more flexible - because I want to give implementers choices.

And I'm afraid yours are too small.

Obviously matter of opinion. :-) Though I did already say that Async being built into Scheduler might be OK. :-)

I'll probably never want to get deeper

That is a pretty large assumption/leap there... wanting to track the progress of many running tasks seems a pretty common problem in many systems.

It will probably have just one for one network request.

Really? A download on a socket would wake many times as data is received. Every download tool I know shows the status of the download as the progress bar fills up.

Timer.sleep(progressTime)

Yes, with your implantation you'd be forced to make the callback one of the tasks itself... (which seems weird to me at a glance, but perhaps it's not that unusual) I already pointed out the problem with this though. Now you're waiting LONGER than you have to when things are complete, whatever the value of your sleep is.

It does not solve the bug

I was more suggesting it as a workaround that fixes your example. It does indeed fix your example does it not?

Scheduler.add() is meant to use...

This is making assumptions about how things must be used. I'd rather avoid doing that and assume there are different ways a implementor might want to accomplish a thing using the low-level primitives. I'm not sure how that is an argument against addAndStart though... I can easily imagine both cases:

  • I want to schedule a task to run later
  • I want to schedule a task and start it immediately

but I see a lot of hard. You can ruin the scheduler completely with that.

How? I'm not sure I follow. Are you saying your real objection is this piece of code?

while(true) {
  Scheduler.add(Fiber.current)
  Scheduler.nextScheduled()
}

I'd say this is simply buggy code and just as bad as writing the following and then wondering why you're stuck in an infinite loop.

while(true) {
  // code without a break
}

It should be understood (and documented) that placing the current fiber at the end of the run queue in a loop is going to result in an infinite loop. I don't find that surprising.

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 4, 2021

A simpler way will just to check if we have items in the queue. If yes, runNextScheduled_(). If not, Fiber.suspend(). This is both simpler and more performant. But both of those solutions requires support from the scheduler.

Great idea. This would be another great addition to the current minimal Scheduler. I think we could add both:

  • add(fiber)
  • isEmpty / hasPending

So then it's easy to write:

if (Scheduler.hasPending) Scheduler.add(Fiber.current)
Scheduler.next()

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 4, 2021

HA, I may have changed my mind - I need to noodle more before responding.

I keep coming back to this is the user's responsibility. I get the feeling you think this is bad:

// give other items on the queue a chance to run before we resume
addSelfToScheduler()
Scheduler.runNextScheduled_()

Really that's just a yield... (a very, very common need). SO common that it's built into Wren with Fiber.yield, but that only works for a single parent single child.

So this pattern is super useful. It's only bad if you have an infinite loop. Your proposal of allowing processes to check what the scheduler is doing internally is mixing concerns. If my code wants to yield (allowing other things to run) it should simple yield. It shouldn't need to FIRST check with the scheduler and then change it's behavior based the scheduler state. That is unnecessary complexity.

We should find a way that avoids the NEED to do this.

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 4, 2021

I think feel like partially we're talking about different things here and that is complicating the whole discussion. I want to build some useful lower-level primitives (yes it is possible to shoot yourself in the foot, just like with Fibers) and you want to build useful higher level primitives.

There is room for both, it's not either or. And it matters not one bit to the users at the top-most layer.

You can build the higher level stuff on top of the lower-level. You may think the middle layers aren't necessary, I think they add value... we may just have to agree to disagree on that point. :-)

  • Being able to reschedule yourself onto the run queue is a useful thing to do. (this is all yield is really)
  • Doing it incorrectly and getting hung in an infinite loop is a bad thing.

I'd think perhaps scheduler could try and protect people from doing ridiculous things, sure, BUT since we don't have Scheduler.yield then adding yourself and calling nextScheduled is a perfectly reasonable thing to do (to yield to others)... perhaps we just need to wrap that pattern so users don't need to build it on their own:

// safe
Scheduler.yield() 

// -potentially- unsafe
Scheduler.add(Fiber.current)
Scheduler.nextScheduled()

You might suggest that Timer.sleep(1) (or perhaps 0 even works) is a reasonable way to yield to others also and I wouldn't necessarily disagree - but I just think it should be wrapped in a nicer API. :-)

@ChayimFriedman2
Copy link

It's equally easy for just a user. We both have the exactly same API. Task.await() The end user who only cares about the highest level abstraction only need focus on the highest level abstraction.

Right. I was too focused on the details, sorry 😺

That provides no way for users to track tasks as they progress or update the UI, etc... as I already pointed out.

It does, as I already pointed out.

There are two use cases (just off the top of my head) here:

  • do NOTHING until all tasks complete
  • do something while all tasks are running (show progress, etc)

The code you showed only does one thing, on purpose. The code I showed easily allows either (by adding a single callback). What I'm suggesting is more flexible - because I want to give implementers choices.

Ditto.

wanting to track the progress of many running tasks seems a pretty common problem in many systems.

Ditto.

I'll probably never want to get deeper

That is a pretty large assumption/leap there...

Try to show a case where it doesn't hold.

Really? A download on a socket would wake many times as data is received. Every download tool I know shows the status of the download as the progress bar fills up.

I meant higher-level protocol, like HTTP. Per data progress is not something that cannot be done, there's a whole bag of patterns in the js world.

Yes, with your implantation you'd be forced to make the callback one of the tasks itself... (which seems weird to me at a glance, but perhaps it's not that unusual)

Weird? I find it pretty natural. You have two tasks: making a network request and reporting progress. Separating them makes a lot of sense.

I already pointed out the problem with this though. Now you're waiting LONGER than you have to when things are complete, whatever the value of your sleep is.

No you don't. This code can be left as-is with cancellation, or cancel by hand pretty easily.

I was more suggesting it as a workaround that fixes your example. It does indeed fix your example does it not?

If you use addAndStart(). Not to mention that a little change will invalidate it:

var task = Task.run {
  Timer.sleep(1000)
  System.print("Task")
}
Scheduler.add { System.print("Non-task") } // `add()` cannot be replaced with `addAndStart()` anymore
System.print("After `add()`")
Task.await([task])

This is making assumptions about how things must be used. I'd rather avoid doing that and assume there are different ways a implementor might want to accomplish a thing using the low-level primitives. I'm not sure how that is an argument against addAndStart though... I can easily imagine both cases:

  • I want to schedule a task to run later
  • I want to schedule a task and start it immediately

I'm sorry but I can't. Do you have some more concrete example? I can easily imagine both cases:

  • I want to schedule a task to run later.
  • I want to schedule many tasks and wait for them to finish.

These needs are pretty much covered by the new APIs.

How? I'm not sure I follow. Are you saying your real objection is this piece of code?

while(true) {
  Scheduler.add(Fiber.current)
  Scheduler.nextScheduled()
}

I'd say this is simply buggy code and just as bad as writing the following and then wondering why you're stuck in an infinite loop.

while(true) {
  // code without a break
}

It should be understood (and documented) that placing the current fiber at the end of the run queue in a loop is going to result in an infinite loop. I don't find that surprising.

Yes and no.

We seem to disagree about the most basic question: How much low-level does the API need to be?

Let me bring an example. Do you think List should expose a pointer to its start (to Wren, not to C)?

This is definitely something that a lot of abstractions can be built on top of. Still it is an implementation detail and I (hope) you don't want to expose it.

On the other hand, it's all about the language. I think we'll both agree that C++'s std::vector and Rust's alloc::vec::Vec should expose a pointer, because they're low-level languages.

The scheduler is a low level component. Messing with it is not something to be done. Just like you can't, and shouldn't, interrupt the event loop in JS.

Great idea. This would be another great addition to the current minimal Scheduler. I think we could add both:

  • add(fiber)
  • isEmpty / hasPending

So then it's easy to write:

if (Scheduler.hasPending) Scheduler.add(Fiber.current)
Scheduler.next()

It goes again to exposing a pointer to the contents of list. Why not just expose __scheduled? You're leaking the abstractions.

@ChayimFriedman2
Copy link

I need to noodle more before responding.

I needed to read this comment before responding! 😶

Scheduler.yield() is a welcomed addition, along with all() and any(). Maybe we should split this issue.

Timer.sleep(0) will do (and in fact, setTimeout(fn, 0) is a pretty common pattern in js, although the standard does not promise what this will do), although it does not have exactly the same effect as yield(): the former will wait for libuv's tick, but the later will execute immediately. What's better? I don't know 😃

@joshgoebel
Copy link
Contributor Author

This code can be left as-is with cancellation, or cancel by hand pretty easily.

Well, sure if you're imagining we're also adding cancellation. :-) Then you'd just cancel the "watching" thread vs having to wait for it...

Weird? I find it pretty natural.

I think I'm thinking a bit more of "supervision" of the running processes.. where you want the "parent" in child... you don't want to have one fo the children babysitting the others. What if that thread dies or errors? (but I supposed that's cheating since I said we weren't talking about errors). That's why it initially truck me as odd... but I guess updating UI really has nothing to do with supervisions, those are likely separate concerns since the UI could ALSO die and need to be restarted, etc...

Not to mention that a little change will invalidate it:

But by design. It's all about what order you WANT the task queued (in the current scheduler)

I'm sorry but I can't. Do you have some more concrete example?

I feel like this distinction only exists at all here because we're not preemptive. A thread runs until it yields... so run order is HUGE. I think you're imagining a more complex scheduler where this doesn't matter (like most complex schedulers) and that would be good in some ways. I'm imagining the limits of the current scheduler...

Try to show a case where it doesn't hold.

I've learned that just because I can't think of a thing doesn't make it not so. I wasn't saying you are wrong, only that I often try to avoid that type of thinking when it can be avoided.

... List should expose a pointer to its start (to Wren, not to C)? ... it is an implementation detail and I (hope) you don't want to expose it. ... it's all about the language. ... C++ ... Rust ... because they're low-level languages.

The scheduler is a low level component. Messing with it is not something to be done.

I feel like you're mixing different things here. And it all depends on HOW we're thinking about these things. I don't think "what kind of language" matters so much. There are higher level abstractions in C++ and lower-level ones, same for Wren. You can build and use both in many languages (obviously the language determines HOW close to the hardware you get). Scheduler is lower-level (cause it's so minimal) but it's not the lowest level (in Wren). Fibers are. Wren code shouldn't try or expect to mess with Fiber internals, but the Scheduler is (in some ways) just Wren code.

I just want to be able to work with the Scheduler to build more complex things, not "mess with it" per se... there should be an easy generic way to say to the scheduler "run other things, then wake me later"... without resorting to a fixed time loop.

Timer.sleep(0) will do

I wasn't sure if the 0 might break or have weird behavior on the C/UV side because I haven't tested it.

(and in fact, setTimeout(fn, 0) is a pretty common pattern in js

I'm aware. :-) It's a common pattern in other places too. In coop multitasking yield often is king.

although it does not have exactly the same effect as yield(): the former will wait for libuv's tick, but the later will execute immediately. What's better? I don't know

Actually yield might be the wrong name them... since there are two different things here (which bugs me too):

  • yield to UV run queue
  • yield to the Wren run queue

Most often I would say you want to yield all the way down to UV to allow it's callbacks to fire as soon as they can etc... ie in a highly concurrent system doing many different things the long the Wren VM holds the CPU without throwing back to UV is bad.

@joshgoebel
Copy link
Contributor Author

The scheduler is a low level component. Messing with it is not something to be done.

Scheduler.yield() is a welcomed addition, along with all() and any().

This feels inconsistent to how I'm thinking about it. You say Scheduler is low level while wanting to add all these higher level concepts to it (all, any, dependencies between threads). To me I want to (at least initially) leave it lower-level and just add a few tiny things so that we can build things like Async, promises, dependencies, supervision trees all on top of it - without baking even more assumptions into Scheduler.

If possible (and not difficult) it would be nice if people could build different higher level Schedulers. See what works, see what doesn't. Find out what level of complexity feels correct for Wren Core, vs the CLI, vs external outside libraries.

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 4, 2021

There may be another philosophical difference here (which we're discussing in other threads). I think (within reason) the more things someone can accomplish without recompiling the CLI is "good". IE, the CLI in a perfect world just "runs Wren" and includes some useful libraries.

The more it becomes a "complex runtime environment with many assumptions already baked in" the worse in some ways. Wren doesn't have a Scheduler... so I feel like (again in a perfect world) that a scheduler should just be another library I could load into the CLI... those libraries (by necessity) building on top of C + Wren's lower-lever Fiber primitives.

What I like about the current Scheduler is that it almost allows you to build these very complex higher level abstractions on top of it. I think it'd be better if we removed the "almost" part.

@ChayimFriedman2
Copy link

Not to mention that a little change will invalidate it:

But by design. It's all about what order you WANT the task queued (in the current scheduler)

I just wanted to say that not always you cannot always replace add() with addAndStart().

I feel like this distinction only exists at all here because we're not preemptive. A thread runs until it yields... so run order is HUGE. I think you're imagining a more complex scheduler where this doesn't matter (like most complex schedulers) and that would be good in some ways. I'm imagining the limits of the current scheduler...

I don't think so.

I've learned that just because I can't think of a thing doesn't make it not so. I wasn't saying you are wrong, only that I often try to avoid that type of thinking when it can be avoided.

But now it can't be avoided, because I doubt it 😃

I feel like you're mixing different things here. And it all depends on HOW we're thinking about these things. I don't think "what kind of language" matters so much. There are higher level abstractions in C++ and lower-level ones, same for Wren. You can build and use both in many languages (obviously the language determines HOW close to the hardware you get). Scheduler is lower-level (cause it's so minimal) but it's not the lowest level (in Wren). Fibers are. Wren code shouldn't try or expect to mess with Fiber internals, but the Scheduler is (in some ways) just Wren code.

I just want to be able to work with the Scheduler to build more complex things, not "mess with it" per se... there should be an easy generic way to say to the scheduler "run other things, then wake me later"... without resorting to a fixed time loop.

Every language has abstractions and low level stuff, but as higher as the language, the less low level stuff it exposes. Is the fiber low level? No. Fibers are not used only to create schedulers: they're not even always used in the context of asynchronous code. Are fibers low-level when we're talking about async stuff in the CLI? Perhaps. But I don't think they are: they are just part. It's somewhat comparable to Fn and map(). One is whole enough on its own, and the second needs the first, but that doesn't make it low-level. The scheduler works with fibers.

Is the scheduler low level? Neither so. It can be used to build higher-level stuff (like progressing and so), but it is also high level enough for some stuff. "Low level" is a relative term, and the scheduler is not "low level" as in "not useful on its own, only as a building block for other abstractions". I just want it to perform even more things.

I wasn't sure if the 0 might break or have weird behavior on the C/UV side because I haven't tested it.

http://docs.libuv.org/en/v1.x/timer.html#c.uv_timer_start:

If timeout is zero, the callback fires on the next event loop iteration.

This feels inconsistent to how I'm thinking about it. You say Scheduler is low level while wanting to add all these higher level concepts to it (all, any, dependencies between threads). To me I want to (at least initially) leave it lower-level and just add a few tiny things so that we can build things like Async, promises, dependencies, supervision trees all on top of it - without baking even more assumptions into Scheduler.

If possible (and not difficult) it would be nice if people could build different higher level Schedulers. See what works, see what doesn't. Find out what level of complexity feels correct for Wren Core, vs the CLI, vs external outside libraries.

Like I said, to me, the scheduler is not low level. Another option is to bake them into Fiber, and make them returns fiber instead of execute immediately (like JS's Promise). Of course, we cannot change Fiber, so this is not an option, and also, I think it's more appropriate to explicit async programming (stackless coroutines) and less to implicit (stackful coroutines).

There may be another philosophical difference here (which we're discussing in other threads). I think (within reason) the more things someone can accomplish without recompiling the CLI is "good". IE, the CLI in a perfect world just "runs Wren" and includes some useful libraries.

The more it becomes a "complex runtime environment with many assumptions already baked in" the worse in some ways. Wren doesn't have a Scheduler... so I feel like (again in a perfect world) that a scheduler should just be another library I could load into the CLI... those libraries (by necessity) building on top of C + Wren's lower-lever Fiber primitives.

I would completely agree if only it was possible.

The scheduler is deeply integrated with the rest of the CLI, because of the fact that it uses libuv and asynchronouty. Decoupling it is not possible.

Now, the only question is if we should open its internals. I think we should not, and instead build meaningful abstractions on top of them.

What I like about the current Scheduler is that it almost allows you to build these very complex higher level abstractions on top of it. I think it'd be better if we removed the "almost" part.

Why I like about the current scheduler is that it almost allows you to build an async app. I think it'd be better if we removed the "almost" part 😄

I don't refuse a yield(). If you still want to build a complex abstraction, you can. But I don't think that's what most users care about. It's something you plug a library for and forget you did.

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 4, 2021

"Low level" is a relative term

Let's agree to strongly agree on that LOL.

Now, the only question is if we should open its internals. I think we should not, and instead build meaningful abstractions on top of them.

I think we may disagree on what "open it's internals" means... taking a thing is already possible and just making it less dirty... is that truly "opening the internals"...? I mean maybe you're technically right but it doesn't feel like "opening the internals" much to me if it only allows existing things to be done more cleanly.

But I don't think that's what most users care about. It's something you plug a library for and forget you did.

Someone has to build the libraries for users to plugin in then not care about. ;-)

@joshgoebel
Copy link
Contributor Author

joshgoebel commented May 4, 2021

Why I like about the current scheduler is that it almost allows you to build an async app.

Wait, why does it almost again? I'm pretty sure the all and some code you showed CAN be fairly cleanly built on top of Scheduler. (without nested fibers within fibers for no reason) I think you just wish it was included?

Meanwhile my example requires getting dirty by running an intermediate fiber (and function) for no other reason than to switch to yet another fiber... ie, your solution can be built without compromises - but mine can't.

I think both approaches should be possible.

@ChayimFriedman2
Copy link

Wait, why does it almost again? I'm pretty sure the all and some code you showed CAN be fairly cleanly built on top of Scheduler. (without nested fibers within fibers for no reason) I think you just wish it was included?

First, no, because we're not exposing runNextScheduled_(), and I don't think we should either.

Second, even if we could, we still cannot build an app without them. We can include them in the app, but that means the CLI is not complete.

Meanwhile my example requires getting dirty by running an intermediate fiber (and function) for no other reason than to switch to yet another fiber... ie, your solution can be built without compromises - but mine can't.

I don't understand.

@joshgoebel
Copy link
Contributor Author

First, no, because we're not exposing runNextScheduled_(), and I don't think we should either.

Well I think you mean "[exposing] by name" but if we add yield() you're getting indirect access, sorta... although it's not exactly the same, since all fibers would get a chance to run not just the next one... one could argue for a yieldToOne() that had that behavior but I think that would just indicate someone had a poorly designed coop system.

So if we add yield() is there any reason not just to just have it call Timer.sleep(0)?

I don't understand.

I was only pointing out you have to jump thru ugly hoops (IMHO) to put a fiber directly onto the queue (put the fiber within a fiber within a function)... where-as your solution has no ugly hoops - merely private API usage.

@ChayimFriedman2
Copy link

Well I think you mean "[exposing] by name" but if we add yield() you're getting indirect access, sorta... although it's not exactly the same, since all fibers would get a chance to run not just the next one... one could argue for a yieldToOne() that had that behavior but I think that would just indicate someone had a poorly designed coop system.

runNextScheduled_() will run all of the scheduler fibers, too. The only difference is that it won't scheduler the current fiber after, and that's exactly what I need.

I was only pointing out you have to jump thru ugly hoops (IMHO) to put a fiber directly onto the queue (put the fiber within a fiber within a function)... where-as your solution has no ugly hoops - merely private API usage.

Private API usage is way worse than ugly hacks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants