BigFish

曾经靠饿,瘦了20斤

0%

引言

这是Rust九九八十一难第13篇,介绍下pin指针。pin指针跟Future紧密相关,也算是多线程部分第三篇。关于这块,之前看了几篇文章,有评论这块难以理解,我也同感。忘记谁说的了,如果不能简单明了的描述某个东西,说明自己还没真正掌握。本篇尝试用简单方式整理Pin相关的知识,有问题请留言。

一、基本概念

入门pin,先要知道四个概念:pin,Unpin和!Upin,以及rust的move。通过这些概念的对比,理解pin的边界,在哪些范围发生作用。

1、Pin<T> 到底是什么

Pin是个智能指针包装器。比如:

1
2
3
4
5
6
7
use std::pin::Pin;

fn main() {
let x = 10;
let mut pinned = Pin::new(&x);
println!("Pinned: {:?}", pinned);//Pinned: 10
}
  • 它包裹了一个类型 T(通常是放在堆上的,如 Box<T>),对 !Unpin 类型 , 编译器会在语法层面阻止移动。
  • 简单说就是可以拿到 &T&mut T,但保证不会把整个 T 移到别的内存位置。

2、Unpin是什么?

Unpin 是一个 标记 trait,表示该类型可以安全地被移动。

  • 大多数普通类型(如 i32, String, Vec<T>默认实现Unpin

  • 但一些类型(如 Future、自引用结构)不会自动实现

    1
    2
    3
    fn need_unpin<T: Unpin>(x: T) {
    println!("可以安全移动");
    }

    如果某类型没有 Unpin,那么它就 必须被固定(pinned) 才能安全使用。

3、!Unpin是什么

!Unpin 就是 没有实现 Unpin 的类型,表示类型 不能随意移动!Unpin 并不是 Rust 的语法,而是“没有实现 Unpin 的类型”的意思。Rust 默认会自动为大部分类型实现 Unpin,只有少数类型(自引用、Future、PhantomPinned)才是 !Unpin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::pin::Pin;
use std::marker::PhantomPinned;

struct SelfRef {
data: String,
ptr: *const String,
_pin: PhantomPinned, // 表示 !Unpin
}

fn main() {
// 普通类型 Unpin
let x = Box::new(42);
let px = Pin::new(x);
let moved_px = px; // ✅ 可以移动

// 自引用类型 !Unpin
let y = Box::pin(SelfRef {
data: "hello".to_string(),
ptr: std::ptr::null(),
_pin: PhantomPinned,
});
// let moved_y = y; // ❌ 编译报错,不可移动
}

Unpin 类型 → 钉住也能搬,!Unpin 类型 → 钉住后无法搬。换句话**!Unpin 的作用是“固定对象的地址”,而不是搬动堆上的数据**,如下图:

Stack                     Heap
+-------+                 +--------+
| a     | --------------> | "hello"|
| Box   |                 +--------+
+-------+
!Unpin 阻止 a 被 move
  • a(栈上的 Box)被 Pin , 栈上的地址不能被移动,堆上的 "hello" 数据不受影响

  • Pin 作用在 栈上的对象地址,确保内部指针不会悬空

4、Pin、Unpin和!Unpin三者对比

类型 Pin 是否生效 说明
Unpin ❌ 不生效(透明) 移动仍然允许,安全无问题
!Unpin ✅ 生效 栈上值被固定,移动会编译报错,保护内部引用安全
Copy 类型 ❌ 不生效 move 其实是复制,Pin 对它无意义

Pin 是动作,“把对象钉住”,Unpin / !Unpin 决定钉住后能否移动。

5、Pin与move的关系

类型 Copy? Unpin? Pin 后移动? Pin 作用
i32 / bool / f64 ✅ 可以移动 不起作用(透明)
String / Vec / Box ✅ 可以移动 对 Unpin 类型不起作用
自引用 / Future ❌ 禁止移动 Pin 生效,保护内部引用

Pin 的实际意义只针对 !Unpin (Pin<T: !Unpin>)类型,Copy 或 Unpin 类型( String、Vec、Box) 标记了 Pin(Pin<T: Unpin>),本质上不起作用。

二、核心API使用入门

上一章节确定了边界,这一章看下Pin的api怎么用。

示例1:Pin<Box>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
use std::pin::Pin;

struct Data {
value: String,
}

fn main() {

let boxed = Box::new(123);
let pinned = Pin::new(boxed);

// i32 是 Unpin,所以可以安全取出
let inner = Pin::into_inner(pinned);
println!("{}", inner);

let mut data = Data {
value: "hello".to_string(),
};

// 普通 Box,可以自由移动
let mut boxed = Box::new(data);

// Pin<Box<T>>:禁止移动内部 T
let mut pinned: Pin<Box<Data>> = Pin::new(boxed);

// 安全访问字段
println!("Value = {}", pinned.value);

// 尝试移动 pinned(编译失败),内部值(Data)被移动出原来的内存地址,原先地址上的那块堆内存变成空的,违反了pin的约束
// let moved = *pinned; // ❌
}

说明:

  • Pin<Box<T>> 保证 T 在堆上的地址不会改变;仍然可以修改内容,但不能“移走”整个结构体。
  • Pin::new():对 !Unpin 类型(例如自引用结构体),不能直接用这个函数。编译器会强制你使用 unsafe { Pin::new_unchecked(...) }
  • 如果想只有当类型是 Unpin(可安全移动)时才能用into_inner。

示例2:自引用结构体(Pin 的典型场景)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
use std::pin::Pin;
use std::ptr::NonNull;

struct SelfRef {
data: String,
ptr: Option<NonNull<String>>,
}

impl SelfRef {
fn new(txt: &str) -> SelfRef {
SelfRef {
data: txt.to_string(),
ptr: None,
}
}

fn init(self: Pin<&mut SelfRef>) {
// 安全地拿到内部可变引用
let this = unsafe { self.get_unchecked_mut() };
// 设置指针指向自己字段
this.ptr = Some(NonNull::from(&this.data));
}

fn print(&self) {
unsafe {
println!("self.data = {}", self.data);
if let Some(ptr) = self.ptr {
println!("ptr -> {}", ptr.as_ref());
}
}
}
}

fn main() {
let mut s = Box::pin(SelfRef::new("Rust Pin!"));
s.as_mut().init(); // 初始化自引用
s.print(); // ✅ OK:data 没被移动
}

示例 3:在异步任务中(Future 的典型应用)

async fn 生成的状态机其实是 自引用结构体Pin 在运行时保护它不被移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
use std::pin::Pin;
use std::future::Future;
use std::task::{Context, Poll};

struct MyFuture {
counter: u8,
}

impl Future for MyFuture {
type Output = u8;

fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.counter < 3 {
self.counter += 1;
println!("Counting: {}", self.counter);
Poll::Pending
} else {
Poll::Ready(self.counter)
}
}
}

#[tokio::main]
async fn main() {
let result = MyFuture { counter: 0 }.await;
println!("Done: {}", result);
}

Pin 确保 MyFuture 内部状态(如引用)不会在 .await 期间被移动。这就是为什么 async 语法能安全地处理复杂状态。

示例4:unsafe

a) Pin::new_unchecked

1
2
let mut x = SelfRef { ... }; // !Unpin 类型
let px = unsafe { Pin::new_unchecked(&mut x) };
  • 为什么 unsafe
    • 编译器不能保证后续不会移动 x
    • 需要开发者保证 x 在生命周期内不会移动
  • Pin::new_unchecked:必须 100% 确保 这个值不会在 pinned 后被移动,一般只在底层框架(如 tokio、futures)或自引用实现中用,业务代码一般不用。

b) Pin::get_unchecked_mut

1
2
3
4
5
6
let mut px: Pin<&mut SelfRef> = Pin::new(&mut x);

unsafe {
let mut_ref: &mut SelfRef = Pin::get_unchecked_mut(px.as_mut());
mut_ref.ptr = &mut mut_ref.data; // 自引用赋值
}
  • get_unchecked_mut():不安全适用任何类型,跳过移动安全检查,手动保证 pinned 值不会移动

c) into_inner 堆上对象拆回原类型(!Unpin 类型不可行)

1
2
let px: Pin<Box<SelfRef>> = Box::pin(SelfRef { ... });
// let b: Box<SelfRef> = Pin::into_inner(px); // ❌ 不安全,编译禁止
  • 对 Unpin 类型安全,可以拆回 Box
  • 对 !Unpin 类型,拆回 Box 需要 unsafe 并自己保证移动不会破坏安全(一般不推荐)

三、为什么要自引用

pin指针常用场景是自引用,这里聊聊自引用是啥,为什么有自引用。

1、对比方法访问和内部引用

假设我们有一个字符串字段 data,想访问前两个字符:

a、方法访问(推荐做法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct MyStruct {
data: String,
}

impl MyStruct {
fn slice(&self) -> &str {
&self.data[0..2] // 每次调用都生成切片
}
}

fn main() {
let s = MyStruct { data: "Hello".into() };
println!("{}", s.slice()); // "He"
}
  • 优点:
    Rust borrow checker 安全,没有悬空指针,简单、可维护

  • 缺点:
    每次调用都会生成一个切片(非常轻量级,但在高性能/大量数据场景下可能产生微小开销)

b、内部引用(self_ref / slice 指向 data)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct SelfRef {
data: String,
slice: *const str, // 内部指针
}

impl SelfRef {
fn new(txt: &str) -> Self {
let mut s = SelfRef {
data: txt.to_string(),
slice: std::ptr::null(),
};
s.slice = &s.data[0..2] as *const str; // 指向 data
s
}

fn get_slice(&self) -> &str {
unsafe { &*self.slice }
}
}
  • 优点:

    • 零拷贝:切片预先计算好,访问不需要每次切分
    • 可用于异步/自引用结构,避免在 Future 状态机 poll 时重复生成切片
  • 缺点:

    • 必须使用 Pin 或堆分配保证 data 地址不变
    • 使用裸指针,需要 unsafe,风险大
    • 程序复杂度高,可维护性差

2、自引用场景

a、零拷贝解析

HTTP、JSON、CSV、文本流等大量数据处理,想存储对 buffer 的切片,而不是复制字符串。那内部引用可以直接保存 slice,避免每次 data[0..n] 生成新切片

b、异步状态机 / Future 自引用

状态机字段之间可能互相引用Poll 时不希望重新计算 slice 或临时变量

c、高性能图结构 / AI / Tensor

节点存指针指向数据的一部分,而不是每次生成新对象

3、不加pin的有什么问题

假设2的场景没加pin,Rust 编译器在面对“直接自引用”,通常会:

  • 如果是安全引用(&str),直接拒绝编译

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    struct BadRef<'a> {
    data: String,
    slice: &'a str, // 引用自身字段
    }

    impl<'a> BadRef<'a> {
    fn new(txt: &str) -> Self {
    let s = txt.to_string();
    Self {
    data: s,
    slice: &s, // ❌ 编译错误
    }
    }
    }
    error[E0505]: cannot move out of `s` because it is borrowed
     --> src/main.rs:9:13
      |
    8 |         let s = txt.to_string();
      |             - binding `s` declared here
    9 |         Self { data: s, slice: &s }
      |                    ^ move out of `s` occurs here
      |                    |
      |                    borrow later used here
    

    Rust 检测到 &s 引用了局部变量 s,但又 move 了它(所有权转移),这违反了生命周期规则。

  • 如果用裸指针(*const T),编译能过但属于未定义行为(UB)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    use std::ptr;

    struct SelfRef {
    data: String,
    ptr: *const String,
    }

    impl SelfRef {
    fn new(s: &str) -> Self {
    let data = String::from(s);
    let ptr = &data as *const String;
    Self { data, ptr }
    }

    fn print(&self) {
    unsafe {
    // 访问 ptr 指向的旧地址
    println!("ptr -> {}", &*self.ptr);
    }
    }
    }

    fn main() {
    let x = SelfRef::new("hello");
    let mut y = x; // move 发生
    // 此时 y.ptr 仍指向 x.data 的旧地址(已被 drop)

    y.print(); // ❌ UB: 访问已释放内存
    }

    interrupted by signal 11:SIGSEGV,或者出现乱码,因为 b.slice 指向了 a.data 的旧位置。有的rust版本可能不崩,有点随机,所以是未定义行为。

四、Pin具体是怎么固定的

Pin 本身不保证对象真的“不会被移动”,它只是 在类型系统层面限制移动,依赖于“不能获取 &mut T 原始引用:

  • 对于 Pin<Box<T>>,堆上分配 + 无法替换指针 = 地址稳定
  • 对于 Pin<&mut T>,编译器禁止 Tmem::replace()move

1、PhantomPinned + !Unpin

Rust 编译器通过 类型系统约束固定内部指针。PhantomPinned 用来标记一个类型 不可移动。默认情况下,所有类型都实现 Unpin

  • Unpin 意味着可以安全移动。

  • 自引用类型必须显式禁用 Unpin(通过 PhantomPinned)。

    1
    2
    3
    4
    5
    6
    7
    8
    use std::marker::PhantomPinned;
    use std::pin::Pin;

    struct SelfRef {
    data: String,
    ptr: *const String,
    _pin: PhantomPinned, // 禁止移动
    }

2、Pin 的 API 层约束

Pin的核心api定义:

1
2
3
4
impl<T: ?Sized> Pin<&mut T> {
pub fn as_mut(self: Pin<&mut T>) -> Pin<&mut T> { ... }
pub unsafe fn get_unchecked_mut(self: Pin<&mut T>) -> &mut T { ... }
}
  • as_mut 安全获取可变引用,但仍然被 Pin 约束。

  • get_unchecked_mutunsafe的,允许手动移动内部字段,但风险自担。

  • 编译器只允许安全 API 移动外部包裹指针,但内部 T 地址固定。

3、阻止move的例子

堆上移动禁止的例子,Pin在编译期就会阻止

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
use std::pin::Pin;
use std::marker::PhantomPinned;

struct SelfRef {
data: String,
ptr: *const String,
_pin: PhantomPinned, // !Unpin,禁止移动
}

fn main() {
// 堆上创建 SelfRef 并 Pin
let mut boxed: Pin<Box<SelfRef>> = Box::pin(SelfRef {
data: String::from("hello"),
ptr: std::ptr::null(),
_pin: PhantomPinned,
});

// 初始化内部自引用指针
let ptr = &boxed.data as *const String;
unsafe {
let mut_ref = Pin::as_mut(&mut boxed);
Pin::get_unchecked_mut(mut_ref).ptr = ptr;
}

// ❌ 尝试 move 内部 SelfRef 会报错
let moved = *boxed; // error: cannot move out of `*boxed` because it is pinned

// // 但是 Box 本身可以移动
// let moved_boxed = boxed; // ✅ Box 可移动,但堆上地址固定
//
// println!("data via pinned: {}", unsafe { &*moved_boxed.ptr });
}


原理:

  • Box 是智能指针,移动 Box 只移动指针,不移动堆数据,如下图,堆没变;
  • Pin API 保证 T 在堆上的内存地址不会改变。

五、为什么 Future 要有 Pin 设计

1、future自引用

一个 async fn 编译后其实会生成一个状态机结构体

1
2
3
4
5
async fn foo() {
let s = String::from("hello");
bar(&s).await;
println!("{s}");
}

编译器大致会生成:

1
2
3
4
5
enum FooFuture {
State0, // 初始状态
State1 { s: String, bar_fut: BarFuture<'_> }, // await 之后
Done,
}

每次 .poll() 时,Future 会被推进到下一个状态。

State1 中,bar_fut 持有一个对 s 的引用:BarFuture<'_> // 生命周期依赖 FooFuture::s

这意味着整个 FooFuture 结构体内部出现了**自引用(self-referential)**关系:

FooFuture
 ├─ s: String
 └─ bar_fut: BarFuture<'s>
              ↑
              └── 引用了 s

2、Future何时被移动

当 Future 被运行(比如通过 tokio::spawnblock_on)时,执行器会做这样的事:

1
2
let fut = foo();          // 创建 Future
executor.spawn(fut); // 把 Future 移进任务队列(Move #1)

在任务系统中,执行器通常会:

  • fut 移进某个堆上分配的任务结构体;
  • 再从任务结构体中取出 Pin<&mut fut> 去调用 poll()

这样,在被 poll 之前,它已经被 move 过一次了。

更隐蔽的情况:

1
2
let fut = foo();
let f1 = async { fut.await };

fut 被嵌入另一个 Future f1 中。 f1 也会被 executor 再次移动。 也就是说 fut 可能经历:

1
2
3
4
5
6
7
foo() -> 创建 Future
move
async { fut.await } -> 另一个 Future 包装
move
executor.spawn() -> 放入任务
move
poll() -> 固定到堆上

如果 FooFuture 在 poll 过程中被 移动 (move),那么 s 在内存中的地址就变了。
bar_fut 仍然保存着旧地址的引用,则引用失效,属于UB(未定义行为)!

所以Rust通过Pin来固定Future内存位置

3、流程如下

说明:

  • 当写一个 async fn foo() { … }async { … },Rust 编译器把它转成一个状态机 struct,其字段包括局部变量、状态标记、可能的 Waker 等。
  • 如果这个状态机 在 await 点之后 还持有对自身结构体内部字段(例如 &mut self.field、或 &self.field)的引用,那么它就是一个 自引用 Future。也就是说,它存储了指向自身内容的引用。
  • 若这种 Future 被移动(即地址变化),那么这些内部引用就变成悬垂引用,可能引起 UB。
  • 因此,为了安全,Rust 要求:若一个 Future 有可能是自引用的(即生成器状态机可能这样),那么它必须 先固定住地址(pinned),再被 poll。这就是为什么 poll(self: Pin<&mut Self>, …) 而不是 &mut Selfhttps://stackoverflow.com/questions/72769316/why-do-futures-use-pins-in-rust?utm_source=chatgpt.com
  • Pin<&mut Self> 的约束下,类型系统禁止你再偷偷将 Self 移动。只有当 Self 类型实现了 Unpin (表示“即便被 Pin 包装,也可安全移动”)时,才允许“解除固定”的操作。
  • 总结来说,在 poll 期间,保证 Future 所表示的内存位置不会改变**,从而底层状态机字段中的“self-引用”依然有效。

六、总结

Pin主要保证 !Unpin 类型在内存中的地址固定,比如自引用类型、异步 Future 类型(async/await 内部状态机)、底层异步 I/O 驱动(如 Tokio 内部 task、io_uring buffer)。理解起来有点复杂,对普通类型或短生命周期变量没必要使用,除非是明确的需求。如果用的话,能用安全 API 就不用 unsafe;只能 unsafe 的地方,要保证对象绝对不动。

如果觉得有用,请点个关注吧。

引言

这是Rust九九八十一难第十六篇。上篇聊了下Rust有哪些危险操作,有个操作是无脑Clone的风险,这时候可能用到引用和’a生命周期注解(Lifetime annotations)。那么问题来了,有时候必须加,有时不用,到底什么时候用’a,生命周期的计算规则是什么等等。这篇就整理下生命周期注解的使用。

一、简单了解和入门

使用之前,先做个入门。Lifetime annotations(’a,’b等)像是一种约束,只是告诉编译器引用之间的有效期关系,编译器以此和borrow-checker,保证引用不会比引用数据活的更久。有点类似学校的某班点名册,学生是数据,点名册是引用,他俩关系是学生毕业了,这本点名册就要废弃。

1、函数返回引用

下面的函数接受俩字符串切片,返回返回其中较长的一个

错误使用:

1
2
3
4
5
6
7
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}

编译报错:Missing lifetime specifier [E0106]

正确使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}

fn main() {
let s1 = String::from("hello world");
let s2 = String::from("hi");
let result = longest(&s1, &s2);
println!("Longest: {}", result);
}

这里&'a str表示&str在用在某个生命周期 'a 内有效,x 和 y 的引用都有生命周期 'a,函数返回一个同样具有 'a 生命周期的引用。编译器知道后,保证x和y活多久,只要它们都活过同一段 'a,那么返回的引用就是安全的。

2、struct\enum使用生命周期注解

用结构体存引用,这必须给struct加生命周期注解。比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#[derive(Debug)]
struct Book<'a> {
title: &'a str,
author: &'a str,
}

fn main() {
let title = String::from("Rust in Action");
let author = String::from("Tim McNamara");

let book = Book {
title: &title,
author: &author,
};

println!("{:?}", book);
}

Book 存有引用字段,Rust需要知道引用在哪段时间有效(防止悬垂引用、推导借用规则等),于是 Book<'a>'a 表示:这个 struct 所持的引用至少活到 'a,且 Book<'a> 的实例本身不能比 'a 活得更久。也就是说:

  • 'a 是一个 约束条件:Book 内部引用必须有效
  • struct 的生命周期 ≤ 'a

二、Lifetime annotations命名规则

1、规则和限制

  • 必须以 ' 开头,所有生命周期名称都必须以单引号 ' 开头,比如:’a,’static。

  • 'a'b'c 是最常见的

  • 生命周期名可以是多个单词或多个字符(但必须合法的标识符):

    1
    fn parse<'de>(s: &'de str) { }  //常见于 Serde 的 'de
  • 必须是合法标识符字符(字母、数字、下划线),但不能以数字开头:

    • 合法: ‘file、’input1 、’_a
    • 不合法:’1abc、’’abc // 双引号
  • 'static(内置生命周期),可以使用 'static,它不是关键字,但它的意义被语言定义。

  • '_'(匿名生命周期),从 Rust 1.34 开始允许,用于减少显式写法

    1
    fn foo<'_>(x: &'_ str) {}
  • Rust 没有任何关键字被禁止用作生命周期名。但随便用关键字会降低可读性。

2、一些建议

  • 如果不需要写生命周期,就别写,写了会传染

  • 简单场景用 'a 足够,不要过度设计

  • 多个生命周期时,用 'a 'b 'c,除非语义重要

  • 不关心具体生命周期用’_

  • 程序整个生命周期,’static

  • 生命周期与类型参数顺序:先 'aT

    1
    fn f<'a, T>(x: &'a T) {}
  • 特定场景

    • 解析数据:用 'de'input 组合
    1
    2
    3
    fn parse<'de, 'input>(src: &'input str) -> Result<T<'de>, E> {
    ...
    }
    • 上下文:’ctx
    • 配置:’cfg
    • 字符串来源:’s
  • 默认使用 'a', 'b', 'c'。只有当它们导致混淆,或者你需要为特定的外部数据源提供明确的上下文时,才考虑使用更长的、描述性的名称(如 'repo, 'src')。

三、生命周期注解如何计算存活时间

Rust 生命周期注解本身并不计算或控制引用的存活时间,它是描述性的。它的作用是告诉 Borrow Checker不同引用之间的生命周期关系,从而让编译器能够检查引用是否有效,防止悬垂引用的产生。

Rust lifetime = 取“引用被使用的区间”与“被借用对象的存活区间”的交集,并确保借用不超过对象范围,如果涉及多个借用,则返回引用的生存区间必须是输入引用交集中的某个合法子集。通过几个例子说明下:

  • 示例1:引用的生命周期计算(显式注解版, 平常不用加,这里为了展示方便)

    1
    2
    3
    4
    5
    6
    fn main<'main>() {
    let x: i32 = 5; // x: 'main
    let r: &'main i32 = &x; // r: 'main

    println!("{}", r); // r 最后一次使用
    } // 'main 在函数结束
    1
    2
    3
    x:   ─────────────────────────── 'main
    r: ────────────────────── 'main
    ^开始借用 ^最后使用

    生命周期 'main 完全包含 r 的使用区间,因此合法。

  • 示例2:返回引用必须绑定输入(显式注解版)

    1
    2
    3
    fn get_ref<'a>(x: &'a i32) -> &'a i32 {
    x
    }
    1
    2
    x (input):  ──────────────────── 'a
    return: ───────────────── 'a

    返回引用直接继承 'a,不能产生新的生命周期。

  • 示例3:两个输入生命周期不同,但返回其中之一

    1
    2
    3
    fn pick<'a, 'b>(a: &'a i32, b: &'b i32) -> &'a i32 {
    a
    }
    1
    2
    3
    'a:  ───────────────────────────────
    'b: ───────────────
    ret: ─────────────────────────────── (和a一样)
  • 示例4:返回交集例子

    • struct例子
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    impl<'a, 'b> Pair<'a, 'b> {
    // 允许只有当 'a 与 'b 有重叠时
    fn shortest<'c>(&'c self) -> &'c str
    where
    'a: 'c,
    'b: 'c,
    {
    if self.left.len() < self.right.len() {
    self.left
    } else {
    self.right
    }
    }
    }

    1
    2
    3
    4
    'a:  ─────────────────────────────
    'b: ───────────────────────
    'c: ─────────────── (交集区域)

    • 函数例子
    1
    2
    3
    4
    5
    6
    7
    fn overlap<'a, 'b, 'c>(a: &'a str, b: &'b str) -> &'c str
    where
    'a: 'c,
    'b: 'c,
    {
    if a.len() < b.len() { a } else { b }
    }
    1
    2
    3
    4
    'a:    ───────────────────────
    'b: ─────────────────────
    'c (交集): ─────────────

    返回值 'c 就是 'a ∩ 'b 的交集范围

四、使用场景:哪些情况必须使用vs哪些不用

既然生命周期注解有这么重要,为什么好多情况不加也没问题呢,到底什么情况下需要?这就涉及到生命周期省略规则。

1、Rust在这些场景下能自动推断生命周期,无需手动加标注。

a、所有输入引用各自都有一个推断到的生命周期

函数参数里每个 &T,如果没写生命周期,编译器会自动给它加一个,但不返回引用时或者返回值不是引用。

  • 例如:fn foo(x: &str, y: &str) 等价于 fn foo<'a, 'b>(x: &'a str, y: &'b str)

b、只有一个输入引用,则输出引用用它的生命周期

如果函数 有且只有一个输入引用,并且返回引用, 那么返回值的生命周期会自动和这个输入一样。这是最安全的默认推断:如果返回一个引用,那么它必须依赖于唯一的输入引用

  • 例如:fn foo(x: &str) -> &str 等价于 fn foo<'a>(x: &'a str) -> &'a str

c、方法中有 &self / &mut self,输出引用默认跟 self 的生命周期

假设方法是借用(而非所有权转移),返回的任何引用最有可能指向方法调用者(self)所拥有的数据。

  • 1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    struct Owner {
    data: String,
    }

    impl Owner {
    // 原始代码:没有手动标注
    fn get_data_ref(&self) -> &str {
    &self.data // 返回对自身数据的引用
    }
    }

    // 编译器推断:等价于
    /*
    impl<'a> Owner {
    fn get_data_ref(&'a self) -> &'a str {
    &self.data
    }
    }
    */
    // 约束:返回的 &str 的生命周期 'a 与 &self 的生命周期 'a 相同。

d、结构体没有引用字段

1
struct Foo { x: i32 }

2、必须加生命周期标注

a、结构体(或枚举)里含有引用字段

1
2
3
struct Foo<'a> {
name: &'a str,
}

结构体持有引用必须明确生命周期,否则编译器不知道引用活多久。

b、函数/方法返回的引用来自多个输入引用(多对一),编译器无法判断返回的是谁

1
fn choose(x: &str, y: &str) -> &str { ... }

这会报错,因为编译器不知道返回值应该跟 'x 还是 'y。必须写:

1
fn choose<'a>(x: &'a str, y: &'a str) -> &'a str //// 约束:返回的引用 'a 依赖于 x 和 y 中生命周期最短的那个。

c、方法返回的引用不属于 self,但有多个输入引用

1
2
3
fn pick<'a>(&self, other: &'a str) -> &str {
other
}

如果不写:

1
fn pick(&self, other: &str) -> &str

编译器会以为输出是 self 的生命周期(规则 3),导致错误。

3、特殊场景

3.1、 声明生命周期的“长短关系” 'a: 'b

你有两个输入生命周期 'a'b,但你要保证 'a >= 'b'a 活得至少跟 'b 一样久),否则返回 'b 的引用可能会出错。

1
2
3
4
5
6
fn choose_first<'a, 'b>(x: &'a str, _y: &'b str) -> &'b str
where
'a: 'b, // 'a 长于 'b,所以返回 &'b str 时是安全的
{
x
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Mapper<'a, 'b> {
from: &'a str,
to: &'b str,
}

impl<'a, 'b> Mapper<'a, 'b>
where
'a: 'b, // 必须明确指出关系
{
fn map(&self) -> &'b str {
self.from
}
}

'a: 'b 是 Rust 生命周期 subtyping(子类型)约束的语法,用来表达:

生命周期 'a 必须比 'b 活得更久(或至少一样久)
换句话说:'a'b

3.2、trait object 中的生命周期(如 Box<dyn Trait + 'a>

这个 Box 必须至少活 'a 那么久,否则其中的引用就可能悬垂。通常情况下不需要,但在某些特定场景下是必须的。在绝大多数情况下,当写 Box<dyn Printer> 时,编译器会自动添加生命周期参数。

Box<dyn Trait>等同于 'static,不能放非 'static 引用。只有当 Trait Object 内部的类型依赖于一个'static' 的,即局部作用域的生命周期时,才必须手动添加 'a 来约束它。

  • 必须加举例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
trait Printer {
fn print(&self);
}

struct StrPrinter<'a> {
msg: &'a str,
}

impl<'a> Printer for StrPrinter<'a> {
fn print(&self) {
println!("{}", self.msg);
}
}

//不加‘a报这个错误:lifetime may not live long enough
//returning this value requires that `'a` must outlive `'static`
fn make_printer<'a>(msg: &'a str) -> Box<dyn Printer + 'a> {
Box::new(StrPrinter { msg })
}

fn main() {
let s = String::from("hello");
let p = make_printer(&s);
p.print();
}

上面例子创建一个 StrPrinter,并且这个对象内部封装了一个生命周期短于 'static' 的引用。省略 'a' 会导致编译器默认采用 'static' 约束,从而引发安全错误.

  • 不用加举例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

trait Logger {
fn log(&self, message: &str);
}

struct OwnedLogger {
prefix: String, // 拥有所有权,生命周期与结构体实例绑定
}

impl Logger for OwnedLogger {
fn log(&self, message: &str) {
println!("[{}] {}", self.prefix, message);
}
}

// 注意:这里不需要写 <'a> 或 + 'a
// Box<dyn Logger> 隐式等价于 Box<dyn Logger + 'static>
fn create_logger(prefix: &str) -> Box<dyn Logger> {
Box::new(OwnedLogger {
prefix: prefix.to_string(), // 将 &str 复制为 String (拥有所有权)
})
}

fn main() {
let msg = String::from("Main scope message");

// 1. 创建 Logger:返回的 Box<dyn Logger> 默认是 'static
// 输入 prefix 是局部的 &str,但它在函数内部被复制成了 String
let logger_box = create_logger("APP");

// 2. 在 msg 被释放后(理论上,如果 logger_box 活得更久),logger_box 仍然安全
// 因为 logger_box 内部的 prefix 是它自己拥有的 String
// 它不依赖于任何外部的局部引用。
logger_box.log("User logged in.");

// 3. 传入一个 'static 的字符串字面量也是安全的:
let static_logger_box = create_logger("STATIC");
static_logger_box.log("System started.");
}

3.3、HRTB 必须加 for<'a>

HRTB(Higher-Rank Trait Bounds,高阶生命周期约束)

HRTB 使用 for<'a> 语法,是因为它用于声明约束一个类型(通常是闭包或函数指针)的能力,即该类型对于任意传入的生命周期都是有效的。

  • 普通函数签名只能用一个固定生命周期,比如:
1
2
//这里的 fn(&i32) 实际上隐含了某个生命周期 'x
fn call_fn(f: fn(&i32)) { ... }
  • 如果你想让 f 可以接受 任意生命周期 的引用,就需要 HRTB:
1
2
3
4
fn call_fn<F>(f: F)
where
F: for<'a> Fn(&'a i32),
{ ... }

这里 for<'a> 告诉 Rust:f所有生命周期 'a 的引用 都有效

  • 举个例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    fn call_with_i32<F>(f: F)
    where
    F: for<'a> Fn(&'a i32),
    {
    let x = 5;
    let y = 10;
    f(&x); // &'a i32 可以是 x 的生命周期
    f(&y); // &'a i32 可以是 y 的生命周期
    }
    //调用,闭包 |n| println!("{}", n) 可以接受任意生命周期的 &i32
    call_with_i32(|n| println!("{}", n));
  • 什么时候必须用HRTB(for<'a>

    • 函数接受泛型闭包或函数指针

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      // 无法省略!必须用 for<'a> 来表达对任意生命周期 'a 的适用性
      fn process_multiple_borrows<F>(f: F)
      where
      // HRTB: 约束 F 必须对于所有生命周期 'a 都满足 Fn(&'a str) Trait
      F: for<'a> Fn(&'a str) -> u32,
      {
      // 短命引用 L_short
      let data_temp = String::from("ephemeral");
      let len_temp = f(&data_temp);
      println!("Temp data length: {}", len_temp);

      // 长命引用 L_static
      let len_static = f("static string");
      println!("Static data length: {}", len_static);
      }

      fn main() {
      let my_counter = |s: &str| s.len() as u32;
      process_multiple_borrows(my_counter);
      }
    • 高阶函数 / trait / async / future 的引用需要泛型生命周期时

      1
      2
      3
      4
      5
      6
      fn apply<F>(f: F)
      where
      F: for<'a> Fn(&'a str) -> &'a str,
      {
      // 可以接受任意生命周期的 &str
      }

3.4 、GATs需要加’a场景

GATs (Generic Associated Types),即通用关联类型,是 Rust 语言中一项重要的、相对高级的特性。它允许 Trait(特型)的关联类型能够拥有自己的泛型参数(包括生命周期参数和类型参数),就像 Trait 本身可以拥有泛型参数一样。使用 GATs,可以定义一个关联类型 Ref,并为其引入一个生命周期参数 'a,从而明确这个引用依赖于 'a

语法:

1
2
3
4
5
6
7
8
trait MyTrait {
// T 是 Trait 的泛型参数
// Item<U> 是关联类型 Item 的泛型参数
type Item<U>;

// 允许关联类型依赖于生命周期参数 'a
type Reference<'a> where Self: 'a;
}

在迭代器场景,Iterator::Item 是一个 独立于迭代器(self)生命周期的类型。没有GATs,不能安全地写出“返回对自身内部数据的引用”的迭代器。

举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub trait LendingIterator {
// GATs: 定义一个关联类型 Item,它依赖于生命周期参数 'a
// 这里的 Item<'a> 表示“一个生命周期为 'a 的引用”
type Item<'a> where Self: 'a;

// next 方法返回的 Item<'a>,其生命周期 'a 取决于 &self 的生命周期
fn next<'a>(&'a self) -> Option<Self::Item<'a>>;
}

struct DataProcessor {
data: Vec<i32>
}

impl LendingIterator for DataProcessor {
// 实例化 Item<'a> 为一个依赖于 'a 的引用
type Item<'a> = &'a i32;

fn next<'a>(&'a self) -> Option<Self::Item<'a>> {
// 实际代码会复杂的多,这里只是示意
self.data.get(0)
}
}
  • Item<'a> 不是一个固定类型,而是一个模板,根据 'a 不同得到不同的具体类型。Item<’short>会变成&’short str, Item<’long>会变成&’long str
  • next 返回的内容与 &self 生命周期保持一致,保证安全

五、总结

本文总结了生命周期注解的使用方式、命名规则和必须使用的场景等,优点是帮助编译器更好的管理。缺点也不少,比如学习复杂;注解有传播性,一旦一个类型(struct、enum、函数)带上生命周期 'a,那么它的使用者可能也必须带上 'a,项目大,可能会反省爆炸;编译器无法推断所有场景。使用的时候,能不加就不加,仅在编译器报错/性能需求/结构设计确实要求借用时,才加生命周期注解。

如果觉得本文有用,本人公众号大鱼七成饱,辛苦点个关注吧。

引言

这是Rust九九八十一难第十五篇。之前聊过anyhow,也介绍了thiserror,感觉差不多了,没想爆出了Cloudflare的新闻。猜测很多,据说有一个原因是用了unwrap。这个前车之鉴,给了一个提醒,比如Rust还有哪些危险操作,为什么catch_unwind抓不到崩溃,能否自动化检查等。因此今天梳理下Rust代码的危险操作。

一、危险操作一览

先定个标准,按照受控程度区分。受控程度是指代码不可预测,有安全漏洞,代码可控的停止等。这里先排除带有usafe关键字的,因为关键字本身就说明了问题。梳理了下,从低到高,大体分为下面三类。

1、低危险

1.1、 乱用clone

大部分情况没问题,clone本身不会产生UB,也没有panic。但是乱用可能埋下性能炸弹,而且很隐蔽。

  • 原因

    • 大对象clone:有些类型的clone是深拷贝,比如let b = a.clone();。如果a是Vec<T>(大量数据),Arc<Mutex<T>> 内含大结构,自定义 struct 里包了大对象。clone() 会 重新分配内存 + 复制一整份数据,导致内存不足,程序延迟跳变、吞吐下降,一般的cr,很难识别,明面上看不出来。

    • 高并发场景频繁 clone Arc :多线程下都要原子性地更新同一个内存地址上的计数器,则有线程不得不等待原子操作完成和同步缓存,这个时间本来应该处理业务逻辑。

    • 多线程复制锁:原子操作很昂贵,尤其是锁结构clone,会增加更多内存共享冲突。

  • 乱用的例子

    • 逃避问题
    1
    2
    3
    fn foo(s: &String) {
    let x = s.clone(); // 逃生命周期问题
    }

    说明:一般没问题,但是属于逃避问题,避开rust生命周期检查

    • 循环内clone,造成性能灾难

      1
      2
      3
      for _ in 0..10000 {
      let v2 = v.clone(); // 大对象向量复制一万次
      }

1.2、整数溢出(wrapping)

这个一出,在Debug 模式下会 panic,但是在 Release 模式下,整数算术会环绕 (wrapping) 而不报错,导致结果不正确(逻辑错误)。举个例子:

1
2
let size = a * b;  // 溢出
let ptr = alloc(size); // size可能是负数,0等,分配错误大小, 内存被破坏

可以用下面方案替,(也有乘除等类似的api,可以问下ai,很容易查到):

  • checked_add,溢出则返回None,安全地失败

  • overflowing_add,会返回布尔值报告是否有溢出

  • saturating_add:溢出的话会限制到最大或者最小值上,不会painic和环绕

    1
    2
    3
    4
    5
    6
    7
    fn safe_overflow_demo() {
    let max = i32::MAX;
    match max.checked_add(1) {
    Some(v) => println!("v={}", v),
    None => println!("overflow detected"), //overflow detected
    }
    }

2、中危险

运行的时候崩溃等,都是可预期的,不会破坏内存结构和编译器假设的规则。

2.1、expect()

unwrap() 一样会 panic,只是能自定义错误信息。用于用于快速原型和Demo,但线上不应该使用。

可以使用使用 ?者显式错误处理(matchmap_err

  • demo

    1
    2
    3
    4
    5
    fn expect_demo() -> Result<(), String> {
    let num: i32 = "abc".parse().map_err(|e| e.to_string())?; // 正确处理错误
    println!("num = {}", num);
    Ok(())
    }

2.2、 panic!()

调用这个API程序立刻崩溃,生成 unwinding(除非 panic=abort)。一般调试时使用。不适合生产系统流程控制。可以用 Result<T, E> 返回错误,或者使用错误库:thiserroranyhow,之前文章介绍过。

  • Demo
1
2
3
4
5
6
fn safe_panic_demo(input: Option<i32>) -> Result<i32, String> {
match input {
Some(v) => Ok(v),
None => Err("input is None".to_string()),
}
}

2.3、数组越界

这个有两种操作: 使用 [] (Panic)是safe越界 ,使用 get_unchecked()或者*v.as_ptr().add(i) unsafe操作可能导致UB。据说越界访问是Rust最常见的UB来源。

  • Demo

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    fn main() {
    let v: Vec<i32> = vec![10, 20, 30];
    let index_safe: usize = 1;
    let index_oob: usize = 5; // 越界索引,有效范围是 0..3

    println!("--- 1. 安全访问方法 (Safe Access) ---");

    // 1.1. `v.get(x)` -> 返回 `Option<T>`,安全地处理越界
    match v.get(index_safe) {
    Some(val) => println!("v.get({}) (安全, 有效): {}", index_safe, val),
    None => println!("v.get({}) (安全, 越界): 返回 None", index_safe),
    }

    match v.get(index_oob) {
    Some(val) => println!("v.get({}) (安全, 有效): {}", index_oob, val),
    None => println!("v.get({}) (安全, 越界): 返回 None, 程序继续", index_oob),
    }

    println!("\n--- 2. 运行时检查访问方法 (Runtime Panic) ---");

    // 2.1. `v[x]` -> 越界时触发 `panic!`,中止程序
    println!("v[{}] (安全, 有效): {}", index_safe, v[index_safe]);

    // 取消注释下方代码块以观察 panic! 行为
    println!("尝试 v[{}] (越界, panic)...", index_oob);
    let _val_panic = v[index_oob]; //index out of bounds: the len is 3 but the index is 5
    println!("此行不会被执行");
    }

3、极度危险

包含 跳过检查取值,原始指针(*mut T、*const T)操作(unsafe操作就不介绍了)等

3.1、unwrap_unchecked()

这个操作跳过检查,直接取值,如果是 None 会导致 UB(未定义行为),不是普通 panic。主要在极少数高性能场景,如编译器内部、手工优化代码。

  • 替代方案

    • 不推荐使用,99.9% 情况不需要。

    • 使用普通 unwrap() 和开发环境 panic 更安全。

  • Demo(仅示意,不要用)

1
2
3
4
unsafe fn unchecked_demo() -> i32 {
let x: Option<i32> = Some(10);
x.unwrap_unchecked()
}

3.2、 mem::transmute

mem::transmute 被认为是极度危险(Rust unsafe 中最危险的之一)。它会 完全跳过 Rust 的类型系统,把一个值的 原始内存比特强行解释成另一种类型,而编译器不会检查是否合理。适用于底层优化、ABI 对接。可以用枚举/结构体替代表达,者用 From / TryFrom。

  • Demo(安全替代版)
1
2
3
4
fn safe_transmute_demo() -> Result<u8, String> {
let x: i32 = 150;
u8::try_from(x).map_err(|_| "overflow".to_string())
}
  • 替代方案表
想做的事 不要用 应该用
数字 → 字节数组 transmute .to_ne_bytes()
&T → &U transmute reinterpret_cast 方案:ptr.cast()
类型安全转换 transmute From / Into / TryFrom
C ABI struct 转换 transmute #[repr(C)] + 指针转换
Option<Box> 优化大小 transmute Option::takeManuallyDrop
枚举表示(discriminant)操作 transmute std::mem::discriminant

二、catch_unwind为什么有时候捕获不到崩溃

一般使用panic::catch_unwind捕获panic,std::panic::set_hook用于记录日志和堆栈,GDB/LLDB等记录系统外的崩溃。只有catch_unwind会优雅处理错误,保持服务运行,主要说下这个。

1、panic::catch_unwind 是如何工作的?

  • panic 可以理解成是“强制异常 + 栈回退”,他会执行 栈展开(stack unwinding),逐层 drop 栈上的变量,若无法继续展开,则进程 abort。

  • Rust是怎么 “展开” 栈的,什么是uwind

    1
    2
    3
    4
    5
    fn a() { b(); }
    fn b() { c(); }
    fn c() { panic!("boom"); }

    a();
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ┌──────────┐
    │ a() │
    └───▲──────┘
    │ calls
    ┌───┴──────┐
    │ b() │
    └───▲──────┘
    │ calls
    ┌───┴──────┐
    │ c() │ ← panic 发生
    └──────────┘

    unwind是这样做的:

    • c() 退出 → drop c 中的变量

    • 回到 b() → drop b 中的变量

    • 回到 a() → drop a 中的变量

    • 若某处有 catch_unwind,停止回退

    • 否则退到线程根并结束线程

    每一步都是 栈帧被弹出(pop stack frame),并执行对应资源释放逻辑。

    这就是 “展开(unwind)”。

  • 增加catch_unwind 后:它捕获当前线程中的 正常 panic 展开(unwind),返回 Result<(), Box<dyn Any + Send>>

1
2
3
4
5
6
7
use std::panic;

let result = panic::catch_unwind(|| {
panic!("boom");
});

assert!(result.is_err());

2、捕获不到场景

2.1、panic 被设置为 abort

Cargo.toml:

1
2
[profile.release]
panic = "abort"

这时候代码既不 unwind,也不执行 drop,进程直接终止了,catch_unwind 根本没机会执行。

2.2、跨语言

panic 发生在跨 FFI/外部库边界,尤其是与非-Rust 语言交互(例如 C 或 C++)时 → unwind → abort 行为不确定 → catch_unwind 未必捕获到。https://doc.rust-lang.org/std/panic/fn.catch_unwind.html

2.3、unsafe 导致的 UB 可能不被 catch_unwind 捕获

当在 unsafe 中触发 UB(例如用裸指针非法读写、悬垂引用、违反借用/别名规则、数据竞争、对齐错误等)——这在语言层面没有定义语义。这可能产生任意行为:程序挂掉、数据破损、继续运行但状态破坏、内存泄漏……这类错误不走 panic/unwind 机制 ,它们不是 “panic! unwind” 的流程。UB本身代表未定义的结果,不好确定代码流程,那么也不一定捕获到,大概率捕获不到。

三、第三方抓取工具

1、FutureExt

相比原生的,它可以直接在 async Future 上捕获 panic,支持链式打印等。

地址:https://github.com/rust-lang/futures-rs?utm_source=chatgpt.com

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use futures::FutureExt; // 0.3.5

#[tokio::test]
async fn test_async() -> Result<(), Box<dyn std::error::Error>> {
println!("before catch_unwind");

let may_panic = async {
println!("inside async catch_unwind");
panic!("this is error")
};

let async_result = may_panic.catch_unwind().await;

println!("after catch_unwind");

assert!(async_result.is_ok());

Ok(())
}

future 内部 的panic 被 catch_unwind 捕获 转为 Result::Err

2、tower http的catch-panic

中间件来捕获 handler 中 panic,并给客户端返回合适的 HTTP 错误响应(例如 500)

https://docs.rs/tower-http/latest/tower_http/catch_panic/index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use http::{Request, Response, header::HeaderName};
use std::convert::Infallible;
use tower::{Service, ServiceExt, ServiceBuilder, service_fn};
use tower_http::catch_panic::CatchPanicLayer;
use http_body_util::Full;
use bytes::Bytes;

async fn handle(req: Request<Full<Bytes>>) -> Result<Response<Full<Bytes>>, Infallible> {
panic!("something went wrong...")
}

let mut svc = ServiceBuilder::new()
// Catch panics and convert them into responses.
.layer(CatchPanicLayer::new())
.service_fn(handle);

// Call the service.
let request = Request::new(Full::default());

let response = svc.ready().await?.call(request).await?;

assert_eq!(response.status(), 500);

axum也可用哈:https://github.com/tokio-rs/axum/discussions/1865?utm_source=chatgpt.com

1
tower-http = { version = "0.5", features = ["catch-panic"] }

代码:

1
2
3
4
5
6
7
8
use tower_http::catch_panic::CatchPanicLayer;
...
let app = Router::new()
.route("/", get(ok_handler))
.route("/panic", get(panic_handler))
// 加上 CatchPanicLayer
.layer(CatchPanicLayer::new());
...

panic则返回HTTP 500 Internal Server Error,服务不会退出。

3、tokio::spawn自带的工具

1
2
3
4
5
6
7
8
9
10
let handle = tokio::spawn(async {
panic!("boom!");
});

let result = handle.await;
if let Err(join_err) = result {
if join_err.is_panic() {
println!("panic caught!");
}
}

每个 Tokio 任务都是独立执行,如果任务 panic,返回 JoinError

四、总结

本文总结了Rust代码的危险操作,在提交过程中还可以增加lint,阻止unwrap等的提交或者加白名单,服务本身性能允许的话,还可以兜底抓取崩溃,能抓到90%的panic。另外,如果是后端服务,从经验看,一般还涉及灰度发布,自动回滚,熔断等等保护性操作,服务崩溃估计是各种问题累加,一个unwrap估计没那么大威力。Rust危险操作可能还有别的,欢迎留言讨论。