Skip to content

Promise Rejections delay process exiting (and others) in processPromiseRejections #34851

Description

@matt4530
  • Version: v14.8.0
  • Platform: Windows 10 - 64 bit
  • Subsystem:

What steps will reproduce the bug?

async function foo() {
  for (let i = 0; i < 100000; i++) {
    try {
      await new Promise((resolve, reject) => reject());
    } catch {}
  }
}

Observe the long runtime. If you add some logging, you'll notice that program execution actually finishes first, but the process doesn't exit for some time afterwards.

image
image

How often does it reproduce? Is there a required condition?

The behavior is consistent across runs. The promise must be rejected. Resolving the promise does not trigger this delay in exiting.

What is the expected behavior?

To not take so much time handling rejected promises.

What do you see instead?

The program's output might finish first, but the process doesn't exit for some time.

Additional information

Profiling, reports that some time is spent in the processPromiseRejections() function's main loop, as shown.
image

Activity

  1. matt4530 commented on Aug 20, 2020

    @matt4530
    Author

    After some investigation, this also appears to block some other types of operations, not just process exiting. I haven't been able to nail down an additional test case though, since this other processing that was blocked occurred in a 3rd party library which involves spinning up a server.

    The code more or less goes:

    // same loop as above
    console.log("done");
    // call to 3rd party
    console.log("done 3rd party");
    

    Which results in the output:

    done
    done 3rd party
    <massive delay>
    <3rd party operation completes>
    

    By sleeping for some time during foo(), we can eliminate this massive delay since it gets spread out earlier. (I assume the loop handling the pendingUnhandledRejections doesn't fill up)

    image
    a close up of the process that starts after processPromiseRejections finishes:
    image

  2. changed the title [-]Promise Rejections delay process exiting in processPromiseRejections[/-] [+]Promise Rejections delay process exiting (and others) in processPromiseRejections[/+] on Aug 20, 2020
  3. added
    performanceIssues and PRs related to the performance of Node.js.
    promisesIssues and PRs related to ECMAScript promises.
    on Aug 20, 2020
  4. mmarchini commented on Aug 21, 2020

    @mmarchini
    Contributor

    My guess is this happens because we keep track of rejections to determine if they are unhandled or not, which means the program is looping through 100000 rejections in one tick (whereas on real world applications this is unlikely to happen). This is the relevant code btw:

    function processPromiseRejections() {
    let maybeScheduledTicksOrMicrotasks = asyncHandledRejections.length > 0;
    while (asyncHandledRejections.length > 0) {
    const { promise, warning } = asyncHandledRejections.shift();
    if (!process.emit('rejectionHandled', promise)) {
    process.emitWarning(warning);
    }
    }
    let len = pendingUnhandledRejections.length;
    while (len--) {
    const promise = pendingUnhandledRejections.shift();
    const promiseInfo = maybeUnhandledPromises.get(promise);
    if (promiseInfo === undefined) {
    continue;
    }
    promiseInfo.warned = true;
    const { reason, uid } = promiseInfo;
    switch (unhandledRejectionsMode) {
    case kStrictUnhandledRejections: {
    const err = reason instanceof Error ?
    reason : generateUnhandledRejectionError(reason);
    triggerUncaughtException(err, true /* fromPromise */);
    const handled = process.emit('unhandledRejection', reason, promise);
    if (!handled) emitUnhandledRejectionWarning(uid, reason);
    break;
    }
    case kIgnoreUnhandledRejections: {
    process.emit('unhandledRejection', reason, promise);
    break;
    }
    case kAlwaysWarnUnhandledRejections: {
    process.emit('unhandledRejection', reason, promise);
    emitUnhandledRejectionWarning(uid, reason);
    break;
    }
    case kThrowUnhandledRejections: {
    const handled = process.emit('unhandledRejection', reason, promise);
    if (!handled) {
    const err = reason instanceof Error ?
    reason : generateUnhandledRejectionError(reason);
    triggerUncaughtException(err, true /* fromPromise */);
    }
    break;
    }
    case kWarnWithErrorCodeUnhandledRejections: {
    const handled = process.emit('unhandledRejection', reason, promise);
    if (!handled) {
    emitUnhandledRejectionWarning(uid, reason);
    process.exitCode = 1;
    }
    break;
    }
    case kDefaultUnhandledRejections: {
    const handled = process.emit('unhandledRejection', reason, promise);
    if (!handled) {
    emitUnhandledRejectionWarning(uid, reason);
    if (!deprecationWarned) {
    emitDeprecationWarning();
    deprecationWarned = true;
    }
    }
    break;
    }
    }
    maybeScheduledTicksOrMicrotasks = true;
    }
    return maybeScheduledTicksOrMicrotasks ||
    pendingUnhandledRejections.length !== 0;
    }
    .

    Looking at the complete flamegraph (for Node.js v12), we're making some very expensive calls:

    image

    There might be some room for optimization here.

  5. devsnek commented on Aug 21, 2020

    @devsnek
    Member

    I can try to improve the perf here soonish. If anyone else wants to take a look before I get around to it, the problem is that we continue tracking handled promises in the pendingUnhandledRejections array even if they haven't been warned.

  6. mmarchini commented on Aug 21, 2020

    @mmarchini
    Contributor

    @devsnek I'm looking. I'm not sure that's an accurate description of the problem: those rejections are tracked so we can determine if they will be warned, we only know if they are warned or not after foo() executes (because we're "stuck" in the microtask queue, and pendingUnhandledRejections, which runs on nextTick is not executed until the end). But there's another problem: we're using Array.shift on pendingUnhandledRejections, which is orders of magnitude slower than Array.pop. I changed to Array.pop and the time dropped from 8 seconds to ~5ms. pop changes the semantic though, so it can't be used as a drop-in replacement.

  7. devsnek commented on Aug 21, 2020

    @devsnek
    Member

    @mmarchini if a promise has not been warned about when it is handled, we can forget about it at that point. the problem is that we leave it in that array, probably because there are generally not a lot of these situations, and splicing them out would be very slow.

  8. mmarchini commented on Aug 21, 2020

    @mmarchini
    Contributor

    You're right. So the patch below should solve it, right?

    diff --git a/lib/internal/process/promises.js b/lib/internal/process/promises.js
    index f2145d425c..2a43de3535 100644
    --- a/lib/internal/process/promises.js
    +++ b/lib/internal/process/promises.js
    @@ -130,6 +130,11 @@ function unhandledRejection(promise, reason) {
     }
     
     function handledRejection(promise) {
    +  const i = pendingUnhandledRejections.indexOf(promise)
    +  if (i > -1) {
    +    pendingUnhandledRejections.splice(i)
    +    return;
    +  }
       const promiseInfo = maybeUnhandledPromises.get(promise);
       if (promiseInfo !== undefined) {
         maybeUnhandledPromises.delete(promise);

    Tests are passing (apparently, I got a bunch of apparently unrelated failures build was all weird, rebuild and tests passed), but I'm probably missing some edge cases here 🤷🏻‍♀️. More importantly, would this have an impact on runtime performance? Do we have benchmarks for it?

  9. devsnek commented on Aug 21, 2020

    @devsnek
    Member

    It will decrease the throughput of adding catch handlers to rejected promises. I'm not sure how much we care about that.

  10. mmarchini commented on Aug 21, 2020

    @mmarchini
    Contributor

    That's what I thought. It's still hard to determine if this would be an improvement on overall performance, if it would degrade it or stay the same. With this we have more latency on .catch, but less on the next tick after the catch. I think it's likely not perceptible on most applications, but only way to find out is by running some benchmarks.

  11. mmarchini commented on Aug 21, 2020

    @mmarchini
    Contributor

    With some really arbitrary not at all scientific microbenchmarks, the patch above has overall better performance. I'll open a PR and will see if I can collect more perf info around it.

    Edit: ran some better benchmarks and the patch was slower, I'm evaluating alternatives.

  12. matt4530 commented on Aug 21, 2020

    @matt4530
    Author

    Thanks for jumping on this. The PR also looks great.

    whereas on real world applications this is unlikely to happen

    I think you are right here. The use case I have is simulation software which intentionally uses promise rejections fairly frequently. It runs on its own as fast as it can (a while loop processing events as fast as it can). Obviously that isn't the typical case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceIssues and PRs related to the performance of Node.js.promisesIssues and PRs related to ECMAScript promises.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions