为何带setTimeout的async递归不会触发栈溢出?
- 不会触发栈溢出的代码
const s = async () => { await new Promise((resolve) => { setTimeout(resolve, 1); }); await s(); console.log("exit"); }; s();
- 会触发栈溢出的代码
const s = async () => { await s(); console.log("exit"); }; s();
案例2会触发栈溢出符合预期,但无法理解案例1为何不会触发栈溢出,以下是对应的技术原理解释:
核心逻辑本质是JS调用栈只记录当前同步执行的函数调用链,一旦函数因为异步等待被挂起,对应的栈帧就会被弹出清空,不会持续占用栈空间,两个案例的执行流程差异可以结合await的执行规则拆解:
await 表达式的执行分两步走:
- 同步计算
await后面的表达式的值,这个过程全程在当前调用栈内执行,不会让出执行权 - 如果表达式返回的是Pending状态的Promise,就把
await后面的所有逻辑包装成回调挂到Promise上,当前函数上下文挂起,对应的栈帧从调用栈弹出,执行权交回给事件循环;等Promise落定后,挂起的回调会被推入任务队列,等调用栈完全清空后,事件循环才会取出回调执行,恢复函数上下文。
案例2触发栈溢出的原因
执行全局的s()时,会压入第一层s的栈帧,进入函数体后首先要同步计算await后面的s()返回值,这会直接触发第二层s()调用,压入第二层s的栈帧;第二层s进入函数体后,又要同步计算它内部await后面的s()返回值,触发第三层s调用……
整个递归过程全程是同步执行的,根本走不到「挂起函数、弹出栈帧」的步骤,调用栈会一层一层持续叠加,很快就超过JS引擎默认的栈深度阈值,直接抛出栈溢出错误。
案例1不触发栈溢出的原因
执行全局的s()时,压入第一层s的栈帧,进入函数体后首先计算第一个await后面的表达式:执行Promise构造函数,注册一个1ms后执行的setTimeout定时器,返回一个Pending状态的Promise。
这时候因为await的是Pending的Promise,当前s函数直接被挂起,对应的栈帧从调用栈弹出,调用栈回到完全清空的状态,执行权交还给事件循环。
等1ms定时器到期,resolve执行,之前挂起的s后续逻辑(也就是await s()和console.log)会被推入任务队列,等事件循环轮询到该任务时,调用栈是空的,才会重新执行下一层s()调用——这时候新的s调用又会重复之前的流程:创建setTimeout定时、挂起、弹栈,等下一轮事件循环再触发下一次递归。
整个递归过程中,调用栈最多只会存在1层s的栈帧,完全达不到栈溢出的深度阈值,自然不会报错。
补充一个容易混淆的点:就算把案例1里的setTimeout换成await Promise.resolve(),代码依然不会触发栈溢出——因为哪怕是立即resolve的Promise,await后面的逻辑也会被推入微任务队列,必须等当前调用栈完全清空才会执行,一样不会出现栈帧持续堆叠的问题。
内容的提问来源于stack exchange,提问作者Hanwen D.

