You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Looping async functions with Promise.race() allocates excessive memory #29385
I took a look and I can't really find anything inside Node.js that seems to be at fault. I'm inclined to say this is a V8 issue after all, probably in the way its Promise.race() implementation retains references to the subordinate promises.
If I change your test case like below it runs in constant time and memory. Tweaking logOutput confirms it's still behaving the same as before.
--- tmp/bug29385.orig.js 2019-08-31 13:02:36.000000000 +0200+++ tmp/bug29385.js 2019-08-31 13:10:41.000000000 +0200@@ -5,7 +5,7 @@
const RESULT_OBJECT = new Array(128).fill(0).map(x => ~~(Math.random() * 128));
const STREAM_LENGTH = 10;
-const SUITE_SIZE = 10000;+const SUITE_SIZE = 1e5;
function deepCloneInner(obj) {
/* Some heavy sync operation */
@@ -77,12 +77,22 @@
},
};
+function race(a, b) {+ return new Promise(r => {+ const k = x => {+ if (r) r(x);+ r = null;+ };+ a.then(k);+ b.then(k);+ });+}+
async function runGame(logOutput) {
let chunk;
let x = Promise.resolve();
- // Changing this to chunk = await MY_STREAM.read() removes the leak.- while ((chunk = await Promise.race([MY_STREAM.read(), x]))) {+ while ((chunk = await race(MY_STREAM.read(), x))) {
if (logOutput) console.log(chunk);
}
}
I'm reproducing your test case below for posterity.
Details
'use strict';/* global gc */constRESULT_OBJECT=newArray(128).fill(0).map(x=>~~(Math.random()*128));constSTREAM_LENGTH=10;constSUITE_SIZE=10000;functiondeepCloneInner(obj){/* Some heavy sync operation */if(obj===null||typeofobj!=='object')returnobj;if(Array.isArray(obj))returnobj.map(prop=>deepCloneInner(prop));constclone=Object.create(Object.getPrototypeOf(obj));for(constkeyofObject.keys(obj)){clone[key]=deepCloneInner(obj[key]);}returnclone;}functiondeepCloneSync(obj){returndeepCloneInner(obj);}asyncfunctiondeepClone(obj){returndeepCloneInner(obj);}asyncfunctiondeepCloneNextTick(obj){returnnewPromise((resolve,reject)=>{process.nextTick(()=>resolve(deepCloneInner(obj)));});}asyncfunctiondeepCloneSetImmediate(obj){returnnewPromise((resolve,reject)=>{setImmediate(()=>resolve(deepCloneInner(obj)));});}asyncfunctiondeepCloneAwait(obj){await'ayuwoki';returndeepCloneInner(obj);}constMY_STREAM={iterations: 0,asyncread(){if(++this.iterations%STREAM_LENGTH===0){returnnull;}/* * Using deepCloneSync() causes a leak of Promises in Node 10.15.3 (V8 6.8.275.32-node.51), * but it doesn't leak in Node 8.15.1 (V8 6.2.414.75) * * Leak also confirmed (Increase SUITE_SIZE x10) in: * node 12.0.0-nightly20190331bb98f27181, v8 7.4.288.13-node.13 * node 12.0.0-v8-canary201903313c649ecee6, v8 7.5.149-node.0 * */returndeepCloneSync(RESULT_OBJECT);// return deepClone(RESULT_OBJECT);// return deepCloneNextTick(RESULT_OBJECT);// return deepCloneSetImmediate(RESULT_OBJECT);// return deepCloneAwait(RESULT_OBJECT);},};constENV={iterations: 0,getNextGameParameters(){if(++this.iterations%SUITE_SIZE===0){returnnull;}returnthis.iterations%2 ? 'arg1' : 'arg2';},};asyncfunctionrunGame(logOutput){letchunk;letx=Promise.resolve();// Changing this to chunk = await MY_STREAM.read() removes the leak.while((chunk=awaitPromise.race([MY_STREAM.read(),x]))){if(logOutput)console.log(chunk);}}asyncfunctionrunSuite(){letparameters;letiterations=0;while((parameters=ENV.getNextGameParameters())){awaitrunGame().catch(err=>{console.error(`Error for parameters ${parameters}\n${err.stack}`);});// Uncommenting fixes the leak (Node.js only)/* if (iterations++ % 50 === 0) { await new Promise(resolve => setImmediate(resolve)); } //*/}}(async()=>{awaitrunSuite();})();
Running the test in V8's simple REPL, d8 doesn't show any growth in memory, see details in the V8 issue. That's why I suspected it has to do with something Node.js specific.
@MayaLekova That was my hunch too. Specifically: async_hooks - but those aren't enabled when this test runs and that also wouldn't explain why my ersatzPromise.race() doesn't exhibit the same behavior.
We're at V8 7.7.299.8-node.12. Have there been upstream changes that might have fixed this? Any other suggestions I could try out?
About the upstream changes - not that I know of, sorry. Does enabling async hooks change anything?
About suggestions - not clear ones, but I'm thinking if there's any difference between how d8 handles the microtask queue vs. how it's embedded in Node.js. For instance I know that d8's setImmediate implementation is a dummy one, so there might be something in the actual implementation related to the cause of the leak (why does it supress it?).
Enabling async_hooks slows it down by about a factor of 5 but doesn't otherwise impact behavior.
FWIW, when I use a race() that's a bit more faithful to (my reading of) the Promise.race() spec, I see the same memory consumption as with the built-in Promise.race():
I checked the other day whether manually flushing the microtask queue makes any difference but it doesn't. If you want to try for yourself, start node with --expose-internals and add this code:
const{internalBinding}=require('internal/test/binding');const{runMicrotasks}=internalBinding('task_queue');runMicrotasks();// takes no arguments
Not sure whether it's really important, but I've tried runMicrotasks() each 50 iterations (as suggested), as well as await new Promise(resolve => queueMicrotask(resolve)) - both don't remove the leak. So it looks like the difference between queueMicrotask and setImmediate/setTimeout(0) is what's causing it. Will experiment further, thanks for the extra info!
Hi, I've also stumbled on this issue. I wonder, does anyone knows user space workaround that would prevent leaks, like custom impl of race function? Or they only way to make Promise.race usable is to have that fixed in Node.js source? Thanks!
Hmm, indeed I was running different test case, reverted back to v12.13.1 and it also seems to be working fine - GC is keeping up, must be something with my code then. Sorry for confusion.
Digging in on this a bit... in the original code, if you increase the number of iterations to 100 and run it through the clinicjs.org clinic doctor tool, you'll see that clinic gives you a data analysis error, looking at the underlying trace event file that is collected by clinic, you'll find that the code is getting stuck when running the microtaskqueue. This is caused by the creation of a large number of orphaned Promises in a tight sync loop. If you take a heap snapshot, you'll see that the Promises are being retained by queueMicrotask. I believe the workarounds that have been identified are working only because they end up giving the garbage collector a chance to catch up. If you increase the number of iterations, the workarounds don't appear to work and you'll end up with a memory error being thrown.
For d8, it would be interesting to increase the number of iterations to see if the problem occurs there as well. If it doesn't, then it would appear that there is definitely something a bit wonky about the way Node.js is handling the microtaskqueue ... however, just in general I would say that this is yet another reason to avoid using Promise.race(), especially with synchronous loops.
Bit more analysis... with the original code, after enabling trace event tracking using clinic bubble and reducing the number of iterations to 5... running grep -o '\"PROMISE"\B' 8552.clinic-bubbleprof-traceevent | wc -l returns 579948.
I don't believe there's anything actionable for Node.js in this issue. Closing. Can reopen if new information is received that does point to anything we can do in Node.js
Activity
bnoordhuis commentedon Aug 31, 2019
I took a look and I can't really find anything inside Node.js that seems to be at fault. I'm inclined to say this is a V8 issue after all, probably in the way its
Promise.race()implementation retains references to the subordinate promises.If I change your test case like below it runs in constant time and memory. Tweaking
logOutputconfirms it's still behaving the same as before.I'm reproducing your test case below for posterity.
Details
MayaLekova commentedon Sep 2, 2019
Running the test in V8's simple REPL, d8 doesn't show any growth in memory, see details in the V8 issue. That's why I suspected it has to do with something Node.js specific.
bnoordhuis commentedon Sep 2, 2019
@MayaLekova That was my hunch too. Specifically:
async_hooks- but those aren't enabled when this test runs and that also wouldn't explain why my ersatzPromise.race()doesn't exhibit the same behavior.We're at V8 7.7.299.8-node.12. Have there been upstream changes that might have fixed this? Any other suggestions I could try out?
MayaLekova commentedon Sep 2, 2019
About the upstream changes - not that I know of, sorry. Does enabling async hooks change anything?
About suggestions - not clear ones, but I'm thinking if there's any difference between how d8 handles the microtask queue vs. how it's embedded in Node.js. For instance I know that d8's
setImmediateimplementation is a dummy one, so there might be something in the actual implementation related to the cause of the leak (why does it supress it?).bnoordhuis commentedon Sep 2, 2019
Enabling async_hooks slows it down by about a factor of 5 but doesn't otherwise impact behavior.
FWIW, when I use a
race()that's a bit more faithful to (my reading of) thePromise.race()spec, I see the same memory consumption as with the built-inPromise.race():I checked the other day whether manually flushing the microtask queue makes any difference but it doesn't. If you want to try for yourself, start node with
--expose-internalsand add this code:MayaLekova commentedon Sep 2, 2019
Not sure whether it's really important, but I've tried
runMicrotasks()each 50 iterations (as suggested), as well asawait new Promise(resolve => queueMicrotask(resolve))- both don't remove the leak. So it looks like the difference betweenqueueMicrotaskandsetImmediate/setTimeout(0)is what's causing it. Will experiment further, thanks for the extra info!thaaddeus commentedon Nov 6, 2019
Hi, I've also stumbled on this issue. I wonder, does anyone knows user space workaround that would prevent leaks, like custom impl of
racefunction? Or they only way to makePromise.raceusable is to have that fixed in Node.js source? Thanks!thaaddeus commentedon Nov 26, 2019
Is seems like it works fine in v13, was that V8 thing after all?
Slayer95 commentedon Nov 26, 2019
@tardis, reproduced with node-v13.2.0-win-x64. Are you running a different test case or environment?
thaaddeus commentedon Nov 26, 2019
Hmm, indeed I was running different test case, reverted back to v12.13.1 and it also seems to be working fine - GC is keeping up, must be something with my code then. Sorry for confusion.
jasnell commentedon Nov 27, 2019
Digging in on this a bit... in the original code, if you increase the number of iterations to 100 and run it through the clinicjs.org
clinic doctortool, you'll see that clinic gives you a data analysis error, looking at the underlying trace event file that is collected by clinic, you'll find that the code is getting stuck when running the microtaskqueue. This is caused by the creation of a large number of orphaned Promises in a tight sync loop. If you take a heap snapshot, you'll see that the Promises are being retained by queueMicrotask. I believe the workarounds that have been identified are working only because they end up giving the garbage collector a chance to catch up. If you increase the number of iterations, the workarounds don't appear to work and you'll end up with a memory error being thrown.For d8, it would be interesting to increase the number of iterations to see if the problem occurs there as well. If it doesn't, then it would appear that there is definitely something a bit wonky about the way Node.js is handling the microtaskqueue ... however, just in general I would say that this is yet another reason to avoid using Promise.race(), especially with synchronous loops.
jasnell commentedon Nov 27, 2019
Bit more analysis... with the original code, after enabling trace event tracking using
clinic bubbleand reducing the number of iterations to 5... runninggrep -o '\"PROMISE"\B' 8552.clinic-bubbleprof-traceevent | wc -lreturns579948.jasnell commentedon Jun 19, 2020
I don't believe there's anything actionable for Node.js in this issue. Closing. Can reopen if new information is received that does point to anything we can do in Node.js
fix: get rid of Promise.race in combine function as in some edge case…
cefn commentedon Jan 17, 2024
It looks like this was a more complex variant of the simpler repro at #51452 which still exists in Node (and apparently not in V8).
fix: get rid of Promise.race in combine function as in some edge case…
fix: get rid of Promise.race in combine function as in some edge case…