2

I've seen many people saying that Promise.all can't achieve parallelism, since node/javascript runs on a single-threaded environment. However, if i, for instance, wrap 5 promises inside a Promise.all, in which every single one of the promises resolves after 3 seconds (a simple setTimeout promise), how come Promise.all resolves all of them in 3 seconds instead of something like 15 seconds (5 x 3 sec each)?

See the example below:

function await3seconds () { 
    return new Promise(function(res) {
        setTimeout(() => res(), 3000)
    })
}

console.time("Promise.all finished in::")

Promise.all([await3seconds(), await3seconds(), await3seconds(), await3seconds(), await3seconds()])
    .then(() => {
        console.timeEnd("Promise.all finished in::")
})

It logs:

Promise.all finished in::: 3.016s

How is this behavior possible without parallelism? Concurrent execution wouldn't be able to proccess all of these promises in 3 seconds either.

  • 2
    Because `setTimeout` doesn't block, it just queues a task after the timeout is done, which takes no real resources to execute – CertainPerformance May 25 '21 at 23:16
  • 2
    First of all, `Promise.all` doesn't *run* anything. It just waits. It's the 5 independent `await3seconds()` calls that don't wait for each other, scheduling their timers at once, what makes things concurrent. – Bergi May 25 '21 at 23:16
  • 2
    Concurrency (multiple timers active at the same time) is not the same thing as (multi-threading) parallelism. – Bergi May 25 '21 at 23:17
  • I don't understand what you mean by "*Concurrent execution wouldn't be able to proccess all of these promises in 3 seconds either.*" – Bergi May 25 '21 at 23:17
  • Your timeout isn't doing anything, it will trigger when the synchronous thread that counts seconds notices your timer is done and then run your code. It’s not parallel, but since computers are fast you barely notice the synchronicity here. Also note that setTimeout does not promise to trigger at exactly that time, just the first moment the cycle runs to check them, which is not exact. – somethinghere May 25 '21 at 23:20
  • @somethinghere Actually there isn't even any second-counting thread, the JS engine uses asynchronous OS timers underneath. – Bergi May 25 '21 at 23:21
  • Maybe using a setTimeout promise wasn't the best example, but i've done the same experiment by wrapping multiple api calls inside a Promise.all, and all the calls were resolved muuuch faster than it did when i made the api calls run sequentially (using a simple for loop). – Raphael Almeida May 25 '21 at 23:26
  • The js engine doesn't do anything, the timeout is managed by the host, which may very well have multiple helper threads, but for this case it's even necessary. When the timeout is over, the host will queue a new task which will itself start a new synchonous JS job. Remember that browsers are not written in js, and even node relies on non-js APIs. – Kaiido May 25 '21 at 23:27
  • 1
    @RaphaelAlmeida No difference between timers and API calls in that regard. Network I/O is asynchronous. It sends the requests all at once, then when the responses arrive the OS notifies the runtime which schedules a task to resolve the respective promise and run its handlers. – Bergi May 25 '21 at 23:30
  • @Bergi Oh, thanks man! Now i get it! So whenever i fire multiple API Calls, the server does all the request/response logic (and the server may very well process all these calls in parallel) and "emit" a signal to the runtime environment whenever the request finishes. Right? – Raphael Almeida May 25 '21 at 23:36
  • @RaphaelAlmeida Yes, the HTTP client doesn't care how the server is implemented. It might even process them sequentially but very fast, and the network latency is the major limiting factor. All what the OS/TCP stack/HTTP client will notice is response packets arriving at the network interface, and then they'll notify the JS engine about it. – Bergi May 25 '21 at 23:38

3 Answers3

8

It's particularly useful to understand what this line of code actually does:

Promise.all([await3seconds(), await3seconds(), await3seconds(), await3seconds(), await3seconds()]).then(...)

That is fundamentally the same as:

const p1 = await3seconds();
const p2 = await3seconds();
const p3 = await3seconds();
const p4 = await3seconds();
const p5 = await3seconds();

Promise.all([p1, p2, p3, p4, p5]).then(...)

What I'm trying to show here is that ALL your functions are executed serially one after the other in the order declared and they have all returned BEFORE Promise.all() is even executed.

So, some conclusions from that:

  1. Promise.all() didn't "run" anything. It accepts an array of promises and it justs monitors all those promises, collects their results in order and notifies you (via .then() or await) when they are all done or tells you when the first one rejects.
  2. Your functions are already executed and have returned a promise BEFORE Promise.all() even runs. So, Promise.all() doesn't determine anything about how those functions run.
  3. If the functions you were calling were blocking, the first would run to completion before the second was even called. Again, this has nothing to do with Promise.all() before the functions are all executed before Promise.all() is even called.
  4. In your particular example, your functions each start a timer and immediately return. So, you essentially start 5 timers within ms of each other that are all set to fire in 3 seconds. setTimeout() is non-blocking. It tells the system to create a timer and gives the system a callback to call when that timer fires and then IMMEDIATELY returns. Sometime later, when the event loop is free, the timer will fire and the callback will get called. So, that's why all the timers are set at once and all fire at about the same time. If you wanted them to each be spread out by 3 seconds apart, you'd have to write this code differently, either to set increasing times for each timer or to not start the 2nd timer until the first one fires and so on.

So, what Promise.all() allows you to do is to monitor multiple asynchronous operations that are, by themselves (independent of Promise.all()) capable of running asynchronously. Nodejs itself, nothing to do with Promise.all(), has the ability to run multiple asynchronous operations in parallel. For example, you can make multiple http requests or make multiple read requests from the file system and nodejs will run those in parallel. They will all be "in flight" at the same time.

So, Promise.all() isn't enabling parallelism of asynchronous operations. That capability is built into the asynchronous operations themselves and how they interact with nodejs and how they are implemented. Promise.all() allows you to track multiple asynchronous operations and know when they are all done, get their results in order and/or know when there was an error in one of the operations.


If you're curious how timers work in nodejs, they are managed by libuv which is a cross platform library that nodejs uses for managing the event loop, timers, file system access, networking and a whole bunch of things.

Inside of libuv, it manages a sorted list of pending timers. The timers are sorted by their next time to fire so the soonest timer to fire is at the start of the list.

The event loop within nodejs goes in a cycle to check for a bunch of different things and one of those things is to see if the current head of the timer list has reached its firing time. If so, it removes that timer from the list, grabs the callback associated with that timer and calls it.

Other types of asynchronous operations such as file system access work completely differently. The asynchronous file operations in the fs module, actually use a native code thread pool. So, when you request an asynchronous file operation, it actually grabs a thread from the thread pool, gives it the job for the particular file operation you requested and sends the thread on its way. That native code thread, then runs independently from the Javascript interpreter which is free to go do other things. At some future time when the thread finishes the file operation, it calls a thread safe interface of the event loop to add a file completion event to a queue. When whatever Javascript is currently executing finishes and returns control back to the event loop, one of the things the event loop will do is check if there are any file system completion events waiting to be executed. If so, it will remove it from the queue and call the callback associated with it.

So, while individual asynchronous operation can themselves run in parallel if designed appropriately (usually backed by native code), signaling completion or errors to your Javascript all runs through the event loop and the interpreter is only running one piece of Javascript at a time.

Note: this is ignoring for the purposes of this discussion, the WorkerThread capability which actually fires up a whole different interpreter and can run multiple sets of Javascript at the same time. But, each individual interpreter still runs your Javascript single threaded and is still coordinated with the outside world through the event loop.

jfriend00
  • 683,504
  • 96
  • 985
  • 979
2

First, Promise.all has nothing to do with JS code running in parallel. You can think of Promise.all as code organizer, it puts code together and wait for response. What's responsible for the code to run in a way that "looks like" it's parallel is the event-based nature of JS. So in your case:

Promise.all([await3seconds(), await3seconds(), 
await3seconds(), await3seconds(), await3seconds()])
.then(() => {
    console.timeEnd("Promise.all finished in::")
})

Let's say that each function inside Promise.all is called a1 : a5, What will happen is:

  1. The Event loop will take a1 : a5 and put them in the "Callback Queue/Task Queue" sequentially (one after the other), But it will not take too much time, because it's just putting it in there, not executing anything.
  2. The timer will start immediately after each function is put by the Event Loop in the "Callback Queue/Task Queue" (so there will be a minor delay between the start of each one).
  3. Whenever a timer finishes, the Event loop will take the related callback function and put it in the "Call Stack" to be executed.
  4. Promise.all will resolve after the last function is popped out of the "Call Stack".

As you can see in here

Promise.all finished in::: 3.016s

The 0.16s delay is a combination between the time the Event loop took to put those callback functions sequentially in the "Callback Queue/Task Queue" + the time each function took to execute the code inside it after their timer has finished.

So the code is not executed in parallel, it's still sequential, but the event-based nature of JS is used to mimic the behavior of parallelism.

Look at this article to relate more to what I am trying to say.

Synchronous vs Asynchronous JavaScript

  • 1
    Just wanted to give a big thanks for your explanation! Helped a lot to understand what concurrency actually means in case of `Promise.all` – Advena Oct 26 '22 at 14:34
1

No they are not executed in parallel but you can conceptualize them this way. This is just how the event queue works. If each promise contained a heavy compute task, they would still be executed one at a time -

function await3seconds(id) { 
    return new Promise(res => {
        console.log(id, Date.now())
        setTimeout(_=> {
          console.log(id, Date.now())
          res()
        }, 3000)
    })
}

console.time("Promise.all finished in::")

Promise.all([await3seconds(1), await3seconds(2), await3seconds(3), await3seconds(4), await3seconds(5)])
    .then(() => {
        console.timeEnd("Promise.all finished in::")
})
time 1 2 3 4 5
1621997095724 start
1621997095725 start
1621997095725 start
1621997095725 start
1621997095726 start
1621997098738 end
1621997098740 end
1621997098740 end
1621997098741 end
1621997098742 end

In this related Q&A we build a batch processing Pool that emulates threads. Check it out if that kind of thing interests you!

Mulan
  • 129,518
  • 31
  • 228
  • 259