Trang chủ

Khi nào unsafe là chính đáng, và bạn nợ gì khi dùng nó

Cách dùng sai unsafe phổ biến nhất trong Rust là coi nó như cửa thoát hiểm khỏi borrow checker. Không phải vậy. Nó không tắt việc kiểm tra mượn, không tắt hệ thống kiểu, và không khiến một thiết kế đang đánh nhau với trình biên dịch bỗng dưng trở nên đúng.

Thứ nó làm là mở khóa năm thao tác, và lấy trách nhiệm về các điều kiện tiên quyết của chúng khỏi trình biên dịch rồi trao cho bạn.

Năm thứ nó cho phép

  1. Giải tham chiếu một con trỏ thô
  2. Gọi một hàm unsafe, bao gồm mọi FFI
  3. Truy cập hoặc sửa một static khả biến
  4. Cài đặt một trait unsafe (Send, Sync)
  5. Truy cập một trường của một union

Đó là toàn bộ danh sách. Mọi thứ khác trong ngôn ngữ hoạt động y hệt bên trong một khối unsafe — các phép mượn vẫn được kiểm tra, các kiểu vẫn được thi hành, các lifetime vẫn áp dụng.

Nếu lỗi của bạn là “cannot borrow as mutable more than once” thì unsafe không giúp được gì. Trình biên dịch đang mô tả một vấn đề bí danh có thật, và bọc nó lại chẳng thay đổi điều gì.

Ba lý do chính đáng

Giao diện hàm ngoại lai. Gọi C, hoặc bị C gọi. Trình biên dịch không kiểm chứng được một hợp đồng sống ở một ngôn ngữ khác, nên phải có ai đó làm việc ấy, và người đó là bạn.

extern "C" {
    fn compute_checksum(data: *const u8, length: usize) -> u32;
}

pub fn checksum(data: &[u8]) -> u32 {
    // SAFETY: `data` là một lát cắt hợp lệ, nên con trỏ khác null, đúng căn chỉnh,
    // và hợp lệ cho `len` byte. `compute_checksum` không giữ lại con trỏ.
    unsafe { compute_checksum(data.as_ptr(), data.len()) }
}

Dựng một lớp trừu tượng mà borrow checker không diễn đạt được. Vec, RefCell, Rc và Mutex đều được cài đặt bằng unsafe bên trong. Chúng tồn tại để cung cấp một API an toàn trên một thao tác mà bộ kiểm tra không chứng minh được là đúng.

pub fn split_at_mut(&mut self, index: usize) -> (&mut [T], &mut [T]) {
    // hai tham chiếu khả biến vào cùng một lát cắt — bộ kiểm tra không thấy được chúng rời nhau
}

Hiệu năng đã được đo và thật sự cần. Bỏ một phép kiểm tra biên trong một vòng lặp mà trình đo hiệu năng đã chỉ ra là nóng. Đây là lý do chính đáng hiếm gặp nhất và là lý do được viện dẫn nhiều nhất.

Bạn nợ gì: dòng chú thích về tính an toàn

Mọi khối unsafe đều nên mang theo một dòng chú thích nói rõ vì sao nó đúng đắn — những bất biến nào đang giữ, và ai bảo đảm chúng.

// SAFETY: `index` đã được kiểm tra với `self.len` ở trên, nên độ dời nằm trong
// vùng cấp phát. `self.pointer` khác null và đúng căn chỉnh trong suốt vòng đời của
// `self` vì nó đến từ một lần `alloc` thành công trong `with_capacity`.
unsafe { *self.pointer.add(index) }

Đây không phải phép lịch sự về tài liệu. Trình biên dịch đã thôi kiểm tra; dòng chú thích là bản ghi duy nhất còn lại về thứ khiến nó đúng. Không có nó, người tiếp theo — kể cả chính bạn sau sáu tháng — không kiểm chứng hay sửa đổi an toàn được đoạn code, vì lập luận khiến nó đúng đắn đã biến mất.

Cảnh báo

Hãy thi hành điều đó. #![deny(clippy::undocumented_unsafe_blocks)] biến một dòng chú thích an toàn bị thiếu thành một lỗi build. Nó là một dòng và là cái lint có giá trị cao nhất trong cả ngôn ngữ.

Giữ nó nhỏ và bọc nó lại

Hai quy tắc giới hạn thiệt hại.

Khối bao lấy thao tác, không bao cả hàm. Một unsafe fn dài một trăm dòng là một trăm dòng bạn phải rà soát. Một khối ba dòng bên trong một hàm an toàn thì là ba dòng.

Một API an toàn đặt lên trên. Mục đích của unsafe là được giam lại. Nếu bên gọi phải duy trì một bất biến nào đó thì mới dùng hàm của bạn an toàn được, thì hàm đó phải là unsafe fn và phải nói ra điều ấy — đẩy gánh nặng ra ngoài mà không đánh dấu chính là cách một lỗi về tính đúng đắn lan ra khắp một codebase.

// Sai: chữ ký an toàn, yêu cầu thì không an toàn
pub fn get_unchecked(&self, index: usize) -> &T { … }

// Đúng: chữ ký nói rằng bên gọi có một nghĩa vụ
pub unsafe fn get_unchecked(&self, index: usize) -> &T { … }

Các công cụ kiểm tra thứ trình biên dịch không kiểm tra được

Miri thông dịch code của bạn và phát hiện hành vi không xác định — truy cập ngoài biên, dùng sau khi giải phóng, căn chỉnh sai, đua dữ liệu:

cargo +nightly miri test

Nó chậm và nó tìm ra những con bug thật mà mọi bài test của bạn đi lọt qua. Mọi crate có unsafe đều nên chạy nó trong CI.

AddressSanitizer cho FFI, nơi vấn đề thường nằm ở phía C:

RUSTFLAGS="-Z sanitizer=address" cargo +nightly test

Câu hỏi cần hỏi trước tiên

Trước khi viết unsafe, hãy hỏi: đã có ai bọc sẵn thứ này chưa?

Câu trả lời thường là rồi. bytemuck cho các phép chuyển kiểu an toàn, zerocopy cho chuyển đổi ở mức byte, crossbeam cho các cấu trúc không khóa, parking_lot cho các cái khóa, slotmap cho các khuôn mẫu vùng nhớ mà nếu không thì cần đến con trỏ thô.

Dùng một crate được kiểm thử kỹ có chứa unsafe hoàn toàn khác với việc tự viết của mình. Của họ đã được review, fuzz và chạy dưới Miri bởi những người chuyên đúng việc này. Của tôi thì được đọc bởi tôi, đúng một lần, vào cái ngày tôi viết nó.

Sự bất đối xứng đó mới là lập luận thật. Những trường hợp unsafe trong code ứng dụng là chính đáng thì hiếm, và những trường hợp nó cần thiết — vì chưa ai dựng cái lớp trừu tượng ấy — còn hiếm hơn nữa.