引言
这是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,会增加更多内存共享冲突。
-
-
乱用的例子
- 逃避问题
rustfn foo(s: &String) { let x = s.clone(); // 逃生命周期问题 }说明:一般没问题,但是属于逃避问题,避开rust生命周期检查
-
循环内clone,造成性能灾难
rustfor _ in 0..10000 { let v2 = v.clone(); // 大对象向量复制一万次 }
1.2、整数溢出(wrapping)
这个一出,在Debug 模式下会 panic,但是在 Release 模式下,整数算术会环绕 (wrapping) 而不报错,导致结果不正确(逻辑错误)。举个例子:
rust
let size = a * b; // 溢出
let ptr = alloc(size); // size可能是负数,0等,分配错误大小, 内存被破坏
可以用下面方案替,(也有乘除等类似的api,可以问下ai,很容易查到):
-
checked_add,溢出则返回None,安全地失败 -
overflowing_add,会返回布尔值报告是否有溢出 -
saturating_add:溢出的话会限制到最大或者最小值上,不会painic和环绕rustfn 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,但线上不应该使用。
可以使用使用 ?者显式错误处理(match、map_err)
-
demo
rustfn 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> 返回错误,或者使用错误库:thiserror、anyhow,之前文章介绍过。
- Demo
rust
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
Rustfn 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(仅示意,不要用)
rust
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(安全替代版)
rust
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::take 或 ManuallyDrop |
| 枚举表示(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
rustfn a() { b(); } fn b() { c(); } fn c() { panic!("boom"); } a();markdown┌──────────┐ │ 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>>
rust
use std::panic;
let result = panic::catch_unwind(|| {
panic!("boom");
});
assert!(result.is_err());
2、捕获不到场景
2.1、panic 被设置为 abort
Cargo.toml:
toml
[profile.release]
panic = "abort"
这时候代码既不 unwind,也不执行 drop,进程直接终止了,catch_unwind 根本没机会执行。
2.2、跨语言
panic 发生在跨 FFI/外部库边界,尤其是与非-Rust 语言交互(例如 C 或 C++)时 → unwind → abort 行为不确定 → catch_unwind 未必捕获到。doc.rust-lang.org/std/panic/f...
2.3、unsafe 导致的 UB 可能不被 catch_unwind 捕获
当在 unsafe 中触发 UB(例如用裸指针非法读写、悬垂引用、违反借用/别名规则、数据竞争、对齐错误等)------这在语言层面没有定义语义。这可能产生任意行为:程序挂掉、数据破损、继续运行但状态破坏、内存泄漏......这类错误不走 panic/unwind 机制 ,它们不是 "panic! unwind" 的流程。UB本身代表未定义的结果,不好确定代码流程,那么也不一定捕获到,大概率捕获不到。
三、第三方抓取工具
1、FutureExt
相比原生的,它可以直接在 async Future 上捕获 panic,支持链式打印等。
rust
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)
rust
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也可用哈:github.com/tokio-rs/ax...
ini
tower-http = { version = "0.5", features = ["catch-panic"] }
代码:
css
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自带的工具
rust
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危险操作可能还有别的,欢迎留言讨论。
如果觉得本文有用,辛苦点个关注吧,本人公众号大鱼七成饱