普通视图

发现新文章,点击刷新页面。
今天 — 2025年8月20日技术

栈溢出优化

作者 猫系七月
2025年8月19日 15:20

前景知识

  • 调用栈是JavaScript引擎追踪函数执行的一个机制
  • 每调用一个函数,JavaScript引擎都会为其创建执行上下文,并把该执行上下文压入调用栈,然后JavaScript引擎开始执行函数代码
  • 当前函数执行完毕后,该函数执行上下文会出栈
  • 当调用栈空间被占满时,会引发堆栈溢出问题

思考下以下代码

function runStack (n) {
  if (n === 0) return 100;
  return runStack( n- 2);
}
runStack(50000)

每次调用 runStack,引擎都会创建一个新的执行上下文,压入 调用栈 (call stack) ,因为 JS 没有保证尾调用优化 (TCO),旧的上下文不会被立即释放,所以当 n=50000 时,调用链条太深,超过了栈深限制 → 爆栈

思考方向:

重点:减少调用栈深度!!!

  • 减少函数执行上下文创建
  • 及时弹出函数执行上下文
  • 分解深度

解决方案

改成循环

直接改成循环,这样不会产生递归调用,栈深度始终为1

function runStack(n) {
  while (n > 0) {
    n -= 2;
  }
  return 100;
}

console.log(runStack(50000));

因为只有一次runStack调用,while 语句只是 在当前执行上下文里反复执行,不会产生新的函数调用,所以无论循环多少次,调用栈的深度都不会增加

但当函数逐渐复杂起来,该方法也没法适用

尾递归+Trampoline

什么是尾递归?

如果一个函数在最后一步直接返回一个函数调用(没有再做额外计算),那就是尾递归

function factorial(n, acc = 1) {
  if (n === 1) return acc;
  return factorial(n - 1, n * acc);
}

调用 factorial(5) 的展开过程:

factorial(5, 1)
= factorial(4, 5)
= factorial(3, 20)
= factorial(2, 60)
= factorial(1, 120)
= 120

这里每次递归调用都直接把结果传下去,函数本身不需要等子函数返回后再做额外运算

所以只要 JS 引擎支持“尾调用优化 (TCO, Tail Call Optimization) ”,编译器就可以复用当前栈帧,不会越递归越深,也就不会栈溢出。

但需要注意:JavaScript 规范(ES6)确实要求引擎实现尾调用优化,但实际上大多数引擎(比如 V8/Chrome、Node.js)没有实现

规范上,JS应该支持尾调用优化,但实际上大多数引擎没有做,怕影响调试体验,所以,在JS里写尾递归只是一种代码风格,并没有性能优势

什么是trampoline 技术?

trampoline(蹦床)技术就是专门用来解决 JS 没有尾调用优化 的场景的

在 JS 里递归会导致调用栈过深爆栈

function sum(n, acc = 0) {
  if (n === 0) return acc;
  return sum(n - 1, acc + n);
}

console.log(sum(100000)); // ❌ RangeError: Maximum call stack size exceeded

这本来是个 尾递归,但因为 JS 引擎没做尾调用优化,还是会压栈 → 最终爆栈

Trampoline 技术

核心思想: 不要让递归函数自己“直接调用自己”,而是 返回一个函数;由一个“蹦床函数”统一负责不断调用,直到得到最终结果

这样,每次递归调用都会立即出栈,不会无限压栈

为什么叫 trampoline(蹦床)?

因为递归函数每次返回的“下一步”会交给蹦床函数执行,像在蹦床上一样“弹回去”,不会一直往下压栈。

  • 普通递归 = 一直往下掉(压栈),最后摔死(爆栈)
  • Trampoline = 每次掉下去都被蹦床弹起来(出栈),所以永远不会爆

再回到问题函数,采用尾调用+trampoline解决

实现步骤:

  1. 将递归函数改写为返回函数
  2. 写一个trampoline函数,不断执行返回函数,直到拿到结果
// 1、将递归函数改为返回函数的形式
function runStackRec() {
  if (n <= 0) {
    return 100;
  }
  return () => runStackRec(n - 2);
}

// 2、使用trampoline函数来处理递归调用
function trampoline(n) {
  return function tramp(fn) {
    let result = fn();
    while (typeof result === 'function') {
      result = result();
    }
    return result;
  }
}

const runStack = trampoline(runStackRec);

来逐步拆解下,以runStack(6)为例:

  1. 进入包装函数
  • 栈:[global → runStack(wrapper)]
  • 执行 result = fn(6)
    • 栈:[global → wrapper → runStackRec(6)]
    • runStackRec(6) 返回 thunk th1,立刻 返回到 wrapper
  • 栈恢复为:[global → wrapper]
  1. while 循环第一次
  • 调用 th1()
    • 栈:[global → wrapper → th1]
    • th1 内部调用 runStackRec(4)
      • 栈:[global → wrapper → th1 → runStackRec(4)]
      • 返回 thunk th2,随后 th1 返回到 wrapper
  • 栈恢复为:[global → wrapper]
  1. 第二次循环
  • 调用 th2() → 内部 runStackRec(2) → 返回 th3 → 回到 wrapper
  • 栈峰值仍然是类似的 3~4 层。
  1. 第三次循环
  • 调用 th3() → 内部 runStackRec(0) → 直接返回 100(不是函数)。
  • while 结束,wrapper 返回 100

峰值栈深是常数:最多就 wrapper → thunk → runStackRec(再加一层全局)。
n 无关,不会像递归那样随着 n 线性增长

虽然runStackRec 被调用很多次,也会创建很多执行上下文,但这些调用是一个接着一个发生的,前一个先返回,栈清空后才进行下一个,所以调用总次数多调用栈深,trampoline 把“深度”换成了“次数”,避免栈溢出

分片异步

调用栈始终是同步的,只负责执行当前上下文里的代码,异步函数不会直接进入调用栈,而是进入任务队列,等调用栈清空后,事件循环才会把它推入调用栈执行,利用以上特性,可以将5000拆解为一段一段的,以此来解决栈溢出

function runStackAsync(n, callback) {
  function step() {
    if (n <= 0) {
      callback(100);
      return;
    }
    n -= 2;
    if (n % 1000 === 0) {
      // 每 1000 次让出一次事件循环
      setTimeout(step, 0);
    } else {
      step();
    }
  }
  step();
}

runStackAsync(50000, (res) => {
  console.log(res);
});

来逐步拆解下

  1. 初始调用
  • 执行 runStackAsync(50000, callback) → 进入函数,调用 step()
  • 调用栈: [global → runStackAsync → step]
  1. 循环执行 step
  • n=50000,大于 0,减 2 → n=49998
  • 因为 49998 % 1000 ≠ 0,所以递归调用 step()
  • 注意:这时候调用的是 同步递归,会立即创建新的上下文压栈。 所以一开始确实还是像递归一样堆积栈。
  1. 关键点:setTimeout
  • 当执行到 n % 1000 === 0 时,比如 n=49000,触发:
    setTimeout(step, 0);
    
  • 发生了什么?
    • 当前这层 step 没有继续递归调用,而是安排了一个 异步任务(放到任务队列)。
    • 这层 step 立即返回 → 调用栈开始清空。
  1. 下一轮事件循环
  • 当调用栈清空后,事件循环会取出队列里的 step 并执行。
  • 相当于“接力”,在一个全新的调用栈里继续运行 step()
  • 又会执行 1000 次同步递归,然后再次 setTimeout → 清空栈 → 下一次循环。

这里通过把大任务切分,每次做一部分,然后通过setTimeout 把“下一段递归”放到 事件循环的下一轮,每次 setTimeout 触发时,之前的调用栈都已经释放了,所以 栈深度永远有限

🚩 特点总结

不会栈溢出,因为调用被分散到多轮事件循环

不会长时间阻塞主线程,UI 有机会在片段之间更新

多了事件循环切换,性能比纯循环或 trampoline 差一些(但换来更好的“流畅性”)

总结

  • 普通递归:一次性背着 50000 层楼梯下楼,太重了会崩
  • Trampoline:一次背一层,但要走 50000 次
  • 分片异步:每次背 1000 层,休息一下(让 UI 更新),再继续。这样既不会爆栈,也不会让人觉得“卡死”

学Python,先把这“三板斧”练到炉火纯青!(零基础也能看懂)

2025年8月20日 14:15
今天我就用最白话的方式,带你把这三块地基踩实。不需要背诵复杂的定义,也不用怕看不懂数学符号,保证你能“秒懂+立刻上手”。 一、顺序结构 —— “排队做事,先来后到” 编程世界里最基础的,就是顺序执行。
❌
❌