Mười một bài qua đo cách Rust bảo vệ bạn: ownership, borrow checker, lifetime, Send/Sync — mỗi cái chặn một lớp bug ngay lúc biên dịch. Bài cuối này đo điều ngược lại: khi nào và tại sao bạn tự tay tháo một phần những đảm bảo đó. Vì đôi khi bạn phải — để nói chuyện với phần cứng, gọi thư viện C có sẵn, hay viết một cấu trúc dữ liệu mà borrow checker không chứng minh nổi là an toàn dù nó thật sự an toàn. Công cụ đó là unsafe, và điều quan trọng nhất về nó thường bị hiểu sai. Đo lần cuối trong container rust:1 (rustc 1.99.1), rồi khép lại cả sê-ri.

unsafe không tắt borrow checker

Hiểu lầm phổ biến nhất: unsafe "tắt kiểm tra an toàn của Rust". Sai. Trong một khối unsafe, borrow checker vẫn chạy đầy đủ, ownership vẫn áp, lifetime vẫn kiểm. unsafe chỉ mở thêm đúng 5 thao tác mà bình thường bị cấm:

  1. Deref một con trỏ thô (*const T, *mut T).
  2. Gọi một hàm unsafe (gồm mọi hàm FFI từ C).
  3. Truy cập/sửa một static mut (biến toàn cục có thể đổi).
  4. Cài một trait unsafe (như Send, Sync bằng tay).
  5. Truy cập trường của một union.

Chỉ vậy. unsafe không phải "vùng cho phép mọi thứ"; nó là một danh sách 5 mục rất hẹp. Ý nghĩa thật của nó: với 5 thao tác này, trình biên dịch không thể tự chứng minh chúng an toàn, nên nó chuyển trách nhiệm cho bạn — "tôi, lập trình viên, đảm bảo đoạn này đúng". Phần còn lại của ngôn ngữ vẫn canh bạn như thường.

Con trỏ thô: tạo thì an toàn, deref thì unsafe

Thao tác số 1 là cốt lõi. Tạo một con trỏ thô hoàn toàn an toàn; chỉ deref nó mới cần unsafe:

let mut x = 42i32;
let p = &mut x as *mut i32;    // tạo con trỏ thô — AN TOÀN
unsafe {
    *p = 100;                  // deref để ghi — phải trong unsafe
    println!("đọc qua con trỏ thô: {}", *p);
}
đọc qua con trỏ thô: 100
x giờ = 100

Vì sao deref cần unsafe? Vì con trỏ thô không mang đảm bảo nào: nó có thể null, có thể trỏ vào bộ nhớ đã giải phóng, có thể lệch căn lề. Borrow checker không theo dõi nó như theo dõi &T. Nên khi bạn deref, bạn tự nhận trách nhiệm rằng con trỏ hợp lệ. Đây cũng là cách con trỏ thô làm được thứ tham chiếu an toàn cấm — ví dụ, lấy hai &mut tới hai vị trí khác nhau của một mảng, điều mà luật aliasing XOR mutability ở bài 2 không cho qua tham chiếu thường:

let p1 = arr.as_mut_ptr();
unsafe {
    let a = &mut *p1;            // &mut tới phần tử 0
    let b = &mut *p1.add(2);     // &mut tới phần tử 2 — THẬT SỰ an toàn vì khác vị trí
    *a += 10; *b += 20;
}
mảng sau khi sửa qua 2 &mut không chồng: [11, 2, 23, 4]

Thao tác này thật sự an toàn — hai &mut trỏ hai ô khác nhau, không bao giờ đụng nhau — nhưng borrow checker không đủ thông minh để chứng minh điều đó, nên nó từ chối cách viết an toàn. unsafe là chỗ bạn nói "tôi biết nó an toàn, tin tôi". (Chính vì mẫu này hữu dụng mà thư viện chuẩn gói sẵn nó thành hàm an toàn split_at_mut — dùng unsafe bên trong, nhưng phơi ra một API mà người dùng không cần unsafe.)

Ảnh chụp sơ đồ nền tối giải thích unsafe và FFI trong Rust, bên trái hiểu lầm unsafe tắt borrow checker là SAI thực tế unsafe chỉ mở thêm đúng 5 thao tác deref con trỏ thô gọi hàm unsafe gồm FFI truy cập static mut cài trait unsafe như Send Sync truy cập trường union phần còn lại borrow checker ownership lifetime vẫn canh như thường, ở giữa con trỏ thô tạo thì an toàn deref phải trong unsafe vì con trỏ thô không mang đảm bảo có thể null trỏ bộ nhớ đã giải phóng lệch căn lề làm được thứ tham chiếu cấm như hai và mut tới hai vị trí khác nhau của mảng thư viện chuẩn gói thành split_at_mut an toàn, bên phải FFI gọi hàm C khai extern C gọi trong unsafe kết quả khớp Rust 88 bằng 88 benchmark 500 triệu lần gọi qua C 412 ms Rust native 404 ms chênh 1,9 phần trăm FFI gần như không phụ phí vì hàm Rust và C dùng cùng quy ước gọi

Hình 1: unsafe chỉ mở thêm 5 thao tác, không tắt borrow checker; con trỏ thô deref trong unsafe; và FFI gọi hàm C gần như không phụ phí.

FFI: gọi hàm C gần như không phụ phí

Thao tác số 2 mở cánh cửa quan trọng nhất về mặt thực dụng: FFI (Foreign Function Interface) — gọi mã viết bằng ngôn ngữ khác, thường là C. Khai báo hàm C bằng extern "C", rồi gọi nó trong unsafe (vì trình biên dịch Rust không kiểm được mã C):

extern "C" { fn c_bien_doi(x: u64) -> u64; }   // hàm C: return x*x + 7;
let a = unsafe { c_bien_doi(9) };
c_bien_doi(9) = 88   rust_bien_doi(9) = 88   (khớp: true)

500.000.000 lần gọi:
gọi hàm C qua FFI    : 412 ms
gọi hàm Rust (native) : 404 ms
chênh: 1,9%

Hàm C cho đúng kết quả như hàm Rust tương đương (88 = 88), và quan trọng: gọi nó qua FFI chỉ chậm 1,9% so với gọi một hàm Rust — nằm trong nhiễu đo. Lý do là Rust và C dùng cùng quy ước gọi (calling convention) ở mức ABI: không có lớp dịch, không marshaling, không sao chép dữ liệu — một lời gọi FFI biên dịch ra gần như cùng những lệnh với một lời gọi hàm thường. Đây là lý do Rust là lựa chọn tốt để viết thư viện gọi được từ C (và ngược lại, dùng lại kho thư viện C khổng lồ): cầu nối giữa hai ngôn ngữ gần như miễn phí. Cái giá của FFI không nằm ở tốc độ, mà ở an toàn — trình biên dịch không thể bảo chứng mã C, nên mọi lời gọi là một lời hứa của bạn.

unsafe đúng cách: gói nó lại

Bài học thực dụng của cả chương này: đừng rải unsafe khắp nơi — gói nó vào trong một API an toàn. Thư viện chuẩn Rust đầy unsafe bên trong (Vec, split_at_mut, mọi nguyên thủy đồng bộ), nhưng người dùng gần như không bao giờ phải viết unsafe, vì mỗi khối unsafe được bọc trong một hàm an toàn đã được kiểm chứng kỹ. Mẫu đúng là: một vùng unsafe nhỏ, có lý lẽ rõ ràng vì sao nó an toàn, phơi ra một giao diện mà người gọi dùng hoàn toàn trong Rust an toàn. unsafe không phải cách lách luật; nó là nơi bạn tự chứng minh một bất biến mà trình biên dịch không chứng minh hộ được — rồi khoá bằng chứng đó sau một API sạch.

Ảnh chụp sơ đồ nền tối bảng tổng kết sê-ri Rust đo thật 12 phần mỗi phần một con số cốt lõi, phần 1 ownership và move dùng lại sau move lỗi E0382, phần 2 borrow checker aliasing XOR mutability E0502 E0499 E0515, phần 3 bố cục bộ nhớ repr Rust 16 byte vs repr C 32 byte Option tham chiếu 8 byte niche, phần 4 zero cost iterator bằng vòng tay 9,94 vs 10,44 ms 0 lệnh gọi, phần 5 Box Rc Arc clone 2,47 ns vs 3,62 ns atomic Rc qua luồng E0277, phần 6 Vec nhân đôi 0 4 tới 2048 push O1 khấu hao, phần 7 trait tĩnh 0 gọi gián tiếp vs động vtable và dyn 16 byte, phần 8 lifetime E0106 E0597 bị xoá sau biên dịch 0 byte, phần 9 Result chênh 0,4 phần trăm panic 1585 ns, phần 10 đồng thời Cell E0277 Mutex 193 vs atomic 91 ms chia để trị gấp 3, phần 11 async 393 byte mỗi task vs luồng 9800 byte, phần 12 unsafe mở 5 thao tác FFI chênh 1,9 phần trăm, luận điểm xuyên suốt Rust trả giá an toàn lúc biên dịch để lúc chạy gần như không phải trả gì

Hình 2: Tổng kết 12 phần sê-ri — mỗi phần một con số đo thật, cùng chứng minh một luận điểm: Rust trả giá an toàn lúc biên dịch, lúc chạy gần như miễn phí.

Tổng kết sê-ri: Rust đo thật, 12 phần

Sê-ri này có một luận điểm xuyên suốt, và mọi con số đều phục vụ nó: Rust trả cái giá an toàn ở thời điểm biên dịch, để lúc chạy gần như không phải trả gì. Nhìn lại 12 phần:

# Chủ đề Con số cốt lõi đo được
1 Ownership & move Dùng lại sau move = lỗi E0382; move chỉ chuyển 3 word, heap đứng yên; drop xác định
2 Borrow checker Aliasing XOR mutability: E0502/E0499/E0515; iterator invalidation bị chặn
3 Bố cục bộ nhớ repr(Rust) tự sắp còn 16 byte vs repr(C) 32; Option<&T>=8 nhờ niche; ZST=0 byte
4 Zero-cost iterator iter().filter().map().sum() = vòng tay (9,94 vs 10,44 ms, 0 lệnh gọi)
5 Box, Rc, Arc Rc::clone 2,47 ns vs Arc 3,62 ns (atomic); Rc qua luồng = E0277
6 Vec & capacity Nhân đôi 0→4→…→2048, push O(1) khấu hao; grow-by-1 chậm ×4
7 Trait dispatch Tĩnh 0 lệnh gọi gián tiếp (12,4 ms) vs động vtable blr (15,5 ms); &dyn=16 byte
8 Lifetime E0106/E0597; lifetime bị xoá sau biên dịch, struct giữ &str vẫn 16 byte
9 Result & panic Result đường thành công chênh 0,4%; panic+catch 1585 ns, đắt hàng nghìn lần
10 Đồng thời Cell qua luồng = E0277; Mutex 193 ms vs atomic 91 ms; chia-để-trị ×3
11 Async/Tokio 100.000 task ~393 byte/task trên 10 luồng vs luồng HĐH ~9800 byte
12 unsafe & FFI unsafe mở 5 thao tác, không tắt borrow checker; FFI gọi C chênh 1,9%

Nối lại, bức tranh hiện rõ. Những lỗi biên dịch — E0382, E0502, E0277, E0106, E0597 — không phải sự khó chịu của một ngôn ngữ cầu kỳ; mỗi cái là một lớp bug bộ nhớ hoặc đua tranh bị xoá khỏi lúc chạy. Và những con số thời gian — iterator ngang vòng tay, lifetime 0 byte, FFI chênh 1,9%, Result 0,4% — chứng minh cái giá cho sự an toàn đó gần như bằng không khi đã tối ưu. Rust không bắt bạn chọn giữa an toàn và tốc độ; nó dời phần lớn chi phí an toàn sang trình biên dịch. unsafe là van xả cho đúng những lúc bạn cần vượt qua cái trình biên dịch chưa chứng minh được — dùng hẹp, gói kỹ, và phần còn lại của chương trình vẫn an toàn như thường.

Cảm ơn bạn đã theo hết 12 phần. Mọi con số ở đây đều đo thật trong container, không trích dẫn lại — bạn có thể tự chạy để kiểm chứng. Đó là tinh thần của cả sê-ri.