从异步捕获抛出时堆栈跟踪不完整

phi*_*hag 5 javascript v8 node.js

为什么在重新抛出异步异常时没有异步堆栈跟踪?对于节点 12+,运行以下代码时出现异常:

async function crash() {
    try {
        await (async () => {throw new Error('dead');})();
    } catch (e) {
        throw new Error('rethrow');
    }
}

async function foo() {
    await new Promise(resolve => setTimeout(() => resolve(), 1));
    await crash();
}

async function entrypoint() {
    try {
        await foo();
    } catch(e) {
        console.log(e.stack);
    }
}
entrypoint();
Run Code Online (Sandbox Code Playgroud)

非常不完整:

Error: rethrow
    at crash (/async-stackt/crash.js:6:15)
Run Code Online (Sandbox Code Playgroud)

通过在 的开头定义异常crash()找到了一种解决方法,这会产生更好的结果:

Error: rethrow
    at crash (/workaround.js:2:17)
    at foo (/workaround.js:12:11)
    at async entrypoint (/workaround.js:17:9)
Run Code Online (Sandbox Code Playgroud)

这不是最优的,因为无论是否需要,都必须提前构建错误,并且堆栈跟踪有些不精确。

为什么从异步 catch 块抛出错误时堆栈跟踪不完整?是否有任何解决方法或更改代码以首先获得完整的堆栈跟踪?

jfr*_*d00 5

该堆栈跟踪向您显示当时堆栈中的内容。当您进行诸如 之类的异步调用时setTimeout(),它会运行 ,setTimeout()它会注册一个计时器,该计时器将在未来的某个时间触发,然后继续执行。由于您在await此处使用,它会暂停 的执行foo(),但会在foo()调用where 后继续执行代码。由于那也是await,它会继续执行调用 的代码entrypoint()。完成后,堆栈完全为空。

然后,稍后,您的计时器将触发,并使用完全干净的堆栈调用其回调。在您的情况下,setTimeout()回调只是调用resolve(),然后触发承诺安排其解析处理程序在下一个事件循环滴答上运行。返回到系统并且堆栈帧再次为空。在事件循环的下一个滴答声中,promise 解析处理程序被调用,并满足await函数上下文中的 promise。当它await满足时,该函数的其余部分开始执行。

当该函数执行结束时,解释器知道这是一个挂起的函数上下文。没有return从函数发生,因为这已经发生了。相反,由于这是一个async函数,函数执行的结尾会解析此async函数返回的承诺。解析该承诺然后安排其解析处理程序在事件循环的下一个滴答声中被调用,然后将控制权返回给系统。堆栈帧再次为空。在事件循环的下一个滴答声中,它调用满足语句await中的的解析处理程序,await foo()并且entrypoint()函数可以继续运行,选择上次挂起的位置。

所以,这里的关键是,当计时器关闭时,执行从fooback 到entrypoint,而不是通过堆栈和return语句(该函数已经返回一段时间),而是通过承诺得到解决。因此,在计时器关闭然后您调用 时crash(),除了对crash()自身的函数调用外,堆栈确实是空的。

当 promise 被解决时,这个空栈的概念是async函数实际工作方式的核心,所以理解这一点很重要。您必须记住,它会暂停包含 的函数的内部执行await,但是一旦遇到第一个await,它会立即使函数返回一个承诺,并且调用者继续进一步执行。调用者不会暂停,除非他们也暂停,await在这种情况下调用者的调用者会继续执行。在某些时候,有人会继续执行,最终,它会将控制权返回给系统,并带有一个现在为空的堆栈。

定时器事件(或其他一些承诺触发事件)然后被一个完全空的堆栈帧调用,没有原始调用序列的残余。

不幸的是,我现在知道的唯一解决方法是执行您发现的操作 - 在原始堆栈仍然存在时更早地创建 Error 对象。如果我没记错的话,有一个关于向 Javascript 语言添加一些功能以使异步跟踪更容易的讨论。我不记得提案的细节,但也许是通过记住最初调用函数时的堆栈帧是什么,因为它在承诺被解决/拒绝之后以及创建 Error 对象时不再很有用。


如果有人不熟悉async函数的工作原理以及它们如何在第一个awaitreturn很早的时候暂停自己的执行,这里有一个小演示:

function delay(t) {
    return new Promise(resolve => {
        setTimeout(resolve, t);
    });
}

async function stepA() {
    console.log("5");
    await stepB();
    console.log("6");
}

async function stepB() {
    console.log("3");
    await delay(50);
    console.log("4");
}

console.log("1");
stepA();
console.log("2");
Run Code Online (Sandbox Code Playgroud)

这将生成以下输出。如果您一步一步地遵循此执行路径,您将看到每个await函数如何导致该函数的提前返回,然后可以看到一旦正在等待的承诺得到解决,堆栈帧将如何为空。这是生成的输出:

1
5
3
2
4
6
Run Code Online (Sandbox Code Playgroud)

很明显为什么1是第一,因为它是第一个执行的事情。

然后,应该清楚为什么5stepA()第一次被调用时会出现 next 。

然后,stepA调用stepB(),从而在开始执行,这就是为什么我们看到3未来。

然后,stepB调用await delay(50)。执行delay(50)它启动一个计时器,然后立即返回一个与该计时器挂钩的承诺。然后它点击await并停止执行stepB

stepB击中它时await,它会stepB在那个点返回一个来自函数的承诺async。该承诺将stepB最终(在未来)与执行挂钩,有机会完成其所有执行。目前, 的执行stepB已暂停。

stepB返回它的承诺时,它会回到stepA执行的地方await stepB();。现在stepB()已经返回(一个逝去的诺言),然后stepA点击其awaitunfulfilled的承诺。这会暂停 的执行stepA并在该点返回一个承诺。

因此,既然原始函数调用stepA()已返回(未实现的承诺)并且await该函数调用上没有,则该函数调用之后的顶级代码继续执行,我们看到控制台输出2.

console.log("2")是在此处执行的最后一条语句,因此控制权返回给解释器。此时,堆栈帧完全为空

然后,稍后,计时器触发。这会在 JS 事件队列中插入一个事件。当 JS 解释器空闲时,它会接收该事件并调用与该事件关联的计时器回调。这只会做一件事(调用resolve()承诺)然后返回。对该承诺计划调用解决方案,承诺.then()在事件循环的下一个滴答声中触发它的处理程序。发生这种情况时,await代码行上的await delay(50);满足并恢复该函数的执行。然后我们4在控制台中看到最后一行stepB执行。

console.log("4");执行之后,stepB现在已经完成执行并且它可以解决它的async承诺(它之前返回的那个)。解决该承诺会告诉它为.then()事件循环的下一个滴答安排其处理程序。控制权回到 JS 解释器。

在事件循环的下一个滴答声中,.then()处理程序通知awaitawait stepB();的 承诺现在已经解决并且stepA继续执行,现在我们6在控制台中看到。这是stepA执行到它的最后一行,它可以解决它的async承诺并将控制权返回给系统。

事实证明,没有人听到async调用stepA()返回的承诺,因此没有进一步执行。