深夜两点,手机像报警器一样疯狂震动。打开电脑,监控面板上全是红色告警,线上服务已经无响应。翻看日志,心跳停了,进程也没了,最后的输出是一串类似 thread 'actix-rt worker' has overflowed its stack 之类的错误信息。看到“stack overflow”,我的心基本凉了半截。

这不是一个偶发错误,而是必现的。只要某个请求带着一个稍微大一点的参数进来,整个进程就会瞬间崩溃。后来我们把请求放回本地,用同样的参数一压测,立刻复现。通过回溯调用栈,定位到问题出在一个在 Actix Web 内部触发的深层递归函数上。追根溯源,是“递归处理”加“Actor 调用链”共同把默认的线程栈给吃满了。

一、事故现场:服务在深夜突然崩了

先说结论:这不是 Actix Web 的锅,而是我们对“栈”这个资源太不敏感了。一个不起眼的递归函数,加上 Actor 之间的深层调用链,就能把默认的线程栈烧得一干二净。

我们线上是一个典型的 Actix Web 服务,接收前端请求,然后分发给不同的 Actor 去处理。某天发布了一个新功能,涉及对树形结构做递归解析。上线后一切正常,直到有人传了一棵特别深的树,进程立马崩。第一次崩的时候,大家还以为是数据库连接问题,后来看了 core dump 才确认是栈溢出。

二、栈溢出的基本原理

2.1 什么叫调用栈

写代码的时候,每个函数调用都会在内存里画出一道“楼层”。函数 A 调用函数 B,B 调用 C,每层都记录自己的局部变量、返回地址。这些楼层连续排在一起,就是调用栈。函数返回时,楼层一层层拆掉,回到调用方。栈的大小是固定的,从操作系统那里一次性申请好。

你可以想象成餐厅里从下往上摞盘子。每调用一次函数,就在最顶上放一个新盘子;每次返回,就取走一个。如果只顾着放不取走,盘子迟早会顶到天花板,甚至倒下来。

2.2 默认栈空间有多大

Rust 里创建一个新线程,默认栈大小是 2MB。我的主流操作系统线程一般有 8MB,但线上服务通常跑在框架自动创建的 worker 线程上,这些线程往往不会特意调大栈。Actix Web 的异步运行时会在线程池里安排任务,每个线程默认栈大小也不会超过这个数。2MB 听着挺大,但对于递归来说,一次函数调用可能占用几百字节,极端情况下几十万层递归就能把栈吃得干干净净。

2.3 递归为什么会耗尽栈

递归就是函数自己调用自己。如果递归没有终止条件或者深度过大,就会不断往栈里“盖楼”。我们常写的递归,比如算阶乘、遍历二叉树,看起来优雅,但每一层都会保留一个等待返回的上下文。等到某层返回时,下面那些层才能继续执行。这种结构天然吃栈。如果你的递归深度超过了几万甚至十几万,2MB 的栈会瞬间见底。

这里附带提一下尾递归。如果递归调用是函数体的最后一个动作,有些语言会优化成循环,不再占栈。但 Rust 目前不保证做这种优化,所以别指望编译器帮你兜底。

三、Actix Web 是怎么把问题放大的

3.1 Actor 模型下的调用链

Actix Web 底层是 Actor 模型。请求进来,会落到某个 Actor 的处理函数上。处理函数可以再给另一个 Actor 发消息,于是形成一条调用链。如果链上的每个 Actor 都正常用异步发送,没有同步等待,栈占用是平稳的。但一旦某个 Actor 在处理函数里走了同步递归,等于把一条本来很轻的链子压上了千斤重担。

同时,Actor 之间如果还有嵌套的同步访问,栈上的帧会层层叠加。线上这种问题不容易一眼看出来,因为你看到的代码是一个 Actor 调用另一个 Actor,消息发出去就结束了,似乎没什么问题。真正的问题藏在某个 Actor 内部,那个递归函数像是藏在管道里的炸弹,等参数一到就引爆。

3.2 一个活生生的例子

技术栈:Rust + Actix Web

// 技术栈:Rust + Actix Web
use actix::prelude::*;

// 定义一个消息:让Actor处理一个深度递归任务
struct Compute {
    depth: usize,
}

impl Message for Compute {
    type Result = usize;
}

// 定义Actor
struct Worker;

impl Actor for Worker {
    type Context = Context<Self>;
}

// 这是一个纯粹的递归函数,不是尾递归
// 计算 1 + 2 + ... + n,注意这种写法很危险
fn sum_recursive(n: usize) -> usize {
    if n == 0 {
        0
    } else {
        n + sum_recursive(n - 1) // 先递归,再相加,每次调用都保留栈帧
    }
}

impl Handler<Compute> for Worker {
    type Result = usize;

    fn handle(&mut self, msg: Compute, _ctx: &mut Context<Self>) -> Self::Result {
        // 直接在actor的处理线程里执行递归,完全不做保护
        sum_recursive(msg.depth)
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let addr = Worker.start();

    // 深度控制在100,没问题
    if let Ok(v) = addr.send(Compute { depth: 100 }).await {
        println!("深度100的结果:{}", v);
    }

    // 深度放大到100000,栈直接爆炸
    // 注意:这里很可能直接终止进程,根本到不了打印错误的那一步
    let result = addr.send(Compute { depth: 100_000 }).await;
    match result {
        Ok(v) => println!("深度100000的结果:{}", v),
        Err(e) => eprintln!("服务器炸了:{:?}", e),
    }

    Ok(())
}

在这个例子里,Actor 的处理函数收到消息后,直接同步执行递归函数。深度 100 没问题,深度变成 10 万后,线程栈很快耗尽,进程直接崩溃,而不是优雅地返回错误。这就是线上的事故重现。

3.3 为什么异步环境救不了你

有的同学说,Actix Web 不是异步吗?异步的话可以用等待语法来挂起,不会占栈吧?这里要分清:同步递归是在一次函数调用内部进行的,它不会因为外层是异步就自动变成异步。

异步函数在执行到等待点时,会把当前状态保存到堆上的对象里,把线程让出去。但普通递归函数没有等待点,它必须依赖栈来保存中间状态。因此异步运行时能管理的是异步任务的挂起与恢复,管不了同步递归。哪怕你坐在一个异步运行时里,只要你在某个函数体里写了一个死递归,它照样会在线程栈上疯狂叠加。

四、解决问题的几条路

4.1 把递归改成迭代

最容易想到的办法,也是大多数场景下的最佳方案:用循环替代递归。循环不新增栈帧,深度再大也就一个循环在跑。

技术栈:Rust + Actix Web

// 技术栈:Rust + Actix Web
use actix::prelude::*;

struct Compute {
    depth: usize,
}

impl Message for Compute {
    type Result = usize;
}

struct Worker;

impl Actor for Worker {
    type Context = Context<Self>;
}

// 用迭代代替递归,栈安全
fn sum_iterative(mut n: usize) -> usize {
    let mut acc = 0;
    while n > 0 {
        acc += n;
        n -= 1;
    }
    acc
}

impl Handler<Compute> for Worker {
    type Result = usize;

    fn handle(&mut self, msg: Compute, _ctx: &mut Context<Self>) -> Self::Result {
        sum_iterative(msg.depth)
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let addr = Worker.start();

    // 深度100000也没问题
    if let Ok(v) = addr.send(Compute { depth: 100_000 }).await {
        println!("深度100000的结果:{}", v);
    }

    Ok(())
}

这段代码把递归改成累加,虽然看起来不够“数学”,但这是线上生存的智慧。记住,处理树、链表、JSON 这些结构时,大部分递归都能改写成显式栈的迭代版本。显式栈可以放在堆上,大小不受线程栈限制。

4.2 限制递归深度

有些算法写成递归很清晰,不好改。那么可以加一个深度保护,超过阈值就抛错。比如处理一个嵌套 JSON,你可以规定最多嵌套 500 层。

技术栈:Rust + Actix Web

// 技术栈:Rust + Actix Web
use actix::prelude::*;

struct Compute {
    depth: usize,
}

impl Message for Compute {
    type Result = Result<usize, String>;
}

struct Worker;

impl Actor for Worker {
    type Context = Context<Self>;
}

fn safe_sum_recursive(n: usize) -> Result<usize, String> {
    const MAX_DEPTH: usize = 1000;
    if n > MAX_DEPTH {
        return Err("超过最大允许深度".to_string());
    }
    if n == 0 {
        Ok(0)
    } else {
        // 递归前先算子结果,错误会向上传播
        Ok(n + safe_sum_recursive(n - 1)?)
    }
}

impl Handler<Compute> for Worker {
    type Result = Result<usize, String>;

    fn handle(&mut self, msg: Compute, _ctx: &mut Context<Self>) -> Self::Result {
        safe_sum_recursive(msg.depth)
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let addr = Worker.start();

    // 100000就会被拒,不会崩
    match addr.send(Compute { depth: 100_000 }).await {
        Ok(Ok(v)) => println!("结果:{}", v),
        Ok(Err(e)) => println!("请求被拒绝:{}", e),
        Err(e) => eprintln!("actor通信失败:{:?}", e),
    }

    Ok(())
}

注意看,这个函数比原来多返回一个结果类型,一旦超过阈值就立刻停止递归,避免栈被吃穿。这是一种实用主义思路,也是我推荐的兜底方案。

4.3 给特殊任务开一个大栈线程

有些老库、老算法真的没法改。别硬着头皮全改成迭代,那可能引入新问题。这时候可以自己开一个线程,指定更大的栈,然后把递归任务扔进去。Rust 标准库的线程构造器允许在创建线程时设置栈大小。

技术栈:Rust + Actix Web

// 技术栈:Rust + Actix Web
use actix::prelude::*;
use std::thread;

struct Compute {
    depth: usize,
}

impl Message for Compute {
    type Result = usize;
}

struct Worker;

impl Actor for Worker {
    type Context = Context<Self>;
}

fn sum_recursive(n: usize) -> usize {
    if n == 0 {
        0
    } else {
        n + sum_recursive(n - 1)
    }
}

fn run_on_big_stack(depth: usize) -> usize {
    // 创建一个栈大小为256MB的新线程,把递归放进去
    let handle = thread::Builder::new()
        .stack_size(256 * 1024 * 1024)
        .spawn(move || sum_recursive(depth))
        .expect("创建线程失败");
    handle.join().expect("线程执行失败")
}

impl Handler<Compute> for Worker {
    type Result = usize;

    fn handle(&mut self, msg: Compute, _ctx: &mut Context<Self>) -> Self::Result {
        // 用大栈线程来跑,避免占满actor线程的栈
        run_on_big_stack(msg.depth)
    }
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let addr = Worker.start();

    let v = addr.send(Compute { depth: 100_000 }).await.unwrap();
    println!("深度100000的结果:{}", v);

    Ok(())
}

这个方案很直接,但也有代价:创建一个 256MB 的线程非常奢侈,而且线程是同步阻塞的,如果高并发下调用,会拖垮整个服务。所以这种方案只适合低频、非核心、实在无路可走的任务。平时当救命稻草可以,别当成常规手段。

4.4 善用日志和监控

栈溢出往往不是瞬间爆发的,它有一个逐步逼近的过程。上线之后,应该监控线程栈的使用量,设置阈值告警。另外,在处理复杂请求前,可以主动记录请求深度,一旦发现异常就拒绝。千万不要等到进程崩了才去翻日志。

线上日志每一条都值得重视,特别是 stack overflow 这种关键字,可以直接接入告警系统。很多服务就倒在对日志的忽视上,等发现时进程已经凉透了。

五、应用场景、技术优缺点与注意事项

5.1 容易踩坑的应用场景

哪类开发最容易碰到这个问题?我总结几个:

  • 对树形结构做递归遍历,比如组织架构、评论楼中楼、文件目录。
  • 深度解析嵌套 JSON 或 XML,数据源不信任外部输入。
  • 写图算法,比如深度优先搜索在邻接表上直接递归。
  • Actor 链路上每一环都处理消息,消息内容中包含递归结构。

想象一下,你有一个解析器,输入是一个嵌套了 1000 层的 JSON,代码用递归函数一层层往下走。每层都分配几个局部变量,比如当前节点的名字、子节点列表的迭代器。明明没多少数据,内存却涨得飞快,最后栈先崩了。这种问题在开发环境很难遇到,因为测试数据往往只有几层;一到线上,真实用户的数据五花八门,分分钟给你整出个一千层。

如果你们项目里有这些场景,就得小心了。尤其是对外提供 API 的 Actix Web 服务,只要恶意用户传一个深度极大的 JSON,就可能直接打崩进程。

5.2 技术优缺点

先说说递归本身。优点是代码简洁,特别适合描述分治、树形、层级这类结构,读起来和自然语言一样。缺点是栈压力大,深度不可控时就是定时炸弹。

再聊聊 Actix Web 的 Actor 模型。优点是把并发拆成独立 Actor,数据隔离,消息驱动,写起来很舒服。缺点是不太容易察觉“同步递归”和“异步调用链”的区别。一旦在处理函数里出现同步递归,actor 线程就成了火药桶。如果再叠上 Actor 之间的同步通信,爆炸半径会更大。

另外,Actix Web 的默认工作线程数通常和 CPU 核数相关,线程数少,栈溢出时更容易整体崩溃;如果线程数多,可能只是部分请求失败。但无论哪种,只要栈被写穿,进程基本活不下来。

5.3 注意事项

几个非常重要的提醒:

  • 别盲目相信第三方库的输入,凡是递归处理外部数据,必须先限制深度或做校验。
  • 异步环境不能消除同步递归,不要以为加了异步等待就万事大吉。
  • 用阻塞任务执行工具跑 CPU 密集型任务时,也要注意同样的递归风险。
  • 在 Actix 的 Actor 里,尽量把递归函数放到独立的大栈线程或阻塞任务队列中,并设置深度上限。
  • 不要为了省事把栈改成无限大,操作系统对栈大小有硬限制,过度设置反而会降低线程创建效率。
  • 上线前做压力测试,用大深度、大数据量的样本去试,让问题在测试环境先爆出来。
  • 把 stack overflow、SIGSEGV 等关键错误配置为最高优先级告警,确保第一时间收到通知。

六、文章总结

这次线上血案,本质上不是 Actix Web 的锅,而是我们对递归和栈空间的认知不够。Actix Web 的默认线程栈空间有限,深层 Actor 调用链习惯上又是同步串联,再加上不受控的递归处理,三股力量凑在一起,把服务推向了崩溃。

解决起来也不复杂:优先用迭代替代递归,给递归加深度保护,必要时牺牲一点性能开大栈线程。应用场景、优缺点和注意事项我们都梳理过了,核心就是一句话——栈空间是稀缺资源,递归深度必须掌握在自己手里。下次再有人把递归当魔术一样写,别急着鼓掌,先看一眼栈大小再说。