Bỏ qua để đến nội dung

Unsafe trong Rust

Mọi đảm bảo mà khóa học này đã nhắc tới (không có tham chiếu treo, không có data race, không truy cập ngoài giới hạn) đều do trình biên dịch giữ. unsafe là cách bạn nói với trình biên dịch: “cho tôi làm một việc mà bạn không kiểm chứng được là an toàn; tôi sẽ tự chịu trách nhiệm.”

Nhiều người nghĩ unsafe tắt hết các kiểm tra an toàn của Rust. Không phải vậy. Bên trong một khối unsafe, bạn chỉ được làm thêm đúng năm việc mà bình thường bị cấm:

  1. Giải tham chiếu (dereference) một con trỏ thô (raw pointer)
  2. Gọi một hàm unsafe
  3. Đọc hoặc sửa một biến static khả biến (mutable)
  4. Cài đặt (implement) một trait unsafe
  5. Truy cập các trường của một union

Mọi thứ khác, như quy tắc mượn (borrowing), ngữ nghĩa move, kiểm tra kiểu, vẫn áp dụng đầy đủ bên trong code unsafe. Nó là con dao mổ cho năm thao tác cụ thể, không phải công tắc tắt mọi thứ.

Tham chiếu bình thường của Rust (& và &mut) luôn trỏ tới dữ liệu hợp lệ và được căn chỉnh (aligned) đúng; đó là một phần những gì trình biên dịch đảm bảo. Con trỏ thô (*const T hoặc *mut T) bỏ đảm bảo đó, nên muốn đọc nó thì cần unsafe:

fn main() {
let value = 5;
let pointer = &value as *const i32;
unsafe {
println!("{}", *pointer);
}
}

Kết quả:

5
▶ Tự chạy thử

Bạn có thể tạo con trỏ thô trong code an toàn. Chỉ việc giải tham chiếu nó (đọc giá trị nó trỏ tới) mới cần unsafe, vì lúc đó trình biên dịch không còn kiểm chứng được con trỏ có hợp lệ hay không.

Có những hàm tự chúng được đánh dấu unsafe, vì chúng có yêu cầu mà trình biên dịch không kiểm tra hộ bạn được. Gọi chúng nghĩa là bạn hứa rằng mình đã tự kiểm tra các yêu cầu đó:

unsafe fn dangerous() {
println!("hàm này tin rằng bạn đã kiểm tra các yêu cầu của nó");
}
fn main() {
unsafe {
dangerous();
}
}

Kết quả:

hàm này tin rằng bạn đã kiểm tra các yêu cầu của nó
▶ Tự chạy thử

Thư viện chuẩn có những hàm unsafe thật mà bạn có thể gọi. Một ví dụ là get_unchecked trên slice. Nó hoạt động giống numbers[i], nhưng bỏ qua bước kiểm tra giới hạn. Nếu i nằm ngoài phạm vi, nó không panic. Nó đọc bất cứ thứ gì đang nằm ở vùng nhớ đó, và điều này gọi là hành vi không xác định (undefined behavior): chương trình có thể crash, in ra rác, hoặc trông có vẻ ổn cho đến lúc không còn ổn nữa.

Vì vậy quy tắc khi gọi nó là “chỉ dùng với chỉ số mà bạn biết chắc là hợp lệ”. Cách làm quen thuộc là tự kiểm tra quy tắc đó bên trong một hàm bình thường, an toàn:

fn first_or_zero(numbers: &[i32]) -> i32 {
if numbers.is_empty() {
return 0;
}
// SAFETY: slice không rỗng, nên chỉ số 0 nằm trong giới hạn.
unsafe { *numbers.get_unchecked(0) }
}
fn main() {
println!("{}", first_or_zero(&[7, 8, 9]));
println!("{}", first_or_zero(&[]));
}

Kết quả:

7
0
▶ Tự chạy thử

Hãy nhìn những gì người gọi thấy: fn first_or_zero(numbers: &[i32]) -> i32. Chữ ký hàm không có unsafe, và không có cách nào gọi sai, vì hàm kiểm tra quy tắc trước khi đi tới khối unsafe. Phần unsafe vừa nhỏ vừa được giấu kín bên trong.

Hai thói quen mà các lập trình viên Rust có kinh nghiệm luôn giữ:

  • Viết một chú thích // SAFETY: phía trên mỗi khối unsafe, giải thích vì sao các quy tắc được thỏa mãn. Nó dành cho người đọc code tiếp theo, và cho chính bạn sau này.
  • Giữ khối unsafe nhỏ nhất có thể, để có ít code phải kiểm tra lại hơn.

Nói thật, bạn sẽ không viết đúng hàm này trong code thực tế: numbers.first() làm cùng việc một cách an toàn, và trình biên dịch rất giỏi loại bỏ những lần kiểm tra giới hạn mà nó chứng minh được là thừa. Nhưng khuôn mẫu này (kiểm tra quy tắc, rồi gọi unsafe bên trong một hàm an toàn) chính là cách Vec, String và các anh em của chúng được xây dựng. Trang FFI dùng đúng mẹo này để bọc các hàm C.

Nếu Rust đặt an toàn lên hàng đầu, tại sao lại cho phép điều này? Bởi vì có những việc thực sự không thể chứng minh là an toàn lúc biên dịch, dù trên thực tế chúng hoàn toàn an toàn: làm việc trực tiếp với phần cứng, gọi vào một thư viện C (xem FFI), hay xây dựng một số cấu trúc dữ liệu hiệu năng cao. unsafe cho phép thư viện chuẩn (và hiếm khi là code của chính bạn) cài đặt những mảnh cấp thấp này, trong khi mọi thứ xây trên chúng vẫn là Rust an toàn bình thường, được kiểm tra đầy đủ.

Viết unsafe không có nghĩa là code của bạn giờ có lỗi. Nó có nghĩa là trong khối đó, trình biên dịch không còn bắt được một số loại sai sót cho bạn nữa, và bạn phải tự tay giữ những đảm bảo ấy. Phần lớn lập trình viên Rust có kinh nghiệm viết rất ít unsafe trực tiếp; nó thường nằm trong vài góc nhỏ, được review kỹ, ở tầng dưới cùng của một thư viện, và được bọc trong một API an toàn cho mọi người khác dùng.

Tạo let mut score = 10;, tạo một con trỏ thô *mut i32 trỏ tới nó, rồi dùng con trỏ đó để cộng thêm 5. Sau đó in score. Gợi ý: tạo con trỏ bằng &mut score as *mut i32, và viết *pointer += 5 bên trong một khối unsafe.

Xem lời giải
fn main() {
let mut score = 10;
let pointer = &mut score as *mut i32; // tạo con trỏ thì an toàn
unsafe {
*pointer += 5; // ghi qua con trỏ thì không
}
println!("score = {score}");
}

Kết quả:

score = 15
▶ Tự chạy thử

Trên thực tế điều này an toàn, vì score vẫn còn sống và không có gì khác dùng nó trong lúc chúng ta ghi. Giữ những lời hứa đó là việc của bạn khi dùng unsafe.

Viết fn get_or(numbers: &[i32], index: usize, default: i32) -> i32. Hàm trả về phần tử ở index, hoặc default nếu chỉ số nằm ngoài phạm vi. Dùng get_unchecked để đọc, và đảm bảo hàm không bao giờ bị gọi theo cách phá vỡ quy tắc của nó. Gợi ý: so sánh index < numbers.len() trước, và chỉ gọi get_unchecked khi điều đó đúng.

Xem lời giải
fn get_or(numbers: &[i32], index: usize, default: i32) -> i32 {
if index < numbers.len() {
// SAFETY: chúng ta vừa kiểm tra index nằm trong giới hạn.
unsafe { *numbers.get_unchecked(index) }
} else {
default
}
}
fn main() {
let numbers = [10, 20, 30];
println!("{}", get_or(&numbers, 1, -1));
println!("{}", get_or(&numbers, 5, -1));
}

Kết quả:

20
-1
▶ Tự chạy thử

Chỉ số 5 không bao giờ đi tới khối unsafe, nên nó được xử lý an toàn. Để so sánh, phiên bản hoàn toàn an toàn là numbers.get(index).copied().unwrap_or(default), và đó là cách bạn thường sẽ viết.

  • unsafe mở khóa đúng năm thao tác (chủ yếu là con trỏ thô và gọi hàm unsafe); mọi thứ khác vẫn được kiểm tra đầy đủ.
  • Con trỏ thô (*const T / *mut T) có thể được tạo trong code an toàn; giải tham chiếu nó thì cần unsafe.
  • Bọc unsafe trong một hàm an toàn, nhỏ, kiểm tra quy tắc trước, và giải thích vì sao nó đúng trong một chú thích // SAFETY:.
  • unsafe tồn tại để có thể xây dựng code cấp thấp (truy cập phần cứng, làm việc với C), trong khi code dùng nó vẫn an toàn.
  • Là người mới, bạn hiếm khi cần tự viết nó; hãy tìm một lớp trừu tượng an toàn trước.

Kiểm tra nhanh

1. Một khối unsafe thực sự mở khóa điều gì?

2. Bạn có thể tạo con trỏ thô trong code an toàn không?

3. Là người mới, bạn nên dự tính tự viết unsafe thường xuyên đến mức nào?

4. Viết code unsafe có nghĩa là chương trình của bạn giờ có lỗi không?

Điểm: 0 / 4