Rust 1.92.0 于 2025 年 12 月 11 日发布。这个版本没有惊天动地的新特性,但带来了一批等了很久的实用 API。作为一个写了几年 Rust 的开发者,看到这些 API 终于稳定,感觉就像是"终于不用自己造轮子了"。

本文将深入分析这些新 API 的实战价值、生态影响和设计哲学。

总览:这个版本稳定了什么?

并发相关

  • RwLockWriteGuard::downgrade

智能指针零初始化

  • Box::new_zeroed / new_zeroed_slice
  • Rc::new_zeroed / new_zeroed_slice
  • Arc::new_zeroed / new_zeroed_slice

数值计算

  • NonZero<u{N}>::div_ceil

集合操作

  • btree_map::Entry::insert_entry
  • btree_map::VacantEntry::insert_entry

FFI / 调试

  • Location::file_as_c_str

过程宏

  • TokenStream: Extend<Group/Literal/Punct/Ident>

常量化

  • <[_]>::rotate_left / rotate_right (const)

让我们逐个深入。

一、RwLockWriteGuard::downgrade:等了九年的 API

这是什么?

impl<T: ?Sized> RwLockWriteGuard<'_, T> {
    pub fn downgrade(self) -> RwLockReadGuard<'_, T>;
}

简单说:把写锁降级为读锁,而不释放锁

使用场景

downgrade 解决的核心问题:修改数据后,需要继续持有锁读取,同时希望其他线程也能并发读

use std::sync::{RwLock, RwLockWriteGuard};
use std::thread;
use std::time::Duration;

fn update_and_compute(data: &RwLock<Vec<i32>>) -> i64 {
    // 1. 获取写锁,修改数据
    let mut write = data.write().unwrap();
    write.push(42);

    // 2. 降级为读锁:原子操作,没有时间窗口
    let read = RwLockWriteGuard::downgrade(write);

    // 3. 耗时的只读计算(此时其他线程可以并发读!)
    thread::sleep(Duration::from_millis(100));
    read.iter().map(|&x| x as i64).sum()
}

fn main() {
    let data = RwLock::new(vec![1, 2, 3]);
    let result = update_and_compute(&data);
    println!("结果: {}", result);  // 输出: 结果: 48
}

为什么不能先释放写锁再获取读锁? 因为存在时间窗口:

use std::sync::RwLock;

fn update_and_compute_bad(data: &RwLock<Vec<i32>>) -> i64 {
    {
        let mut write = data.write().unwrap();
        write.push(42);
    }  // 写锁释放

    // 时间窗口:其他线程可能在这里修改数据!

    let read = data.read().unwrap();
    read.iter().map(|&x| x as i64).sum()  // 读到的可能不是我们刚修改的数据
}

fn main() {
    let data = RwLock::new(vec![1, 2, 3]);
    let result = update_and_compute_bad(&data);
    println!("结果: {}", result);  // 单线程下输出 48,但多线程下可能不一致
}

downgrade 是原子操作,锁从未被释放,保证读到的一定是自己刚修改的数据。

生态影响

这个 API 对以下场景特别有价值:

  1. 配置热更新:更新配置后,基于新配置执行长时间的只读操作,同时让其他线程可以读取新配置
  2. COW(Copy-on-Write)数据结构:修改后需要长时间读取,降级让其他读者不必等待
  3. 缓存预热:写入缓存后,立即基于缓存数据进行计算,同时允许其他线程读取

一些第三方库(如 parking_lot)早就提供了这个功能,现在标准库终于跟上了。

设计哲学

为什么这个 API 等了这么久?因为 Rust 标准库对 API 的稳定非常谨慎。downgrade 涉及:

  • 语义清晰性:降级后原来的写锁失效,这通过 self 所有权转移来保证
  • 平台兼容性:不同操作系统的 RwLock 实现不同,需要确保所有平台都能正确实现
  • upgrade 的对称性:Rust 故意没有提供 upgrade(读锁升级为写锁),因为这会导致死锁
// 为什么没有 upgrade?假设两个线程都持有读锁并尝试升级:
// 线程 A: 持有读锁,等待所有读锁释放以升级为写锁
// 线程 B: 持有读锁,等待所有读锁释放以升级为写锁
// 结果:死锁!双方都在等对方释放读锁

downgrade 没有这个问题,因为写锁本身就是独占的。

二、零初始化智能指针:性能优化的新选择

新增的 API

// Box
impl<T> Box<T> {
    pub fn new_zeroed() -> Box<MaybeUninit<T>>;
}
impl<T> Box<[T]> {
    pub fn new_zeroed_slice(len: usize) -> Box<[MaybeUninit<T>]>;
}

// Rc 和 Arc 同理
impl<T> Rc<T> {
    pub fn new_zeroed() -> Rc<MaybeUninit<T>>;
}
impl<T> Arc<T> {
    pub fn new_zeroed() -> Arc<MaybeUninit<T>>;
}

这解决什么问题?

传统的 Box::new(value) 流程是:

  1. 在栈上创建 value
  2. 在堆上分配内存
  3. value 从栈复制到堆

对于大型数据结构,第 3 步的复制开销可能很大:

// 问题:语义上,这个数组先在栈上创建,再复制到堆上
let big_array = Box::new([0u8; 1024 * 1024]);  // 1MB

// 更糟:如果栈空间不够,直接栈溢出
// Debug 模式 + 默认栈大小(通常 8MB)下,10MB 数组必定溢出
let huge_array = Box::new([0u8; 10 * 1024 * 1024]);

等等,Release 模式好像不会栈溢出?

上面的代码在 --release 模式下并不会栈溢出。这是因为 LLVM 优化器足够聪明,能够识别这种模式并直接在堆上分配:

Debug 模式(opt-level = 0):
  语义上的流程:栈上创建 → 堆上分配 → 复制
  实际结果:大数组超出默认栈大小,直接栈溢出

Release 模式(opt-level = 3,rustc 1.92.0 测试):
  零初始化 [0u8; N]:直接调用 __rust_alloc_zeroed
  常量填充 [val; N]:__rust_alloc + memset
  → 完全跳过栈分配,不会栈溢出

我们可以通过汇编验证这一点。以下是 Release 模式下 Box::new([0u8; 1024 * 1024]) 在 ARM64 macOS 上的汇编:

; 直接调用 alloc_zeroed,没有栈分配!
mov  w0, #1048576        ; 大小 = 1MB
mov  w1, #1              ; 对齐 = 1
bl   __rust_alloc_zeroed ; 直接堆分配 + 零初始化

但这不意味着可以依赖这个优化! 原因如下:

  1. 不是语言保证:这是 LLVM 的优化行为,不同编译器版本、不同优化级别、不同代码结构可能有不同结果
  2. Debug 构建仍会失败:开发阶段的测试可能因此遗漏问题
  3. 复杂场景可能失效:如果数组初始化逻辑更复杂,优化器可能无法识别

以下场景即使在 Release 模式下也会栈溢出(rustc 1.92.0 验证):

const SIZE: usize = 10 * 1024 * 1024;  // 10MB

// ❌ 场景 1:std::array::from_fn 初始化
// from_fn 需要逐元素调用闭包,无法优化为单次堆分配
let arr1: Box<[u8; SIZE]> = Box::new(std::array::from_fn(|i| (i % 256) as u8));

// ❌ 场景 2:array.map() 转换
// map 返回新数组,整个转换过程在栈上完成后才能装箱
let source = [0u8; SIZE];
let arr2: Box<[u8; SIZE]> = Box::new(source.map(|x| x.wrapping_add(1)));
// ❌ 场景 3:通过 #[inline(never)] 函数包装
// 禁止内联后,数组必须在调用方构造完成后传递给函数
#[inline(never)]
fn wrap_box<T>(value: T) -> Box<T> {
    Box::new(value)
}

fn main() {
    let arr = wrap_box([0u8; 10 * 1024 * 1024]);  // 栈溢出
}

这些场景的共同点:编译器无法将"构造数组"和"堆分配"合并为一步操作,必须先在栈上完成数组构造。

Box::new_zeroed() 是 API 保证——无论什么优化级别,都会直接在堆上分配。这才是生产代码应该使用的方式。

零初始化的优势

fn main() {
    // 固定大小数组:直接在堆上分配并零初始化
    let big_array: Box<[u8; 1024]> = unsafe {
        Box::new_zeroed().assume_init()
    };
    println!("数组长度: {}", big_array.len());

    // 动态大小切片
    let dynamic_array: Box<[u8]> = unsafe {
        Box::new_zeroed_slice(1024).assume_init()
    };
    println!("切片长度: {}", dynamic_array.len());
}

为什么返回 MaybeUninit 对于 new_zeroed() 的典型用例(数值数组),全零总是有效的。但 Rust 类型系统无法证明"全零对于任意 T 是有效的",所以 API 采用保守设计:返回 MaybeUninit<T>,让程序员通过 unsafe { assume_init() } 显式承诺类型有效性。

实战用法

use std::mem::MaybeUninit;
use std::sync::Arc;

fn create_shared_buffer(size: usize) -> Arc<[u8]> {
    let uninit: Arc<[MaybeUninit<u8>]> = Arc::new_zeroed_slice(size);
    // MaybeUninit<u8> 和 u8 有相同的内存布局
    unsafe { Arc::from_raw(Arc::into_raw(uninit) as *const [u8]) }
}

fn main() {
    let buffer = create_shared_buffer(1024);
    println!("缓冲区大小: {} 字节", buffer.len());
    println!("全为零: {}", buffer.iter().all(|&x| x == 0));
}

性能对比

场景:分配 10MB 缓冲区(Debug 模式或优化失效时)

Box::new([0u8; 10 * 1024 * 1024])
  1. 栈上分配 10MB(可能栈溢出)
  2. 堆上分配 10MB
  3. 复制 10MB
  总计:20MB 内存操作 + 复制开销

Box::new_zeroed() + assume_init()
  1. 堆上分配 10MB
  2. 操作系统零初始化(利用零页映射,接近零成本)
  总计:10MB 内存操作,无复制,无栈溢出风险

设计哲学

这组 API 体现了 Rust 的一个核心理念:零成本抽象

  • MaybeUninit 在编译后完全消失,没有运行时开销
  • 通过类型系统强制程序员处理"未初始化"状态
  • 提供 unsafe 逃生舱口,但把责任明确交给程序员

这也是为什么 API 返回 MaybeUninit——虽然对于典型用例全零总是有效的,但类型系统无法表达这个约束,所以用 unsafe 让程序员显式承诺。

三、NonZero::div_ceil:小而美的补全

新增的 API

impl NonZero<u8> {  // 以及 u16, u32, u64, u128, usize
    pub const fn div_ceil(self, rhs: Self) -> Self;
}

除法向上取整,且保证结果也是 NonZero

为什么需要这个?

div_ceil(向上取整除法)是一个常见操作:

// 场景:计算需要多少个页面来存储 n 字节
fn pages_needed(bytes: usize, page_size: usize) -> usize {
    (bytes + page_size - 1) / page_size  // 传统写法,容易溢出
    // 或
    bytes.div_ceil(page_size)  // 标准库方法,1.73.0 稳定
}

但对于 NonZero 类型,之前没有这个方法:

use std::num::NonZero;

fn main() {
    let items = NonZero::new(100usize).unwrap();
    let batch_size = NonZero::new(30usize).unwrap();

    // 之前:需要转换
    let batches_old = NonZero::new(items.get().div_ceil(batch_size.get())).unwrap();

    // 现在:直接调用
    let batches = items.div_ceil(batch_size);

    println!("100 / 30 向上取整 = {}", batches.get());  // 输出: 4
}

为什么返回值也是 NonZero?

数学保证:如果被除数和除数都是正整数,向上取整的结果至少是 1

1 / 1 = 1  (向上取整)
1 / 100 = 1  (向上取整)
99 / 100 = 1  (向上取整)
100 / 100 = 1

所以返回 NonZero 是类型安全的——不可能返回零

生态影响

NonZero 类型在以下场景广泛使用:

  1. 内存布局优化Option<NonZero<u32>>u32 大小相同
  2. 类型安全:避免除零错误
  3. FFI:很多 C API 用非零值表示有效句柄

这个 API 让 NonZero 在数值计算场景更加实用。

四、BTreeMap::Entry::insert_entry:补齐最后一块拼图

新增的 API

impl<'a, K: Ord, V> Entry<'a, K, V> {
    pub fn insert_entry(self, value: V) -> OccupiedEntry<'a, K, V>;
}

impl<'a, K: Ord, V> VacantEntry<'a, K, V> {
    pub fn insert_entry(self, value: V) -> OccupiedEntry<'a, K, V>;
}

与现有 API 的区别

use std::collections::BTreeMap;

fn main() {
    let mut map: BTreeMap<&str, i32> = BTreeMap::new();

    // 现有的 or_insert 方法:返回 &mut V
    let value_ref: &mut i32 = map.entry("key").or_insert(42);
    println!("value_ref = {}", value_ref);

    // 新的 insert_entry 方法:返回 OccupiedEntry
    let entry = map.entry("key2").insert_entry(100);
    println!("key = {}, value = {}", entry.key(), entry.get());
}

使用场景

use std::collections::BTreeMap;

fn insert_if_valid<K: Ord, V>(
    map: &mut BTreeMap<K, V>,
    key: K,
    value: V,
    is_valid: impl FnOnce(&K, &V) -> bool,
) -> Option<V> {
    let entry = map.entry(key).insert_entry(value);
    if !is_valid(entry.key(), entry.get()) {
        Some(entry.remove())
    } else {
        None
    }
}

fn main() {
    let mut map: BTreeMap<i32, i32> = BTreeMap::new();
    // 100 >= 50,不满足条件,被移除
    let removed = insert_if_valid(&mut map, 1, 100, |_k, v| *v < 50);
    println!("removed = {:?}", removed);  // 输出: Some(100)
}

设计哲学

这个 API 体现了 Rust 集合 API 的一个原则:Entry API 应该是完备的

Entry 模式是 Rust 集合的标志性设计,它解决了"查找后插入"的效率问题:

// 低效:两次查找
if !map.contains_key(&key) {
    map.insert(key, compute_value());
}

// 高效:一次查找
map.entry(key).or_insert_with(|| compute_value());

insert_entry 补齐了 Entry API 的最后一块:插入后继续操作条目

五、Location::file_as_c_str:FFI 友好的调试信息

新增的 API

impl<'a> Location<'a> {
    pub fn file_as_c_str(&self) -> &'a CStr;
}

这是什么?

std::panic::Location 用于获取代码位置信息(文件名、行号、列号),常用于 #[track_caller]

#[track_caller]
fn my_unwrap<T>(opt: Option<T>) -> T {
    match opt {
        Some(v) => v,
        None => {
            let loc = std::panic::Location::caller();
            panic!("Called unwrap on None at {}:{}:{}",
                   loc.file(), loc.line(), loc.column());
        }
    }
}

为什么需要 C 字符串版本?

当你需要把位置信息传给 C 代码时:

use std::ffi::{CString, c_char};

// FFI 场景:调用 C 的日志库
extern "C" {
    fn c_log(file: *const c_char, line: u32, message: *const c_char);
}

#[track_caller]
fn log_from_rust(message: &str) {
    let loc = std::panic::Location::caller();
    let message_cstr = CString::new(message).unwrap();

    // 之前:需要额外分配
    // let file_cstr = CString::new(loc.file()).unwrap();

    // 现在:零分配
    let file_cstr = loc.file_as_c_str();

    unsafe {
        c_log(file_cstr.as_ptr(), loc.line(), message_cstr.as_ptr());
    }
}

设计哲学

这个 API 很小,但体现了 Rust 的零成本互操作理念:

  • 文件名字符串本来就以 null 结尾存储在二进制中
  • file_as_c_str 只是暴露了这个事实,没有任何运行时开销
  • 对于 FFI 密集型代码,这些小优化累积起来很可观

六、TokenStream 扩展:过程宏的人体工程学改进

新增的 API

impl Extend<Group> for TokenStream { ... }
impl Extend<Ident> for TokenStream { ... }
impl Extend<Literal> for TokenStream { ... }
impl Extend<Punct> for TokenStream { ... }

之前怎么做?

use proc_macro::{TokenStream, TokenTree, Ident, Literal, Punct, Spacing, Span};

fn build_token_stream() -> TokenStream {
    let mut tokens = TokenStream::new();

    // 之前:必须先转换为 TokenTree
    tokens.extend(std::iter::once(TokenTree::Ident(
        Ident::new("hello", Span::call_site())
    )));
    tokens.extend(std::iter::once(TokenTree::Punct(
        Punct::new(',', Spacing::Alone)
    )));
    tokens.extend(std::iter::once(TokenTree::Literal(
        Literal::string("world")
    )));

    tokens
}

现在:更直接

use proc_macro::{TokenStream, Ident, Literal, Punct, Spacing, Span};

fn build_token_stream() -> TokenStream {
    let mut tokens = TokenStream::new();

    // 现在:直接 extend
    tokens.extend([Ident::new("hello", Span::call_site())]);
    tokens.extend([Punct::new(',', Spacing::Alone)]);
    tokens.extend([Literal::string("world")]);

    tokens
}

生态影响

过程宏是 Rust 生态的重要组成部分(serdetokiodiesel 等都依赖它)。这个改进让手写过程宏更加愉快。

当然,大多数人会使用 quote! 宏,但对于需要动态构建 token 的场景,这个改进很有价值。

七、切片旋转的常量化

新增的能力

const ROTATED: [i32; 5] = {
    let mut arr = [1, 2, 3, 4, 5];
    arr.rotate_left(2);
    arr
};

fn main() {
    println!("{:?}", ROTATED);  // 输出: [3, 4, 5, 1, 2]
}

使用场景

const fn rotate_bytes(s: &[u8; 5], n: usize) -> [u8; 5] {
    let mut result = *s;
    result.rotate_left(n);
    result
}

const HELLO: [u8; 5] = *b"hello";
const LLOHE: [u8; 5] = rotate_bytes(&HELLO, 2);

fn main() {
    println!("{}", std::str::from_utf8(&LLOHE).unwrap());  // 输出: llohe
}

设计哲学

Rust 正在逐步扩展 const fn 的能力边界。每个版本都会有一些函数被"常量化",这是一个渐进的过程:

  1. 编译期计算减少运行时开销
  2. 更多的编译期验证
  3. 支持更复杂的 const 泛型

总结:这个版本值得升级吗?

值得。 虽然没有革命性的新特性,但这些 API 都是实打实解决痛点的:

API解决的痛点受益场景
RwLockWriteGuard::downgrade锁降级的时间窗口缓存、配置热更新
Box/Rc/Arc::new_zeroed大数据结构的栈溢出和复制开销内存密集型应用
NonZero::div_ceilNonZero 数值计算不便分页、批处理计算
Entry::insert_entry插入后无法操作条目复杂的 Map 操作
Location::file_as_c_strFFI 场景的额外分配C 互操作
TokenStream::extend过程宏编写繁琐宏开发
rotate_* const 化编译期数组操作受限查找表、编译期计算

升级命令:

rustup update stable

Rust 的演进哲学:不追求大版本的惊艳,而是每六周稳定地补齐一些 API、修复一些痛点。这种"无聊"的演进方式,恰恰是生产级语言应该有的样子。

每一个新稳定的 API 背后,都是社区多年的讨论、RFC、实现、测试。RwLockWriteGuard::downgrade 从提出到稳定用了九年——这就是 Rust 对 API 稳定性的承诺。