Danh sách bài viết

Bài 140: panic! — Unrecoverable Error

Bài 140 của series Rust Cơ Bản — bài đầu tiên của Nhóm 19 Error Handling, mở màn cho toàn bộ chuỗi 10 bài về cách Rust xử lý lỗi. Bài này tập trung vào panic! — cơ chế dành cho lỗi không thể phục hồi: macro abort thread hiện tại, in ra error message và (tuỳ RUST_BACKTRACE) stack trace, sau đó không cho resume. Bạn sẽ phân biệt rõ khi nào nên panic (bug do lập trình viên, invariant bị vi phạm, contract giữa các module bị phá vỡ, prototype) và khi nào KHÔNG nên panic (lỗi có thể đoán trước như file không tồn tại, network timeout, parse số sai — những lỗi này thuộc về Result ở bài 141). Liệt kê đầy đủ các điểm trong stdlib có thể panic ngầm (v[10], byte slice cắt giữa multi-byte char, chia cho 0, integer overflow trong debug, unwrap() trên None / Err), syntax macro format giống println!, sự khác nhau giữa profile panic = "unwind" mặc định (chạy destructor, giải phóng tài nguyên) và panic = "abort" (exit ngay, binary nhỏ hơn), giới thiệu std::panic::catch_unwind cho ranh giới FFI và test framework, cùng cách dùng RUST_BACKTRACE=1 / full để debug.

09/06/2026
11 phút đọc
2 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Hiểu macro panic! làm gì ở runtime: abort thread hiện tại, in error + (tuỳ chọn) backtrace, không cho resume execution.
  • Phân biệt rõ trường hợp nên dùng panic! (programmer bug, invariant violation, prototype) và trường hợp nên dùng Result (file not found, network lỗi, input người dùng sai).
  • Nhận diện các điểm trong stdlib có thể panic ngầm: v[10] out-of-bound, byte slice cắt giữa multi-byte char, chia cho 0, integer overflow ở debug profile, unwrap() trên None hoặc Err.
  • Biết syntax của panic!: format string giống println!, ví dụ panic!("invalid: {value}").
  • Hiểu khác nhau giữa panic = "unwind" (default — chạy destructor) và panic = "abort" (exit ngay, binary nhỏ hơn).
  • Biết khi nào dùng std::panic::catch_unwind (FFI boundary, test framework) và khi nào KHÔNG nên (không phải exception generic-purpose).
  • Bật RUST_BACKTRACE=1 để in stack, hoặc RUST_BACKTRACE=full để có full backtrace khi debug.

Đây là bài đầu Nhóm 19. Các bài tiếp theo (141 - 149) sẽ chuyển sang Result, ? operator, custom error type, thiserror, anyhow — phần xử lý lỗi có thể phục hồi. Hiểu panic! trước là điều kiện để biết khi nào KHÔNG dùng nó.

2

panic! Là Gì

panic! là macro built-in của Rust, dùng để báo hiệu một lỗi không thể phục hồi. Khi gặp panic!, Rust sẽ:

  1. In error message ra stderr kèm file + line number nơi xảy ra panic.
  2. Gợi ý note: run with `RUST_BACKTRACE=1` ... nếu user chưa bật biến môi trường này; nếu đã bật, in stack trace.
  3. Unwind stack (mặc định) — chạy các destructor (Drop::drop) của biến trên stack để giải phóng tài nguyên, hoặc abort ngay nếu profile cấu hình panic = "abort".
  4. Kết thúc thread hiện tại với exit code khác 0. Nếu thread đó là main thread → toàn bộ process kết thúc.
fn main() {
    let age: i32 = -5;
    if age < 0 {
        panic!("age không thể âm: {age}");
    }
    println!("OK, age = {age}");
}

Output khi chạy:

thread 'main' panicked at src/main.rs:4:9:
age không thể âm: -5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Chú ý: panic KHÔNG resume. Không có cách viết try { ... } catch { ... } kiểu Java / Python để bắt panic và "đi tiếp". Ranh giới duy nhất bắt được panic là catch_unwind ở mục 8 — và nó chỉ dùng cho ranh giới đặc biệt như FFI, không phải để giả lập exception.

3

Khi Nào Dùng panic!

Quy tắc đơn giản: panic chỉ dùng khi lỗi là bug của lập trình viên, không phải lỗi mà người dùng / hệ thống có thể tạo ra hợp pháp.

NÊN dùng panic!

  • Invariant violation: một điều kiện đáng lẽ luôn đúng theo thiết kế nhưng bị vi phạm. Ví dụ: hàm sqrt(x) nhận đầu vào "đã được caller đảm bảo >= 0" nhưng lại nhận giá trị âm → caller đã viết sai → panic là hợp lý.
  • Contract broken: lập trình viên gọi API sai cách (truyền pointer null vào hàm yêu cầu non-null, sử dụng resource sau khi đã close...). Đây là bug, sửa code chứ không phải retry.
  • Prototype / example code: dùng unwrap() / expect() để code ngắn, đỡ rườm rà. Khi nâng cấp lên production thì refactor sang Result.
  • Test: assert!, assert_eq! đều panic khi sai. #[test] coi panic là dấu hiệu test failed.
  • Trạng thái mà tiếp tục là nguy hiểm: detect được memory corruption, double-free, security invariant bị phá → panic an toàn hơn tiếp tục.

KHÔNG nên dùng panic!

  • File không tồn tại, không có quyền đọc → trả Result<_, io::Error>.
  • Network timeout, server trả 500 → trả Result.
  • Input người dùng sai format — parse số không được, JSON malformed → trả Result.
  • Database bị disconnect tạm thời → trả Result để caller retry.

Khái niệm phân biệt: lỗi do bug (bạn cần sửa code) vs lỗi do môi trường (bạn cần xử lý). Panic cho cái đầu, Result cho cái sau. Đây là triết lý xuyên suốt mà toàn bộ Nhóm 19 sẽ mở rộng.

4

panic! vs Result

Rust chia lỗi thành 2 nhóm rõ ràng — đây là điểm khác biệt cốt lõi với hầu hết ngôn ngữ khác:

  • Recoverable errorResult<T, E>. Caller có cơ hội đọc lỗi, quyết định retry / fallback / propagate / log. Hệ thống vẫn chạy tiếp được.
  • Unrecoverable errorpanic!. Không có ý nghĩa nào để tiếp tục — code đã ở trạng thái sai. Tốt nhất là kết thúc nhanh và rõ.

Không có ngôn ngữ nào "buộc" lập trình viên phải dùng đúng — đây là kỷ luật. Tuy vậy Result trong Rust là #[must_use], nếu bỏ qua sẽ có warning, và operator ? làm việc propagate nhẹ nhàng đến mức không có lý do gì panic cho lỗi đoán trước được. Trong khi đó Java/C# trộn lẫn checked exception với runtime exception, dễ dẫn đến anti-pattern catch (Exception e) { ... } nuốt mọi thứ.

Một ranh giới quan trọng: nếu bạn viết library, mặc định KHÔNG được panic dù chỉ một input lạ. Library mà panic giữa chừng ép caller phải dùng catch_unwind — rất bất tiện. Library tốt trả Result cho mọi lỗi caller-facing, chỉ panic khi caller phá invariant nội bộ của library.

5

Implicit Panic Trong Stdlib

Bạn không cần gõ panic! trực tiếp mới bị panic — stdlib có nhiều operation panic ngầm khi gặp đầu vào sai. Đây là danh sách thường gặp:

fn main() {
    // 1. Vec / array out-of-bound
    let v = vec![1, 2, 3];
    let x = v[10];                        // PANIC: index out of bounds: len 3, index 10

    // 2. String slice cắt giữa multi-byte char
    let s = "héllo";
    let bad = &s[0..1];                   // PANIC: byte index 1 is not a char boundary

    // 3. Chia cho 0 (integer)
    let a = 10;
    let b = 0;
    let c = a / b;                        // PANIC: attempt to divide by zero

    // 4. Integer overflow (chỉ panic ở debug profile, wrap ở release)
    let big: i32 = i32::MAX;
    let next = big + 1;                   // PANIC ở debug, wrap (overflow) ở release

    // 5. unwrap trên None / Err
    let opt: Option<i32> = None;
    let val = opt.unwrap();               // PANIC: called `Option::unwrap()` on a `None` value

    // 6. expect — giống unwrap nhưng kèm message
    let res: Result<i32, &str> = Err("oops");
    let v2 = res.expect("config required");  // PANIC: config required: "oops"
}

Ghi nhớ: chúng đều là panic! ngầm — kéo theo cùng cơ chế (unwind / abort, có thể catch bằng catch_unwind, in backtrace). Lập trình defensive: ưu tiên v.get(10) trả Option thay vì v[10], s.get(0..1) trả Option thay vì &s[0..1], dùng checked_div / checked_add cho integer khi không tin đầu vào. Pitfall &s[0..1] đã được phân tích chi tiết ở Bài 81: UTF-8 Boundary Panic.

Đặc biệt chú ý integer overflow: ở debug profile, Rust bật check, overflow → panic — giúp phát hiện bug sớm. Ở release profile, check bị tắt vì lý do hiệu năng, overflow → wrap (modulo 2^N). Nghĩa là code có thể chạy đúng trong test nhưng silent bug ở production nếu bạn không xử lý overflow cẩn thận.

6

panic! Macro Syntax

Macro panic! nhận format string y hệt println! / format! — placeholder {}, {value}, {:?}, {:#?}...

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        // Format string với captured identifier
        panic!("division by zero: a={a}, b={b}");
    }
    a / b
}

fn check_user(id: u64, name: &str) {
    if name.is_empty() {
        // Format string positional
        panic!("user {} có tên rỗng (invalid state)", id);
    }
}

fn process(opt: Option<String>) {
    let s = opt.unwrap_or_else(|| {
        // panic với Debug format
        panic!("input rỗng tại process(), trace = {:?}", std::backtrace::Backtrace::capture());
    });
    println!("{s}");
}

fn main() {
    divide(10, 0);
}

Vài lưu ý:

  • Format identifier {value} chỉ hoạt động với biến trong scope — không dùng được với biểu thức (vẫn phải viết {} + argument).
  • panic!() không có argument chỉ in chuỗi mặc định "explicit panic". Tránh — luôn kèm message để dễ debug.
  • Message nên trả lời câu "tại sao panic" + "giá trị nào liên quan". Người đọc backtrace sẽ cảm ơn bạn.
7

Unwind vs Abort

Khi panic xảy ra, Rust có 2 chiến lược chọn được qua Cargo.toml:

# Cargo.toml
[profile.dev]
panic = "unwind"     # mặc định, không cần khai báo

[profile.release]
panic = "unwind"     # mặc định
# hoặc
# panic = "abort"    # tắt unwind, exit ngay

panic = "unwind" (mặc định)

  • Rust chạy stack unwinding: đi ngược stack từ điểm panic về main, mỗi frame chạy Drop::drop của các biến local. Heap được giải phóng, file/socket được đóng, mutex được release.
  • Cho phép catch_unwind bắt lại panic — ranh giới này chỉ tồn tại khi unwind.
  • Binary lớn hơn vì compiler phải sinh unwinding table.

panic = "abort"

  • Khi panic, gọi std::process::abort() ngay — process kết thúc, OS thu hồi tài nguyên.
  • Không chạy destructor — nếu có tài nguyên cần cleanup (xoá file tạm, gửi log cuối), chúng sẽ bị mất.
  • catch_unwind KHÔNG hoạt động — không có gì để catch.
  • Binary nhỏ hơn vì bỏ unwinding table. Phù hợp embedded, WASM, microservice không cần cleanup ở cấp process.

Khuyến nghị: giữ unwind cho app thông thường, chuyển sang abort khi cần binary nhỏ và đã chắc chắn không cần cleanup ở cấp panic. Nhiều dự án embedded chọn abort mặc định.

8

catch_unwind — Catch Panic

std::panic::catch_unwind cho phép chạy một closure và bắt panic phát sinh trong closure đó, trả về Result<T, Box<dyn Any + Send>>. Chỉ hoạt động khi profile là unwind.

use std::panic;

// Use case 1: FFI boundary — Rust gọi từ C, panic vượt qua FFI là UB
#[no_mangle]
pub extern "C" fn my_c_api(input: i32) -> i32 {
    let result = panic::catch_unwind(|| {
        // Code Rust ở trong, có thể panic
        if input < 0 {
            panic!("input phải >= 0");
        }
        input * 2
    });

    match result {
        Ok(v)  => v,
        Err(_) => -1,     // báo lỗi qua return value cho phía C
    }
}

// Use case 2: test framework
fn run_test<F: FnOnce() + panic::UnwindSafe>(name: &str, test: F) {
    let result = panic::catch_unwind(test);
    match result {
        Ok(()) => println!("[PASS] {name}"),
        Err(_) => println!("[FAIL] {name}"),
    }
}

fn main() {
    run_test("test_a", || assert_eq!(1 + 1, 2));
    run_test("test_b", || assert_eq!(1 + 1, 3));   // panic, FAIL
    println!("Tất cả test đã chạy xong, process vẫn sống");
}

Quan trọng — đọc kỹ:

  • catch_unwind KHÔNG phải try / catch generic-purpose. KHÔNG dùng để xử lý lỗi business logic. Lỗi business → Result.
  • Use case chính: (a) FFI — panic vượt qua ranh giới Rust ↔ C là undefined behavior, phải catch lại trong wrapper Rust trước khi return cho C. (b) Test framework — chạy nhiều test, một test panic không được kéo theo cả process chết. (c) Long-running server — tránh 1 request panic kéo cả thread pool xuống (mặc dù Rust async runtime như Tokio đã catch sẵn ở task boundary).
  • Closure phải UnwindSafe — đảm bảo trạng thái nội bộ không bị "đứt giữa chừng". Nhiều type không tự động UnwindSafe; phải wrap trong AssertUnwindSafe nếu chắc chắn an toàn.
  • Không hoạt động khi panic = "abort". Library dùng catch_unwind mà user app cấu hình abort → process vẫn chết.
9

Backtrace Với RUST_BACKTRACE

Mặc định panic chỉ in message + file:line. Để có stack trace, bật biến môi trường RUST_BACKTRACE trước khi chạy:

# Linux / macOS
RUST_BACKTRACE=1 cargo run

# Backtrace đầy đủ hơn (kèm cả frame của std và panic handler)
RUST_BACKTRACE=full cargo run

# Windows PowerShell
$env:RUST_BACKTRACE = "1"
cargo run

# Windows cmd
set RUST_BACKTRACE=1
cargo run

Output mẫu khi bật RUST_BACKTRACE=1:

thread 'main' panicked at src/main.rs:4:9:
age không thể âm: -5
stack backtrace:
   0: rust_begin_unwind
   1: core::panicking::panic_fmt
   2: my_app::check_age
             at ./src/main.rs:4:9
   3: my_app::main
             at ./src/main.rs:12:5
   ...
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.

Lưu ý thực hành:

  • Backtrace chỉ useful khi build có debug symbol — debug profile mặc định có, release profile mặc định không. Để có backtrace ở release, set debug = true trong [profile.release] hoặc strip = false.
  • Production server nên log panic kèm backtrace đầy đủ. Cách thường gặp: set RUST_BACKTRACE=1 trong systemd unit / Dockerfile / CI env.
  • Crate color-eyre / anyhow kết hợp backtrace của panic và backtrace của Result error trong cùng một cơ chế — sẽ học chi tiết ở các bài cuối Nhóm 19.
  • Có thể chủ động capture trong code bằng std::backtrace::Backtrace::capture() — không cần env var, nhưng cần build có debug info.
10

Tổng Kết

  • panic! abort thread hiện tại, in error + (tuỳ RUST_BACKTRACE) backtrace, không cho resume execution.
  • Dùng panic cho bug của lập trình viên: invariant violation, contract broken, prototype, test. KHÔNG dùng cho lỗi đoán trước được (file not found, network, parse input) — những lỗi đó thuộc về Result.
  • Implicit panic phổ biến: v[10], &s[0..1] cắt UTF-8, chia 0 integer, overflow ở debug, unwrap() trên None/Err. Dùng get, checked_* để tránh.
  • Macro syntax giống println! — luôn kèm message giải thích "tại sao" và biến liên quan.
  • panic = "unwind" (default) chạy destructor và cho phép catch_unwind; panic = "abort" exit ngay, binary nhỏ hơn, mất cleanup.
  • catch_unwind chỉ dùng ở FFI boundary, test framework, server thread isolation — KHÔNG dùng như try / catch generic.
  • RUST_BACKTRACE=1 hoặc full để in stack trace khi debug; production nên bật sẵn.
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Bạn viết hàm fn read_config(path: &str) -> Config đọc file YAML và parse. Nên trả thẳng Config và panic khi file thiếu, hay trả Result<Config, MyError>? Vì sao?
  2. Code let x = vec![1,2,3][10]; panic. Viết lại bằng get để không panic và in message thân thiện nếu index sai.
  3. Trong CI bạn thấy job test fail với log "thread 'main' panicked at..." nhưng không có stack trace. Cần thay đổi gì để có backtrace?
  4. Bạn viết FFI export extern "C" fn rust_compute(...). Vì sao bên trong phải bọc catch_unwind?
  5. Trong release profile, code let x: i32 = i32::MAX + 1; sẽ làm gì? Trong debug thì sao?
Đáp án
  1. Trả Result<Config, MyError>. File config có thể thiếu, sai format, không có quyền đọc — đều là lỗi môi trường, KHÔNG phải bug lập trình viên. Caller có quyền chọn fallback (dùng default config), retry, hoặc thông báo cho user. Hàm panic ép caller phải catch_unwind hoặc chấp nhận chết process — bất lịch sự.
  2. match vec![1,2,3].get(10) { Some(&x) => println!("{x}"), None => eprintln!("index out of range") }get trả Option<&T>, không panic.
  3. Set RUST_BACKTRACE=1 (hoặc full) trong CI env. Ngoài ra, nếu chạy release thì cần debug = true trong [profile.release] để có debug symbol — không có symbol thì backtrace chỉ là địa chỉ raw.
  4. Panic vượt qua ranh giới Rust ↔ C là undefined behavior theo spec. Phía C không có cơ chế stack unwinding tương thích — process có thể crash, corrupt memory, hoặc tệ hơn là silent bug. Bọc catch_unwind để chặn panic ngay trong Rust, sau đó convert sang error code / sentinel value trả về cho C.
  5. Trong debug: panic với "attempt to add with overflow" — Rust bật check overflow để bắt bug sớm. Trong release: wrap modulo 2^32 — i32::MAX + 1 trở thành i32::MIN (-2147483648), không panic. Bug silent. Để consistent giữa 2 mode, dùng checked_add (trả Option), wrapping_add (luôn wrap), hoặc saturating_add (clamp về MAX/MIN).
12

Bài Tiếp Theo

Bài 141: Result<T,E> — Recoverable Error — chuyển sang phía kia của error handling: enum Result<T, E> với 2 variant Ok(T) / Err(E), chuẩn của Rust cho fallible function. Học pattern match Result, các method map / and_then / map_err, ý nghĩa #[must_use], và preview operator ? (sẽ học sâu ở Bài 143). Sau bài 141 bạn sẽ thấy hầu hết function trong stdlib (đọc file, parse số, network) đều trả Result — và biết cách handle gọn.