为什么以下内容会导致堆栈溢出?

4
以下函数旨在通过反复创建微任务来阻止宏任务。但是为什么会导致堆栈溢出?我以为await会将递归调用放置在微任务中,因此清空堆栈(显然我错了)。
const waitUsingMicroTasks = async (durationMs, start = performance.now()) => {
    const wait = async () => {
        let elapsed = (performance.now() - start)
        if(elapsed < durationMs) await wait()
    }
    await wait()
}

waitUsingMicroTasks(1000)

然而,这个我认为几乎等价的代码不会导致堆栈溢出:

const waitUsingMicroTasks = async (durationMs, start = performance.now()) => {
    const wait = async () => {
        let elapsed = (performance.now() - start)
        if(elapsed < durationMs) Promise.resolve().then(wait)
    }
    await wait()
}

waitUsingMicroTasks(1000)


1
开始和performance.now()之间的差异很可能为0,因此该函数将立即连续调用自身。 - SPlatten
当然。这个(愚蠢的)函数的目的是阻止宏任务。但是微任务是异步的,并且提供“协作式多任务处理”(如果你知道我的意思)。 - Ben Aston
这是许多系统上的一个问题,处理器和您尝试执行的操作几乎肯定比时钟的分辨率更快,尝试使用计数器并确定迭代次数。 - SPlatten
但是... 我期望每次间接递归调用都会有一个全新的堆栈。所以内存会增加,但我不期望堆栈溢出。 - Ben Aston
3个回答

3

这个递归函数在同一个微任务中完全解决。

当遇到await时,会创建一个新的微任务,你是正确的。但是await操作符需要一个Promise或值,以便隐式地将其包装在新的Promise中并附加回调。这意味着在安排为微任务之前,需要首先评估await的值。

然而,在调用下一个wait之前,每次执行wait都无法获得要await的Promise。因此,实际上堆栈在没有安排微任务的情况下同步溢出。您实际上从未获得过Promise,因为每个Promise都依赖于下一个要评估的Promise,每个wait()调用在任何await被解决之前都会在上一个之后同步发生。

您可能能够通过await一些不需要递归调用即可评估的值来强制进行微任务:

const waitUsingMicroTasks = async (durationMs, start = performance.now()) => {
    const wait = async () => {
        let elapsed = await (performance.now() - start)
        if(elapsed < durationMs) await wait()
    }
    await wait()
}

waitUsingMicroTasks(1000)

在你的第二个例子中,这个问题不存在,因为你明确地创建了一个Promise并将wait作为回调附加到它上面。这样,wait不会立即执行(像await一样),而是在稍后运行Promise.resolve()微任务时调用。

我发现这个似乎可以工作:if(elapsed < durationMs) await 1 && await wait() - Ben Aston
...这似乎也可以工作:if(elapsed < durationMs) ((await 1), wait()) - Ben Aston
这是否意味着 await 右侧的表达式立即以同步方式运行,相当于 Promise 构造函数的执行器函数? - Ben Aston
但是这肯定取决于await是否是async函数中的第一个,因为后续awaits右侧的表达式肯定不会在第一个await解析之前被评估? - Ben Aston
2
@52d6c6af 只是为了澄清:第一个 await 不会立即评估其操作数,它会在代码中实际遇到时进行评估,就像几乎大多数其他运算符一样。请查看运算符优先级页面进行刷新。await只是一个一元运算符,当遇到它时,必须先评估其操作数,然后才能执行 await 的操作。 - Klaycon
显示剩余6条评论

1

Klaycon的回答已经涵盖了所有内容,但很容易观察到你对wait的所有调用都是同步发生的(在堆栈上):

const waitUsingMicroTasks = async (durationMs, start = performance.now()) => {
    let i = 0;

    const wait = async () => {
        console.log(i);
        i += 1;
        (i < 20) &&  (await wait());
    }

    wait();

    console.log('done');
}

waitUsingMicroTasks(5000)

你可以看到这里所有的console.log(i)都在console.log('done')之前执行,所以wait()根本没有异步运行。
正如Klaycon所指出的那样,等待不是wait的递归调用似乎可以解决这个问题,但你还可以采取另一种方法,等待实际上是异步的东西,比如setTimeout

const waitUsingMicroTasks = async (durationMs, start = performance.now()) => {
    let i = 0;
    console.log('starting', new Date());
    while ((performance.now() - start) < durationMs) {
        await Promise.resolve();
    }
    console.log('done', new Date());
}

waitUsingMicroTasks(5000);
console.log('after wait');


在这个例子中,等待setTimeout并不特别有用,因为那会将延迟提升到宏任务的级别,而这个函数的目的是在微任务上阻塞。 - Ben Aston
1
@52d6c6af 好的,说得好。然而,我认为没有必要使用一个复杂的递归函数来解决这个问题。你只需要一个循环和一个 await 就可以了。显然,await 1 就足够了,但是 await Promise.resolve() 可以确保你正在等待一个 promise。我已经编辑了我的答案。 - JLRishe
await 1 等同于 await Promise.resolve(1) 吗? - Ben Aston
我不会说它们完全相同,但似乎任何使用await的操作都会将任务放置在微队列中并等待它完成,因此等待一个常量值可能就足够了。虽然不能以100%的确定性说出来。 - JLRishe
据我所知,await 1 等同于 new Promise((resolve) => resolve(1))。重要的是接下来的部分(我在这个问题中学到了)。在 await 右侧的代码立即被放入 .then 回调函数中,并将在下一个微任务上调度。这是我的理解。我之前的误解是 await 右侧的代码会被安排在微任务上,但事实并非如此。您的循环实现比我的递归实现更清晰。 - Ben Aston
1
@52d6c6af “在 await 右手边的代码会立即被放入 .then 回调函数中。” 我不确定您所说的“立即之后”的含义,但是我认为更好的措辞应该是“使用微任务等待完全评估await的RHS结果。” 所以,如果您有await 1 + 2,您将等待3,而不是未评估的表达式1 + 2。从这个意义上说, await 就像基本上任何其他运算符一样,其操作数(在这种情况下,操作数)在实际执行任何操作之前都会被评估为一个值。 - JLRishe

0

该函数中没有阻塞任务,因此它几乎立即执行下一个递归,由于没有递归跳出,结果导致堆栈溢出。

将耗时任务(如控制台日志)引入代码中,它将正常运行。

let count=0;
const waitUsingMicroTasks = async (durationMs, start = performance.now()) => {
    const wait = async () => {
        let elapsed = (performance.now() - start);
        console.log(++count);
        (elapsed < durationMs) &&  (await wait());
    }
    await wait();  
}

waitUsingMicroTasks(1000)

好的,谢谢。但是这个发现仍然让我感到困惑。因为await会排队一个微任务,所以每次堆栈都应该是新的。我不明白引入延迟如何避免堆栈溢出。 - Ben Aston
1
@52d6c6af 引入延迟只是限制了在达到“durationMs”之前可以进行的递归调用次数。这仅适用于1000毫秒相对较小的情况。它实际上并没有帮助清除堆栈。 - JLRishe
此外,这根本没有实现 OP 所要做的事情,因为它只是阻塞主线程直到足够的时间过去。在那段时间过去之后才会进行任务调度。 - JLRishe

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接