升级就像搬家,你以为只是换个地方住

上周我把项目从Rust 1.89升级到1.90,心想不就是个小版本更新嘛,能有多大事?

结果第二天早上,监控告警把我从床上炸了起来。服务延迟从0.4ms飙到0.5ms,CPU占用从62%涨到71%,P99延迟直接翻倍。

我当时的表情大概是这样的:这不对啊,我什么代码都没改啊?

后来我才明白,Rust 1.90做了一些"看不见"的调整,就像你搬家后发现新房子的插座位置都变了——东西还是那些东西,但用起来就是不顺手了。

到底改了什么?编译器在背后搞小动作

Rust 1.90调整了两个关键的内部行为:

  1. async状态机变"胖"了:以前编译器会激进地优化掉一些中间状态,现在为了安全性保留了更多
  2. 借用检查更严格了:某些以前能"蒙混过关"的写法,现在会触发更深层的生命周期分析

这就像你家的保安以前只检查门禁卡,现在还要刷脸、验指纹、查身份证。安全是安全了,但进门的时间也变长了。

第一个坑:看起来人畜无害的迭代器

先看这段代码,你觉得有问题吗?

async fn compute(data: Vec<u8>) -> usize {
    let sum: usize = data.iter().map(|x| *x as usize).sum();
    sum
}

看起来很正常对吧?但这里藏着一个性能陷阱。

在Rust 1.89里,编译器会把这个迭代器链优化得很干净。但到了1.90,生成的async状态机会保留更多中间帧,因为编译器要确保内存布局的安全性。

实测数据:

版本单次调用延迟
1.8946 微秒
1.9083 微秒

单次调用差了37微秒,听起来不多。但如果你的服务每秒处理几十万次这样的调用,累积起来就是灾难。

怎么救? 把迭代器链改成显式循环:

async fn compute_fast(data: &[u8]) -> usize {
    let mut sum = 0_usize;
    for x in data {
        sum += *x as usize;
    }
    sum
}

这样生成的状态机更简单,1.90也能跑得飞快。实测能恢复17-24%的性能。

第二个坑:poll的"重建成本"变高了

Rust开发者有个隐藏的假设:如果一个Future只poll一次就完成,它的内部状态应该很轻量。

1.90打破了这个假设。

看这个例子:

async fn run() {
    for _ in 0..10 {
        work().await;
    }
}

async fn work() {
    tokio::task::yield_now().await;
}

在1.89里,run()这个Future会复用更多内部状态。但在1.90里,每次循环的重建成本都变高了。

用图来解释:

(轮询路径)
+----------+      +-----------+
|  Future  | ---> |  Poll #1  |
+----------+      +-----------+
      |                 |
      | 完成了吗? ----> 没有
      |                 |
      v                 v
+-----------+      +-----------+
|  重建状态  | <--- | Poll #2   |
+-----------+      +-----------+

1.89会跳过一些重建步骤,1.90为了安全性把它们加回来了。

实际影响:在高吞吐系统里,这个重建开销会增加6-11%的额外负担。

第三个坑:编译时间也变长了

不光运行时性能受影响,编译时间也遭殃了。

看这个简单的函数:

fn parse<'a>(input: &'a str) -> Vec<&'a str> {
    input.split(',').collect()
}

单独看没问题。但如果你的代码库里有大量嵌套迭代器、内联闭包、泛型模块,1.90会做更深层的生命周期解析。

实测编译时间:

代码规模1.89 编译1.90 编译
~8000行8.3秒12.6秒

多了4秒多,如果你的CI每天跑几百次构建,这个时间累积起来也很可观。

自救指南:三板斧

第一板斧:把复杂迭代器拆开

async块里不要写太长的迭代器链:

// 不推荐
async fn process(items: Vec<Item>) -> Vec<Result> {
    items.iter()
        .filter(|x| x.is_valid())
        .map(|x| transform(x))
        .filter_map(|x| x.ok())
        .collect()
}

// 推荐
async fn process(items: Vec<Item>) -> Vec<Result> {
    let mut results = Vec::new();
    for item in items {
        if item.is_valid() {
            if let Ok(r) = transform(&item) {
                results.push(r);
            }
        }
    }
    results
}

第二板斧:CPU密集型任务别放async里

如果有计算密集的逻辑,用spawn_blocking隔离出去:

async fn handler(buf: Vec<u8>) -> usize {
    tokio::task::spawn_blocking(move || heavy_compute(buf))
        .await
        .unwrap()
}

fn heavy_compute(buf: Vec<u8>) -> usize {
    buf.iter().fold(0usize, |s, x| s + *x as usize)
}

spawn_blocking有自己的开销,但能防止async状态机膨胀。

第三板斧:缩短生命周期链条

把长长的迭代器管道拆成几步:

// 不推荐
fn parse_complex(s: &str) -> Vec<&str> {
    s.split(',').filter(|x| !x.is_empty()).map(|x| x.trim()).collect()
}

// 推荐
fn parse_simple(s: &str) -> Vec<&str> {
    let items = s.split(',');
    let filtered: Vec<_> = items.filter(|x| !x.is_empty()).collect();
    filtered.iter().map(|x| x.trim()).collect()
}

什么时候该留在1.89?

有时候"不升级"才是正确选择:

  • 你的系统每秒处理超过20万次async操作
  • 延迟预算卡得很死
  • CPU已经接近饱和
  • 编译时间直接影响部署节奏
  • 你不需要1.90的新特性

可以先在1.89上把热点路径优化好,再升级到1.90重新测试。

什么时候该升级?

1.90也不是一无是处,它带来了:

  • 更可预测的编译器行为
  • 更好的生命周期诊断信息
  • 更少的Future误编译
  • 更稳定的长期生态支持

这次的性能回退是暂时的阵痛,但正确性的提升是长期收益。

诊断清单:你的代码库有风险吗?

快速自检:

  • async函数里有长迭代器链?
  • async块里有CPU密集循环?
  • 有大量1-2次poll就完成的短命Future?
  • 泛型函数里嵌套了很多闭包?
  • 类型之间有复杂的生命周期耦合?

如果勾中3个以上,升级前最好先做性能基准测试。

最后说两句

Rust 1.90没有"破坏"任何东西,它只是暴露了我们代码里那些依赖编译器特定行为的隐藏假设。

这给我们的教训很简单:如果你的性能依赖于编译器的"意外优化",那这个性能就是脆弱的。

用显式循环、清晰的生命周期、把CPU工作和async状态分离——这样你的代码在任何版本的Rust上都能稳定运行。

Rust在进化,最有韧性的系统也会跟着一起进化。


觉得这篇文章有用吗?

如果帮你避开了升级的坑,点个赞让更多人看到。有什么升级踩坑的经历,欢迎在评论区分享,咱们一起避雷。