为什么我使用异步挂钩 API 会导致 Node.js 异常终止?

Why does my use of the async hooks API cause an abnormal termination of Node.js?

我坏了Node.js!!

我正在使用 async hooks API,我的代码使 Node.js 异常终止。

我的问题是:导致 Node.js 以这种方式终止的代码是什么,我可以在解决问题的代码中更改什么吗?

我的应用程序 Data-Forge Notebook 需要能够跨 JavaScript 笔记本的评估跟踪异步操作,以了解笔记本的评估何时完成。

所以我创建了一个名为 AsyncTracker 的 JavaScript class,它包装了异步挂钩 API,这样我就可以为一段代码启用异步跟踪。在代码部分的末尾,我可以禁用跟踪并等待当前异步操作完成。

为了初始化跟踪,我这样做了:

this.asyncHook = async_hooks.createHook({ 
    init: (asyncId, type, triggerAsyncId, resource) => {
        this.addAsyncOperation(asyncId, type);
    },
    after: asyncId => {
        this.removeAsyncOperation(asyncId);
    },
    destroy: asyncId => {
        this.removeAsyncOperation(asyncId);
    },
    promiseResolve: asyncId => {
        this.removeAsyncOperation(asyncId);
    },
});

this.asyncHook.enable();

异步操作记录在 JS 映射中,但它们仅在通过将 trackAsyncOperations 设置为 true 启用跟踪时添加。这是允许在代码部分的开头启用跟踪的变量:

addAsyncOperation(asyncId, type) {
    if (this.trackAsyncOperations) {
        this.asyncOperations.add(asyncId);
        this.openAsyncOperations.set(asyncId, type);
    }
}

各种异步挂钩导致从映射中删除异步操作:

removeAsyncOperation(asyncId) {
    if (this.asyncOperations.has(asyncId)) {
        this.asyncOperations.delete(asyncId);
        this.openAsyncOperations.delete(asyncId);

        if (this.asyncOperationsAwaitResolver && 
            this.asyncOperations.size <= 0) {
            this.asyncOperationsAwaitResolver();
            this.asyncOperationsAwaitResolver = undefined;
        }
    }
}

注意代码行 this.asyncOperationsAwaitResolver(),这是触发我们在代码部分末尾等待的承诺的解决,以等待挂起的异步操作完成。

禁用跟踪然后等待完成挂起的异步操作的函数如下所示:

awaitCurrentAsyncOperations() {

    // At this point we stop tracking new async operations.
    // We don't care about any async op started after this point.
    this.trackAsyncOperations = false; 

    let promise;

    if (this.asyncOperations.size > 0) {
        promise = new Promise(resolve => {
            // Extract the resolve function so we can call it when all current async operations have completed.
            this.asyncOperationsAwaitResolver = resolve; 
        });
    }
    else {
        this.asyncOperationsAwaitResolver = undefined;
        promise = Promise.resolve();
    }

    return promise;
}

总而言之,这是一个使用跟踪器的最小示例,它使 Node.js 在没有警告的情况下中止:

const asyncTracker = new AsyncTracker();
asyncTracker.init();
asyncTracker.enableTracking(); // Enable async operation tracking.

// ---- Async operations created from here on are tracked.

// The simplest async operation that causes this problem.
// If you comment out this code the program completes normally.
await Promise.resolve(); 

// ---  Now we disable tracking of async operations, 
// then wait for all current operations to complete before continuing.

// Disable async tracking and wait.
await asyncTracker.awaitCurrentAsyncOperations(); 

请注意,此代码并未全面损坏。当与基于回调或基于承诺的异步操作一起使用时,它似乎工作正常(Node.js 正常终止)。只有当我将 await 关键字添加到组合中时,它才会失败。因此,例如,如果我将 await Promise.resolve() 替换为对 setTimeout 的调用,它会按预期工作。

GitHub:

上有一个这样的工作示例

https://github.com/ashleydavis/nodejs-async-tracking-example

运行 使 Node.js 爆炸的代码。要重现克隆 repo,运行 npm install,然后是 npm start.

此代码已在 Windows 10 和 Node.js 版本 8.9.4、10.15.2 和 12.6.0 上进行了测试。

此代码现已在 MacOS v8.11.3、10.15.0 和 12.6.0 上进行了测试。

它在所有测试的版本上都有相同的行为。

代码审查

在 GitHub 和 运行 上使用 Node v10.16.0 查看 Windows 10 上的完整代码后,看起来 AsyncTracker.awaitCurrentAsyncOperations 中返回的承诺是从未解决,这会阻止 main() 中的代码超出 await asyncTracker.awaitCurrentAsyncOperations(); 部分。

这就解释了为什么 ** 33 ** 部分永远不会输出以及为什么 main()then(...) 回调永远不会打印出 Done。上述 promise 的 resolve 方法被分配给 this.asyncOperationsAwaitResolver,但是(根据当前的实现)它只会在 this.asyncOperation 集中没有更多的 promise 时被调用。如下面第一个控制台输出所示,情况并非如此。

问题重现

我稍微修改了代码以处理 index.js 脚本末尾的 process.on 事件

process.on('exit', () => { console.log('Process exited') });
process.on('uncaughtException', (err) => { console.error(err && err.stack || err) });
process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Rejection at: ', promise, ', reason: ', reason) });
process.on('multipleResolves', (type, promise, reason) => { console.error(type, promise, reason) });

并且退出回调是唯一被调用的回调。 ** 22 ** 标记后的控制台输出为:

** 22 **
>>>>>>> Cell has ended, async operation tracking has been disabled, currently have 3 async ops in progress.
Waiting for operations to complete, creating a promise.
!! Have 3 remaining async operations:
  #9 - PROMISE.
  #10 - PROMISE.
  #11 - PROMISE.
%% removed async operation #9
!! Have 2 remaining async operations:
  #10 - PROMISE.
  #11 - PROMISE.
Process exited

解决方案

问题是由removeAsyncOperation(asyncId)方法中的错字引起的。 而不是:

  removeAsyncOperation(asyncId) {
      // ...
      if (this.asyncOperationsAwaitResolver && this.asyncOperations.size <= 0) {
        //...
      }
    }
  } 

如果队列中有 promise,这会阻止 promise 被解析,你需要做:

  removeAsyncOperation(asyncId) {
      // ...
      if (this.asyncOperationsAwaitResolver && this.asyncOperations.size >= 0) {
        //...
      }
    }
  } 

只要队列中有承诺,承诺就会得到解决。

async-tracker.js 进行上述更改后,应用程序按预期运行,生成输出:

** 22 **
>>>>>>> Cell has ended, async operation tracking has been disabled, currently have 3 async ops in progress.
Waiting for operations to complete, creating a promise.
!! Have 3 remaining async operations:
  #9 - PROMISE.
  #10 - PROMISE.
  #11 - PROMISE.
%% removed async operation #9
!! Have 2 remaining async operations:
  #10 - PROMISE.
  #11 - PROMISE.
%% resolving the async op promise!
** 33 **
Done
%% removed async operation #10
!! Have 1 remaining async operations:
  #11 - PROMISE.
%% removed async operation #11
!! Have 0 remaining async operations:
Process exited

好的,我现在有答案了。我会继续更新这个,因为我更好地理解了我造成的这个问题。

我的回答还没有完全解释 Node.js 的运作方式,但它是某种解决方案。

我的代码试图等待一段代码中发生的任意异步操作的完成。在我的代码示例中,异步操作的跟踪发生在 main 函数内,而 thencatch 处理程序发生在 main 函数之外。我的理论是,正在跟踪的异步操作 'kept alive' 通过永远不会执行的代码:导致异步操作完成的代码永远不会执行。

我通过删除使我的程序正常终止的 thencatch 回调发现了这一点。

所以我的工作理论是我造成了某种 Node.js 承诺死锁,Node.js 无法处理(为什么会这样?)所以它就退出了。

我意识到这是 Node.js 的一个完全病态的用例,没有人会使用它。但我已经构建了一个开发人员工具,并且正在探索这个工具,因为我希望能够跟踪和监控用户发起的任意异步操作。