如何捕获 panic 并继续运行?

解读

国内面试中,这道题常被用来区分“写过 Rust”与“写过生产级 Rust”。
面试官真正想听的是:

  1. 不会std::panic::catch_unwind 去替代正常的 Result 错误处理;
  2. 你知道跨 FFI 边界线程边界时 panic 的默认行为是 abort,必须显式处理;
  3. 你能讲清楚 UnwindSafeRefUnwindSafe 这两个 auto trait 的约束,以及为什么 Mutex 可以而 Cell 不行;
  4. 你能给出最小可复用的封装,让主线程继续跑,而不是简单打印一行 “panic caught”。

知识点

  • std::panic::catch_unwind<F: FnOnce() -> R + UnwindSafe, R>(f: F) -> Result<R, Box<dyn Any + Send>>
  • UnwindSafe / RefUnwindSafe:标记在 panic 跨栈展开时是否会出现内存不安全;
  • panic=abort vs panic=unwind 编译模式;Cargo 默认 dev 用 unwind,release 可手动切换;
  • 线程默认 panic 策略:子线程 panic 会调用 std::panic::resume_unwind,主线程默认 abort;
  • FFI 场景:C 调用 Rust,Rust 侧一旦 panic 跨 FFI 边界即 UB,必须用 catch_unwind 把错误转成错误码;
  • no_std 环境catch_unwind 依赖 unwind 运行时,嵌入式若使用 panic_abort 则无法捕获;
  • PoisoningMutex 在 panic 后会被“毒化”,下一次加锁返回 PoisonError,需要显式处理。

答案

use std::panic;
use std::sync::{Arc, Mutex};
use std::thread;

/// 把可能 panic 的闭包包起来,返回 `Result<T, String>`,主线程可继续跑
fn safe_run<F, R>(f: F) -> Result<R, String>
where
    F: FnOnce() -> R + panic::UnwindSafe,
{
    panic::catch_unwind(f).map_err(|e| {
        match e.downcast::<String>() {
            Ok(s) => *s,
            Err(_) => "unknown panic payload".to_string(),
        }
    })
}

fn main() {
    // 1. 同线程捕获
    let r = safe_run(|| {
        panic!("oops");
    });
    println!("caught: {:?}", r); // Err("oops")

    // 2. 子线程捕获并继续
    let flag = Arc::new(Mutex::new(0));
    let flag_clone = Arc::clone(&flag);
    let handle = thread::spawn(move || {
        let _ = safe_run(|| {
            panic!("child panic");
        });
        *flag_clone.lock().unwrap() = 1; // 线程未死,继续干活
    });
    handle.join().unwrap();
    assert_eq!(*flag.lock().unwrap(), 1);

    // 3. FFI 场景:暴露给 C 的函数
    #[no_mangle]
    pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
        match panic::catch_unwind(|| a + b) {
            Ok(sum) => sum,
            Err(_) => -1, // 错误码
        }
    }
}

要点回顾:

  • 闭包必须实现 UnwindSafe;如果内部有 Cell 等内部可变容器,需用 std::panic::AssertUnwindSafe 包装,但必须自己保证安全性
  • 不要滥用catch_unwind 只能做最后的“救生网”,业务错误一律用 Result
  • 编译时加 -C panic=abort 后,本技巧失效,需在 profile 里显式保留 panic = "unwind"

拓展思考

  1. Tokio 任务 panic:异步任务默认会调用 resume_unwind,导致整个运行时退出;生产环境需打开 tokio::task::Builder::new().name(..).catch_unwind(true) 才能隔离;
  2. WebAssemblycatch_unwindwasm32-unknown-unknown 下直接编译失败,因为无 unwind 表;若需捕获,只能改用 panic_abort 并在 JS 侧捕获 RuntimeError
  3. 嵌入式 Rustcortex-m-rt 使用 panic_haltpanic_reset,无法展开;若需“容错继续”,必须把可能失败的逻辑放到独立线程或协程,并通过消息队列重启;
  4. 性能敏感服务:高频调用路径下,catch_unwind 有额外一次 __gcc_personality_v0 展开开销;实测在 100w 次/秒场景下,延迟增加 5~8%,此时应把不稳定代码拆到独立进程,用 IPC 重启策略代替。