2

Given the following piece of code

async function recurse() {
    await someAsyncStuff();
    return recurse();
}

await recurse(0);

It would make sense that recurse(i) would resolve into recurse(i+1) and the old Promise would get GC'd, but I can't seem to find any documentation confirming that this is how Node will resolve stuff under the hood -- on the contrary, MDN seems to imply that this won't happen, but rather that the promises will stack on each other, creating a sort of Matryoshka doll of wrapper Promises.

Running this code locally seems to concur this, but the results are inconclusive -- the total heap size for the program does increase over time, but the rate is so slow that anything else could be causing it.

  • Closely related: [Building a promise chain recursively in javascript - memory considerations](https://stackoverflow.com/q/29925948/1048572) – Bergi Jul 05 '23 at 23:07
  • When working with `async` functions, better write loops than recursion. Native ES6 promises are not optimised for this. – Bergi Jul 05 '23 at 23:08
  • @Bergi is it that bad? Given it's (as far as I can tell) the de-facto standard process to consume e.g. ReadableStreams, I'd have assumed implementations *were* optimized for this. But you know better than me. – Kaiido Jul 06 '23 at 02:10
  • I could be mistaken, but it looks like any way you cut it, you're creating an ever-expanding call stack, and that's probably not good. Though the ES6 spec indicates that implementing engines should provide Tail Call Optimization, the reality (afaik) is that they don't. – JLRishe Jul 06 '23 at 06:10
  • @JLRishe Tail call implementation (as specified) also only works for synchronous functions – Bergi Jul 06 '23 at 13:00
  • The example you provided does not suggest a compelling reason not to simplify it to `async function endlessLoop() { while(true) { await someAsyncStuff(); } } await endlessLoop();` – Wyck Jul 06 '23 at 13:39
  • Also closely related, if not duplicate: [Memory leak in nodejs promise design?](https://stackoverflow.com/q/52427285/1048572) – Bergi Jul 11 '23 at 21:10

1 Answers1

0

I originally said "This will fill the memory infinitely.", but according to many comments I have to consider that I'm probably wrong !

So I deleted the original answer. I only keep this answer for now so that the comments (and the ability to add comments) are not lost. I'll also keep my "experiments" here, which might help to illustrate what's happening.

Example without await

Consider the equivalent using .then() instead of await, which I think is easier to follow:

// without-await.js
function recurse() {
    return someAsyncStuff().then( function(){
        return recurse();
    });
}

(async function(){
    await recurse(0);
})();

Example with immediately resolved promise

You can confirm the memory leak if you replace await someAsyncStuff() by await Promise.resolve(), which will fill the memory quickly:

// immediately-resolved.js
async function recurse() {
    await Promise.resolve();
    return recurse();
}

(async function(){
    await recurse(0);
})();

What someAsyncStuff() does

Also note that (at least for me) it makes a big difference what someAsyncStuff() actually does.

E.g. with a setTimeout my memory does fill, but very slowly:

function someAsyncStuff() {
    return new Promise( function(resolve, reject){
        setTimeout( resolve, 0 );
        //resolve();
    });
}

With an immediately resolved Promise on the other hand the memory will fill very quickly:

function someAsyncStuff() {
    return new Promise( function(resolve, reject){
        //setTimeout( resolve, 0 );
        resolve();
    });
}

My conclusions

Again, my ideas are not to be considered an answer anymore!

My conclusion was that not the call stack would leak, but something about the Promises (I originally said the microtask queue, but according to the comments that's wrong, which makes sense to me, but then there must be something else ? (the "database" ?)).

Not the call stack

The call stack doesn't fill recursively:

  • recurse() is executed and pushed to the call stack
  • someAsyncStuff() is executed and pushed to the call stack
  • someAsyncStuff() creates and returns a pending promise
  • someAsyncStuff() is popped off the call stack
  • recurse() returns that pending Promise
  • recurse() is popped off the call stack
  • The stack is empty, the promise is in memory
kca
  • 4,856
  • 1
  • 20
  • 41
  • 3
    "*it will fill the microtask queue*" - no. The microtask queue comes into play only once the promise is fulfilled - but `recurse()` never settles, it recurses infinitely. – Bergi Jul 05 '23 at 23:10
  • Makes sense, I guess... but where is an unresolved promise stored then, like `new Promise(()=>{})` ? Is my "equivalent" with `then` wrong ? ... I will delete my answer, as I obviously don't understand this as well as I thought. – kca Jul 06 '23 at 05:53
  • @Bergi I believe you're wrong here. We're calling these things queues but they're not really. They're a database (linked list, hashmap, table -- implementation detail doesn't really matter) of callbacks and the events that should trigger the callbacks to be called. As such, the moment a callback is registered (eg. when a Promise is created) the event handler (microtask) gets added to the queue. When the Promise is resolved the queue is consulted to find the related callback and that callback is called (the function you pass to `.then()` or the remainder of the bytecode after an `await`) – slebetman Jul 06 '23 at 06:08
  • However Bergi is right that it will not fill memory. But for a slightly different reason (or maybe I'm reading his comment wrong). A new Promise is only created (`recurse() is called`) inside a `.then()` but that callback won't be called until the Promise resolves. So because the Promise never resolves it actually never recurses. It's a subtle difference. – slebetman Jul 06 '23 at 06:11
  • @slebetman But it _does_ fill the memory, if you try it. (Note that `someAsyncStuff` _does_ get resolved, so the `then` is called, only the `.then` doesn't resolve then) – kca Jul 06 '23 at 09:53
  • @kca "*where is an unresolved promise stored then*" - in heap memory. And it might even get garbage-collected if nothing (in particular the function that could `resolve()` it) references it any more – Bergi Jul 06 '23 at 13:02
  • 1
    "*We're calling these things queues but they're not really.*" - no. There is a queue of tasks that need to be run, **and** there is a "database" (or other data structure) of which functions should be called when which event happens. Creating a promise does not add anything to either. Calling `.then()` or using `await` does store the callback (and next promise) or continuation in the "database" (concretely, it's stored in an array of fullfillment handlers on the promise object). Finally, fulfilling the promise (the "triggering event") actually puts the promise jobs on the microtask queue. – Bergi Jul 06 '23 at 13:09
  • So is "the database" what is filling up recursively ? What is the "database", is there some source where I can read up about it ? This seems to be very relevant for a JS developer, if it can cause memory leaks so "easily", and is not one of the queues I know. – kca Jul 06 '23 at 13:34
  • @kca beware with what you're measuring. Simply seeing the memory usage grow doesn't mean there is a leak, and in this particular case it could not even be the sing of *your* code producing garbage, for instance it could very well be your dev-tools that do hold references to the objects. Unless you know what you are measuring and have the tools to diagnose it, these reports can be very misleading. – Kaiido Jul 09 '23 at 06:17
  • 1
    And regarding the microtask queue story, Bergi's comment was spot on, it won't fill it, you'll always have only between zero and a single microtask queued in there at a time. However, one thing to note with your last snippet: you will never exit the microtask checkpoint, since you're queuing a new microtask from the one that just got dequeued. This once again may very well prevent the *normal* execution of the browser, e.g. they may not have all the GC setup going in between two microtasks. – Kaiido Jul 09 '23 at 06:21
  • @Kaiido Fair enough, but everybody says the memory will not fill, but was anyone _actually_ able to make this code _not_ fill the memory very quickly ? Devtools are not involved, just `node try-memory.js` in the command line (But in browsers it's also the same for me.) – kca Jul 09 '23 at 15:09
  • ... I agree the memory doesn't fill "quickly" if `someAsyncStuff()` goes back into the "macro task loop", e.g. something like resolving in a `setTimeout( f, 0 )`. I admit that's probably the more common use case. But the memory still fills slowly. -- The memory fills very quickly if `someAsyncStuff()` returns `Promise.resolve()` – kca Jul 09 '23 at 17:28
  • 1
    @kca I never disagreed that the memory will fill (infinitely), I think it does in fact. It fills with the promise objects that are created for each of the `async` function calls - they are never actually resolved, but are promised to be resolved when the recursion terminates, and this keeps them alive (not garbage-collected). The call stack and the microtask queue stay constant-sized, it's that the code does not terminate, like a `while (true)` loop (but using the microtask queue, in case of `await Promise.resolve();`, or the macrotask queue, in case of `setTimeout`). – Bergi Jul 09 '23 at 21:48
  • 1
    "*What is the "database", is there some source where I can read up about it?*" - I have never heard anyone use this term, it's something that @slebetman brought up. Concretely the handlers that are stored on the promise objects. – Bergi Jul 09 '23 at 21:50
  • 1
    Oh I didn't say the memory won't fill, just that you should be careful with what and how you measure it. For instance you may want to at least take a snapshot of what is filling the memory (here it seems that it's indeed the various Promise objects). And, yes comparing the usage over various environments is a good idea too. I was just pointing out that one need to be rigorous in this area since there are many factors that can affect the results, sometimes drastically. – Kaiido Jul 10 '23 at 00:35
  • @kca "What is the database" - V8 (node, Google Chrome, Microsoft Edge etc.) use libuv to maintain the collection of event handlers. Internally they call it the queue. It's a simple linked-list as you can see from their source code: https://github.com/libuv/libuv/blob/v1.x/src/queue.h. – slebetman Jul 10 '23 at 11:38
  • @slebetman I doubt that. Where do promises use libuv? – Bergi Jul 10 '23 at 12:38
  • @Bergi I wasn't referring to promises. I was referring to your original comment (the first comment in this thread): `the microtask queue comes into play...`. We call it a queue but it's really just a collection of event handlers. – slebetman Jul 11 '23 at 01:52
  • 1
    @slebetman [Blink's microtask queue](https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/blink/renderer/platform/scheduler/public/event_loop.h;drc=316850595842d7b40953b16f96e7615b1bfa57a7;l=129) is a `Deque`, [V8's one](https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:v8/src/execution/microtask-queue.h;l=130;drc=316850595842d7b40953b16f96e7615b1bfa57a7;bpv=1;bpt=1) uses a *ring buffer*. Chrome and Edge use both of these. Events are yet another beast. The event-loops in browsers are way more complicated than node's implementation. – Kaiido Jul 11 '23 at 01:58
  • @slebetman The collection of event handlers (that might need to be called multiple times, depending on how often an event happens) is not what the microtask queue is. – Bergi Jul 11 '23 at 11:21