How to run event loop when doing nested/recursive computations?
Asked Answered
A

3

6

The usual examples of how to break a computation and release using setTimeout() seem to rely on having a shallow (1-deep) call stack. But what about when you are doing a deeply nested or mutually-recursive computation (like a tree search) and you have plenty of context in the stack?

It would be ideal if JavaScript had a function that would encapsulate the 'current continuation' (that is: the current call-stack), put it on the Event Queue and return/throw/call back to the top-level event loop. (so other events would run, and then the computation would be restarted right where it left off). I'm looking for an easy way for a function to voluntarily 'yield' control, let events catch up, and then return control to where we left off. Preferably without re-writing every function in the call-chain.

But I can't find anything that does this...

  • As a retired schemer, I'm expecting something like call/cc, but not finding it.
  • setTimeout() will return control [but only 1 level up], and restart some other computation (but not the implicit current-continuation, unless we commit the entire application to CPS...)
  • 'yield' will box the continuation of the current function/stack-frame, so it can be restarted, but yield only returns one level up. (yield is like: return/cc vs call/cc)
  • 'throw' can throw way up the stack, but has no facility to restart the computation from the point of the throw (that I know of; need something like 'throw/cc')

I've built a semi-solution using 'yield', but it is klutzy, requiring every function on the stack to (a) be declared as 'function*' and (b) include boilerplate code around each call down to the next function [to propagate a yield and restart with next()]

Q: Is there a way to achieve this in JavaScript without instrumenting all the functions on the call chain?

Autobiographical answered 22/4, 2022 at 4:6 Comment(5)
Thanks to the various 'editors'; note that the Question is not about Generators; the question is about running the event loop. Generators/yield are involved only as a potential answer. But I'm unclear on the politics of editing, so I'll leave Generators in the title for now.Autobiographical
Since you're familiar with Scheme I can confidently say that the real answer is to use CPS. In modern js you can probably use syntactic sugar around CPS by Promisifying your functions and build some of your logic around async/await and Promise.all()Amosamount
So the short answer is "no"... that is: you can use async/await, but that DOES require one to annotate every function on the [recursive] call stack (with 'async') and specially invoke with 'await'. it makes extra effort to async-ify after the code is otherwise done and working.Autobiographical
Yes. The problem is how people end up writing code that is "done and working" that is inflexible to asynchronous code paths being introduced. This is a kind of code smell: it signals a developer not used to programming in asynchronous environments like Javascript or Scheme or indeed GUI programming in C/C++ or GUI programming in any language.Amosamount
Ok. In my case, "done and working" means the algo is correct (a tree search), completely met the given req'ts. the need to async-ify it came solely from real-time/UI/CHI interests when some instances get larger. So it would have been nice to have an 'orthogonal' way to augment the code to run in parallel with the UI. A clean "separation of concerns" as it were. One could likewise reflect your comment back to the design of javascript: it signals that the developers were not prepared to design an runtime environment suitable for flexible combination of UI presentation and computation. :)Autobiographical
A
4

I'll add an alternative solution that you seem to not have considered: Promises. Or more specifically the syntax sugar for handling promises: async/await.

Using a Promise it is simple to implement your allowEventLoop() function:

function allowEventLoop () {
    return new Promise((ok,fail) => setTimeout(ok,0));
}

Now whenever you need to suspend the current computation and run the event loop you just need to call:

await allowEventLoop();

Here's an example of a simple recursive descent parser using the above function (note: code in Js but it should be trivial to do this in Ts):

async function drawTree(node, indent) {
    if (!indent) indent = 0;

    let tree = `${'\t'.repeat(indent)}${node.name}\n`;

    await allowEventLoop();

    if (node.children) {
        for (let child of node.children) {
            tree += await drawTree(child, indent+1);
        }
    }

    return tree;
}

As you can see, very little is changed in the logic of the recursive function. It looks almost exactly the same as the plain synchronous version. The main difference is that your function now returns a Promise of the result.

When using async/await you basically skip the call stack. Instead what you are really doing is using a chain of .then() calls. So in reality the call stack is still 1-level deep but you are dynamically constructing a complicated .then() chain. In practice it feels like the usual call stack based recursion.

The queue of functions to execute is handled invisibly by Promises - which is essentially a design pattern for handling Continuation-Passing-Style (CPS) code. This is similar to how the call stack manages the queue of functions to return. This is why it feels the same.

Amosamount answered 14/5, 2022 at 3:31 Comment(5)
Excellent. I had never studied what the 'async/await' sugar actually did. Now I see that it tells the JS interpreter to suspend until the promise resolves, so that is exactly the right thing!Autobiographical
I confirmed that it works: replace 'function*' with 'async' and replace the 'yieldR' logic with 'await' and replacing the voluntary 'yield' with 'await allowEventLoop()' And performance is ~10% faster (with no chain of .next()) Thanks for your contribution!Autobiographical
Yup. Glad to have helped. The slight improvement is probably due to most of the logic having been implemented in C internallyAmosamount
No. Await is a bit more "magical" than a simple suspend. It's not the interpreter (at least not normally) that process await but the compiler. It recompiles the linear code into CPS bytecode/assembly. This way JS needs zero changes to the interpreter or the behavior of the language itself. I've seen a few attempts in the JVM world to implement similar async/await mechanism with some limited success. The problem Java has is like any language that is not Javascript or Tcl - it is not pervasively asynchronous. Thus your program will encounter big chunks of synchronous code which slows it downAmosamount
Well... years ago, I did a whole sync/async API for Java (basically: 'Future' before there was Future) So I know how that goes. Anyway thanks for confirming that JS just keeps to the base language/interpreter, seems like that should maybe change some day... For those following along at home, I deleted a comment about how async/await is compiled into Promises and a Generator/_next, that is likely equivalent to what I originally wrote. but async/await is better 'sugar' and C-coded!Autobiographical
A
2

We want to enable event processing during long-running, mutually recursive function calls. (for example, a recursive tree search) After a certain depth or time, the search wants to voluntarily suspend execution to allow the top level Event Loop to run (handle mouse/key events, repaint graphics, etc)

The ideal would be a system-level function to runEventLoop() which 'yield' the current computation, put its own continuation on the event queue, and throw control to the system EventLoop.

It seems that Javascript provides only partial solutions for this:

  • 'setTimeout()' will put a function on the event queue [but not the current continuation]
  • 'yield' will suspend the current continuation, but not put it on the event queue. And 'yield' returns a value to the Generator's caller one level up the call stack. So that caller must already have the 'continuation' in form of the Generator.

We also note that although an uncaught 'throw' will return control to the top-level, there is no way (TIKO) in JS to recover & restart the 'thrown' computation. (from top level through the mutually-recursive calls to the voluntary 'yield')

So: to return control from the voluntary yield, up through the nested or mutually-recursive functions, all the way to the system EventLoop, we do 3 things:

  1. Each function [caller & called] must be declared as function* (so it can yield)
  2. Each function [caller] must test whether its [called] descendant suspended, and if so, yield itself to propagate the 'yield' to the top level:
    let result, genR = calledStarFunction(args);
       while (result = genR.next(), !result.done) yield;
       use (result.value)

Note: #2 cannot usefully be wrapped in a function... because that function would be subject to #1, and the caller of that function is subject to #2

  1. At the top-level, use setTimeout(() => genR.next()) return to the JS EventLoop and then restart the chain of suspended functions.

[before #2 was obvious, I wrote this typescript code, now 'yieldR' is inlined, as shown above]

    /** <yield: void, return: TReturn, yield-in: unknown> */
    export type YieldR<TReturn> = Generator<void, TReturn, unknown>
    /**
     * Top-level function to give control to JS Event Loop, and then restart the stack of suspended functions.
     * 'genR' will restart the first/outermost suspended block, which will have code like *yieldR()
     * that loops to retry/restart the next/inner suspended function.
     * @param genR 
     * @param done 
     */
    export function allowEventLoop<T>(genR: YieldR<T>, done?: (result: T) => void): void  {
      let result = genR.next()
      if (result.done) done && done(result.value)
      else setTimeout(() => allowEventLoop(genR, done))
    }
    /** 
     * Return next result from genR. 
     * If genR returns an actual value, return that value
     * If genR yields<void> then propagate a 'yield' to each yieldR up to allowEventLoop(); 
     * 
     * This shows the canonical form of the code.
     * It's not useful to actually *call* this code since it also returns a Generator,
     * and the calling code must then write a while loop to handle the yield-vs-return!
     */
    export function* yieldR<T extends object> (genR: YieldR<T>, log?:string) {
      let result: IteratorResult<void, T>
      while (result = genR.next(), !result.done) yield
      return result.value
    }

Note: most documented usage of function* are to create a Iterator, a case where 'yield' provides the interesting/useful value, and 'return' signals when done. In this use-case that is inverted: yield gives a signal, but no interesting value, and 'return' supplies the interesting computational value.

Appeal to the JS Gods: Provide a function: runEventLoop() That transparently puts the current continuation (the full stack) on the event loop and returns control directly to the top-level. so all the other callers and the call stack do not need to be aware of the suspend/resume being done at the lower level.

After note: looks like there is a significant performance hit for using Generators like this. After inlining code to reduce nested Generators from 4 to 2, the code ran 10X faster. So maybe CPS or data-flow design may be indicated for complex/time-sensitive apps. (but still, it worked during dev/debug to get the kbd/graphics going)

Another note: Chrome imposes a minimum 'setTimeout' delay of 4ms; so if you compute for 1ms and then yield for 4ms that is slow and may explain the note above. It helps to compute the delta from last yield until Date.now() and yield only when that is greater than [20 -- 200 ms?] (depending on degree of responsiveness you need).

Autobiographical answered 22/4, 2022 at 4:6 Comment(1)
@catgirlkelly There should be a better solution, but unless/until the JS gods provide a builtin such as runEventLoop(), there is this yield solution, or a similar approach using CPS (continuation passing style) which likely evolves into a 'data-flow' design, where you have your own queue of functions [or data-elements] to process. Do some functions, setTimeout, come back and do some more functions... Compared to that, using yield/next is not so bad.Autobiographical
A
0

To reify the alternative (data-flow/function-queue) approach, consider this: To keep the call-stack short, divide the application into tasks (functions that return without recursion). If you would make a recursive call, then instead use: callLater(()=>recursiveTask(arg1, arg2, ...)) and simply return. callLater puts the closure [data and continuation] on the queue where the top-level can process it in turn.

So for a tree search, at layer N, you enqueue tasks to process the nodes at layer N+1, PLUS a task to gather and combine the results, then return. The final task enqueued should return the final result. That 'final' task will likely include something like: if (queue.length > 0) callLater(finalTask) so it puts itself to the end of the queue until all the other subtasks have been computed and have stopped adding tasks to the queue. [or maybe you use some Promises and trigger the finalTask with Promise.all(...)]

The code below also includes a timer in the loop, so as to run a number of tasks until a threshold is exceeded (and the return to the JavaScript Event Loop)

type FUNC<T> = ()=>T
const callQueue: Array<FUNC<any>> = []
function callLater(fun: FUNC<any>) {
  callQueue.push(fun)
}
function topLevel<T>(start: FUNC<T>, done?: (value: T) => void, threshold = 30, ms0 = Date.now()) {
  var dms: number
  while ((dms = Date.now() - ms0) < threshold) {
    let value = start()    // which may invoke callLater() to enqueue more tasks
    if (callQueue.length == 0) return done && done(value)
  }
  setTimeout(() => topLevel(callQueue.shift(), done, threshold))
}
Autobiographical answered 12/5, 2022 at 4:7 Comment(1)
Plan C is to put the whole computationally intensive code block into a web worker... Which is what I'll be doing now/next.Autobiographical

© 2022 - 2024 — McMap. All rights reserved.