3

I am trying to use async / await to do long computations on JavaScript on a browser. Should the code be changed or any improvements be done? It seems using promises may make it complicated. I also have two related questions at the end of the question.

It was doing 60000! (factorial), and the answer is too long to show on screen, so the number of digits of the answer in binary is being shown. (showing the number of digits of the answer in decimal took too long for the conversion).

The iterative version is:

(some note: the computation won't start until 3 seconds later, to show how the UI looks like without the computation).

(function() {
      let startTimeOfProgram = performance.now(),
      timer = 3, 
      element = $("#status-display-content"),
      elementTimer = $("#timer");

      setInterval(() => { elementTimer.html((Math.round((performance.now() - startTimeOfProgram) / 10) / 100).toFixed(2)) }, 33);

      function updateState() {
        element.html(timer--).css({ transition: "none" }).css({ opacity: 1 });
        if (timer < 0) timer = 3;

        // need to be next cycle because cannot change
        // transition and have it animated all in the same cycle
        setTimeout(function() {
          element.css({ transition: "opacity 1s" }).css({ opacity: 0 }).on("transitionend", () => { updateState(); element.off("transitionend"); });
        }, 0);
      }

      updateState();


      function binaryLength(n) {
        return n.toString(2).length;
      }

      const occasionalSleeper = (function() {
        let lastSleepingTime = performance.now();

        return function() {
          if (performance.now() - lastSleepingTime > 33) {
            lastSleepingTime = performance.now();
            return new Promise(resolve => setTimeout(resolve, 0));
          } else {
            return Promise.resolve();
          }
        }
      }());

      async function asyncBigIntFactorial(n) {       
        let start = performance.now();

        n = BigInt(n);
        if (n <= 1n) return 1n;

        let result = 1n;
        for (let i = 2n; i <= n; i++) {
          await occasionalSleeper();
          result *= i;
        }
        console.log("Time taken", (performance.now() - start) / 1000);
        return result;
      }

      setTimeout(function() {
        let startTimeOfComputation = performance.now();
        asyncBigIntFactorial(60000).then(result => {
          $("#calculation-result")
          .html(`Number of digits of answer in binary: ${binaryLength(result)}<br>Time it took: ${Math.round((performance.now() - startTimeOfComputation) / 10) / 100} seconds`);
        });
      }, 3000);

    }());
#status-display {
      background-color: #000;
      color: green;
      font: 111px Arial, sans-serif;
      width: 150px;
      height: 150px;
      border-radius: 21%;
      text-align: center;
      line-height: 150px;
    }

    #timer {
      width: 150px;
      text-align: center;
      font: 15px Arial, sans-serif;
    }

    #status-display-content {
      transition: opacity 1s
    }

    #calculation-result {
      margin-left: .3em;
    }
    #left-panel, #calculation-result {
      display: inline-block;
    }
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div id="left-panel">
  <div id="status-display">
    <div id="status-display-content">
    </div>
  </div>

  <div id="timer"></div>
 </div>
 <div id="calculation-result"></div>

It is also on: http://jsfiddle.net/rxdpbvku/

It can be contrasted with the version with the await occasionalSleeper() commented out, with the UI not responsive during the computation: http://jsfiddle.net/fhL2gqpn/

Basically, it is using an occasionalSleeper() and call await occasionalSleeper() during the computation.

const occasionalSleeper = (function() {
  let lastSleepingTime = performance.now();

  return function() {
    if (performance.now() - lastSleepingTime > 33) {
      lastSleepingTime = performance.now();
      return new Promise(resolve => setTimeout(resolve, 0));
    } else {
      return Promise.resolve();
    }
  }
}());

and the calculation part is:

async function asyncBigIntFactorial(n) {       
  let start = performance.now();

  n = BigInt(n);
  if (n <= 1n) return 1n;

  let result = 1n;
  for (let i = 2n; i <= n; i++) {
    await occasionalSleeper();
    result *= i;
  }
  console.log("Time taken", (performance.now() - start) / 1000);
  return result;
}

The recursive version is:

(function() {
      let startTimeOfProgram = performance.now(),
      timer = 3, 
      element = $("#status-display-content"),
      elementTimer = $("#timer");

      setInterval(() => { elementTimer.html((Math.round((performance.now() - startTimeOfProgram) / 10) / 100).toFixed(2)) }, 33);

      function updateState() {
        element.html(timer--).css({ transition: "none" }).css({ opacity: 1 });
        if (timer < 0) timer = 3;

        // need to be next cycle because cannot change
        // transition and have it animated all in the same cycle
        setTimeout(function() {
          element.css({ transition: "opacity 1s" }).css({ opacity: 0 }).on("transitionend", () => { updateState(); element.off("transitionend"); });
        }, 0);
      }

      updateState();


      function binaryLength(n) {
        return n.toString(2).length;
      }

      const occasionalSleeper = (function() {
        let lastSleepingTime = performance.now();

        return function() {
          if (performance.now() - lastSleepingTime > 33) {
            lastSleepingTime = performance.now();
            return new Promise(resolve => setTimeout(resolve, 0));
          } else {
            return Promise.resolve();
          }
        }
      }());

      async function asyncBigIntFactorial(n) {       
        let start = performance.now();

        async function factorialHelper(n) {
          n = BigInt(n);
          if (n <= 1n) return 1n;

          await occasionalSleeper();

          let simplerAnswer = factorialHelper(n - 1n);

          return simplerAnswer.then(async function(a) { 
            await occasionalSleeper(); 
            return n * a;
          });
        }

        let result = factorialHelper(n);
        console.log("Time taken", (performance.now() - start) / 1000);        
        return result;
      }

      setTimeout(function() {
        let startTimeOfComputation = performance.now();
        asyncBigIntFactorial(60000).then(result => {
          $("#calculation-result")
          .html(`Number of digits of answer in binary: ${binaryLength(result)}<br>Time it took: ${Math.round((performance.now() - startTimeOfComputation) / 10) / 100} seconds`);
        });
      }, 3000);

    }());
#status-display {
      background-color: #000;
      color: green;
      font: 111px Arial, sans-serif;
      width: 150px;
      height: 150px;
      border-radius: 21%;
      text-align: center;
      line-height: 150px;
    }

    #timer {
      width: 150px;
      text-align: center;
      font: 15px Arial, sans-serif;
    }

    #status-display-content {
      transition: opacity 1s
    }

    #calculation-result {
      margin-left: .3em;
    }
    
    #left-panel, #calculation-result {
      display: inline-block;
    }
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div id="left-panel">
<div id="status-display">
    <div id="status-display-content">
    </div>
  </div>

  <div id="timer"></div>
</div>  
<div id="calculation-result"></div>

It is also on: http://jsfiddle.net/rxdpbvku/

The recursive version reported finishing almost instantaneously, because at that time only the promise was created.

Some observations:

If inside of:

          return simplerAnswer.then(async function(a) { 
            await occasionalSleeper(); 
            return n * a;
          });

if the await occasionalSleeper() is commented out (or if that function is not an async function), it seems the final unwrapping of the promises will cause it to pause the UI. (I think it is the final unwrapping of 60000 promises). JSFiddle: http://jsfiddle.net/y0827r16/

Another observation is, if it is a real recursion, calling itself 60,000 times deep will cause a stack overflow. But using async / await, it won't. Why is that? Is it because it is like a table of resolving or unwrapping promises, so a table of 60,000 entries is not a problem at all.

I also have a question is that if inside any async function, if such occasionalSleeper() is added automatically by the async standard, then won't it make the programmer not have to worry where to add them?

nonopolarity
  • 146,324
  • 131
  • 460
  • 740
  • 4
    Given that you have working code and are looking for improvements / critics, I'd suggest asking on [CodeReview.SE](//codereview.stackexchange.com/tour). [Their requirements](//codereview.stackexchange.com/help/on-topic) are specifically tailored to your kind of question. – Kyll Dec 30 '19 at 12:36
  • 4
    You might want to look into Web Workers. – AKX Dec 30 '19 at 12:43

1 Answers1

2

As stated in comments, for long-lasting calculations, you could use Web Workers. That way you can keep your browser interactive, while not having to introduce setTimeout "hops" at regular times.

To make use of web workers as easily as possible, I would suggest using this generic "plugin" code, which I suggested earlier in this answer:

Function.prototype.callAsWorker = function (...args) {
    return new Promise( (resolve, reject) => {
        const code = `self.onmessage = e => self.postMessage((${this.toString()}).call(...e.data));`,
            blob = new Blob([code], { type: "text/javascript" }),
            worker = new Worker(window.URL.createObjectURL(blob));
        worker.onmessage = e => (resolve(e.data), worker.terminate());
        worker.onerror = e => (reject(e.message), worker.terminate());
        worker.postMessage(args);
    });
}

Now you can let a web worker execute any pure function with the following syntax:

myPureAdditionFunction.callAsWorker(null, 1, 2)
                      .then((sum) => console.log("1+2=" + sum)); 

As said, this takes away the need to inject the execution with something likeoccasionalSleeper().

Your core function can just be:

function bigIntFactorial(n) {
    n = BigInt(n);
    let result = 1n;
    for (let i = 2n; i <= n; i++) result *= i;
    return result;
}

And the call:

bigIntFactorial.callAsWorker(null, 60000).then(/* handler */);

I adapted your snippet to use this method. I also removed the 3 second run-in time, as with a web worker you don't have to worry about the DOM and the paint events.

let startTimeOfProgram = performance.now(),
    timer = 3, 
    element = $("#status-display-content"),
    elementTimer = $("#timer");

let stopWatch = setInterval(() => { 
    elementTimer.html((Math.round((performance.now() - startTimeOfProgram) / 10) / 100).toFixed(2)) 
}, 33);

function updateState() {
    element.html(timer--).css({ transition: "none" })
                         .css({ opacity: 1 });
    if (timer < 0) timer = 3;

    // need to be next cycle because cannot change
    // transition and have it animated all in the same cycle
    setTimeout(function() {
        element.css({ transition: "opacity 1s" })
               .css({ opacity: 0 })
               .on("transitionend", () => { 
                    if (stopWatch) updateState(); 
                    element.off("transitionend"); 
                });
    });
}

updateState();

const binaryLength = n => n.toString(2).length;

Function.prototype.callAsWorker = function (...args) {
    return new Promise( (resolve, reject) => {
        const code = `self.onmessage = e => self.postMessage((${this}).call(...e.data));`,
            blob = new Blob([code], { type: "text/javascript" }),
            worker = new Worker(window.URL.createObjectURL(blob));
        worker.onmessage = e => (resolve(e.data), worker.terminate());
        worker.onerror = e => (reject(e.message), worker.terminate());
        worker.postMessage(args);
    });
}

function bigIntFactorial(n) {
    n = BigInt(n);
    let result = 1n;
    for (let i = 2n; i <= n; i++) result *= i;
    return result;
}

bigIntFactorial.callAsWorker(null, 60000).then(result => {
    clearInterval(stopWatch);
    stopWatch = null;
    $("#calculation-result")
        .html(`Number of digits of answer in binary: ${binaryLength(result)}<br>
               Time it took: ${Math.round((performance.now() - startTimeOfProgram) / 10) / 100} seconds`);
});
#status-display {
    background-color: #000;
    color: green;
    font: 111px Arial, sans-serif;
    width: 150px;
    height: 150px;
    border-radius: 21%;
    text-align: center;
    line-height: 150px;
}

#timer {
  width: 150px;
  text-align: center;
  font: 15px Arial, sans-serif;
}

#status-display-content {
  transition: opacity 1s
}

#calculation-result {
  margin-left: .3em;
}

#left-panel, #calculation-result {
  display: inline-block;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<div id="left-panel">
  <div id="status-display">
    <div id="status-display-content">
    </div>
  </div>

  <div id="timer"></div>
</div>
<div id="calculation-result"></div>

Your Questions

Concerning your recursive solution, you wrote:

if the await occasionalSleeper() is commented out (or if that function is not an async function), it seems the final unwrapping of the promises will cause it to pause the UI.

Indeed, that is because you have a long chain of immediately resolving promises, without any setTimeout. Your async callback has no await, so it returns an immediately resolving promise. A promise resolution puts a job in the Promise Job Queue, which is serviced right after the call stack is empty, but still as part of the current task. So the browser's event queue is not serviced while there is something pending in the Promise Job Queue. As a result, such a long chain of resolving promises blocks the UI.

If it is a real recursion, calling itself 60,000 times deep will cause a stack overflow. But using async / await, it won't. Why is that?

This is because you don't really have recursion going on in a way where a current call is waiting for the recursive one to finish. Not here. You have the recursive call of the async happening after an await. This means that the recursive call will return when it processes the await. It returns with a promise, and the current execution context can continue. Only when it reaches the end, the await of the nested call, gets a chance to get the execution back by an event in the Promise Job Queue. And so you see there is not really a stack of function calls here. It is a chain, driven by a FIFO queue... the Promise Job queue.

If inside any async function, if such occasionalSleeper() is added automatically by the async standard, then won't it make the programmer not have to worry where to add them?

That might be a tempting idea, but be aware that EcmaScript is not covering the agent part; it focusses on the core language, which includes promises, async and await, but excludes the exact definition of the (other) event queues. That is covered in other specifications, such as the specifications drafted by the WHATWG.

Altering the meaning of async to include aspects of non-blocking UI go beyond the scope of the language.

Anyway, with Web Workers (not part of core EcmaScript, but specified in the HTML Living Standard), there is a satisfying solution.

trincot
  • 317,000
  • 35
  • 244
  • 286