Bài 1 đặt luật "mỗi giá trị một chủ". Nhưng đời thực có những lúc bạn cần đúng cái ngược lại: một dữ liệu mà nhiều nơi cùng sở hữu (một nút trong đồ thị, một cấu hình dùng chung), hoặc một giá trị phải nằm trên heap (kiểu đệ quy, dữ liệu lớn không muốn copy khi move). Rust giải quyết bằng con trỏ thông minh (smart pointer): các kiểu bọc một con trỏ heap kèm quy tắc sở hữu riêng. Bài này đo ba cái dùng nhiều nhất — Box, Rc, Arc — và quan trọng hơn, đo cái giá của chúng, vì khác với bài trước, đây là những trừu tượng có chi phí, và chọn sai thì trả tiền oan. Tất cả trong container rust:1 (rustc 1.99.1).

Box: một chủ, trên heap

Box<T> là con trỏ thông minh đơn giản nhất: nó sở hữu duy nhất một giá trị nằm trên heap. Trên stack, Box chỉ là một con trỏ 8 byte, bất kể dữ liệu nó trỏ tới lớn cỡ nào:

Box<i32>        size = 8   (chỉ 1 con trỏ)
Box<[u8;1024]>  size = 8   (dữ liệu 1024 byte nằm heap, stack chỉ giữ con trỏ)

Đây là công dụng đầu: đưa dữ liệu lớn hoặc kích thước chưa biết lúc biên dịch xuống heap, để move nó chỉ là chuyển một con trỏ 8 byte (như bài 1 đo với String). Công dụng thứ hai là kiểu đệ quy: một enum cây mà một biến thể chứa chính nó sẽ có kích thước vô hạn nếu nhúng trực tiếp — Box cắt vòng đó bằng một con trỏ cố định 8 byte. Box tuân đúng luật ownership: một chủ, drop khi hết phạm vi, giải phóng heap ngay. Nó là trừu tượng gần như miễn phí — chỉ một lần cấp phát và một con trỏ.

Rc: nhiều chủ, đếm tham chiếu

Khi bạn thật sự cần nhiều chủ cho cùng một dữ liệu, Rc<T> (reference counted) giữ một bộ đếm: mỗi Rc::clone tăng đếm lên 1, mỗi lần drop giảm 1, và dữ liệu chỉ được giải phóng khi đếm về 0. Mấu chốt: clone ở đây không sao chép dữ liệu, chỉ cộng bộ đếm.

let a = Rc::new(String::from("dữ liệu chia sẻ"));   // strong_count = 1
let b = Rc::clone(&a);                               // strong_count = 2
{
    let _c = Rc::clone(&a);                          // strong_count = 3
}                                                     // _c drop → strong_count = 2
tạo a            -> strong_count = 1
clone thành b    -> strong_count = 2
clone thành c    -> strong_count = 3
c ra khỏi scope  -> strong_count = 2
Rc::clone địa chỉ heap giống nhau: 0xaaaab7218af0 == 0xaaaab7218af0
drop b           -> strong_count = 1

Bộ đếm lên xuống đúng theo số chủ đang sống, và hai bản a, b trỏ về cùng một địa chỉ heap — bằng chứng rằng String chỉ tồn tại một bản, không hề nhân đôi. Đây là lý do Rc::clone rẻ: nó không đụng tới dữ liệu, chỉ +1 một số nguyên. (Đổi lại, Rc chỉ cho mượn bất biến — muốn sửa dữ liệu chia sẻ cần thêm RefCell, một chủ đề riêng.) Chữ "strong" ngụ ý có cả "weak": Weak<T> là tham chiếu không giữ dữ liệu sống, dùng để cắt chu trình tham chiếu — nhưng đó là chuyện khác.

Ảnh chụp sơ đồ nền tối ba con trỏ thông minh Box Rc Arc trong Rust, bên trái Box T một chủ duy nhất trên heap stack chỉ giữ một con trỏ 8 byte mũi tên trỏ xuống heap chứa dữ liệu dù 1024 byte stack vẫn 8 byte, ở giữa Rc T nhiều chủ a b c cùng trỏ vào một ô heap chứa bộ đếm strong count bằng 3 và dữ liệu String chia sẻ mỗi Rc clone chỉ cộng 1 bộ đếm không sao chép dữ liệu khi đếm về 0 mới giải phóng cùng một địa chỉ heap, bên phải Arc T giống Rc nhưng bộ đếm là nguyên tử atomic để nhiều luồng cùng tăng giảm an toàn, phía dưới bảng chi phí 100 triệu lần clone cộng drop Rc 2,47 ns mỗi lần đếm thường Arc 3,62 ns mỗi lần đếm nguyên tử Arc chậm gấp 1,5 lần, và ghi chú Rc không Send dùng qua luồng cho lỗi biên dịch E0277 cannot be sent between threads safely

Hình 1: Box một chủ trên heap; Rc nhiều chủ qua bộ đếm thường; Arc như Rc nhưng đếm nguyên tử cho đa luồng — kèm chi phí đo được.

Arc: như Rc nhưng an toàn qua luồng — và đắt hơn

Rc cố tình không an toàn khi chia sẻ qua luồng: bộ đếm của nó là phép tăng/giảm số nguyên thường, hai luồng cùng sửa sẽ đua tranh và làm hỏng đếm (giải phóng sớm hoặc rò rỉ). Arc<T> (atomic Rc) giải quyết bằng cách làm bộ đếm nguyên tử (atomic) — nhưng phép nguyên tử đắt hơn phép thường, và ta đo được chính xác bao nhiêu:

100.000.000 lần clone+drop:
Rc  (đếm thường, không nguyên tử) : 0,247 s   (2,47 ns/lần)
Arc (đếm nguyên tử, an toàn luồng): 0,362 s   (3,62 ns/lần)
Arc chậm hơn Rc 1,5 lần

Mỗi Arc::clone tốn 3,62 ns so với 2,47 ns của Rc — chênh 1,5 lần, đến từ việc mỗi lần tăng/giảm đếm phải là một phép nguyên tử đồng bộ giữa các nhân CPU thay vì một lệnh cộng bình thường. Con số tuyệt đối nhỏ (vài nano giây), nhưng trên đường nóng clone hàng triệu lần thì tích lại thật. Bài học rút ra rõ ràng: dùng Rc khi chỉ ở một luồng, chỉ dùng Arc khi thật sự cần chia sẻ qua luồng — đừng mặc định Arc cho "chắc ăn".

Trình biên dịch ép bạn chọn đúng: E0277

Và điều đẹp nhất: bạn không thể vô tình dùng Rc sai. Thử gửi một Rc vào luồng khác:

let b = Rc::clone(&a);
thread::spawn(move || {
    println!("{}", b);   // dùng Rc trong luồng khác
});
error[E0277]: `Rc<i32>` cannot be sent between threads safely
  = help: the trait `Send` is not implemented for `Rc<i32>`

Lỗi E0277. Rc không cài đặc tính Send (đánh dấu "an toàn để chuyển qua luồng"), nên thread::spawn từ chối nhận nó ngay lúc biên dịch. Đây chính là cơ chế của bài sau — đồng thời không sợ hãi — hé lộ sớm: data race không bị bắt bằng kỷ luật hay chạy thử, mà bằng hệ thống kiểu. Bạn chọn Rc để lấy tốc độ ở đơn luồng, và nếu lỡ mang nó qua luồng, trình biên dịch chỉ thẳng rằng bạn cần Arc. Không có cửa sau, không có lỗi lúc chạy.

Ảnh chụp sơ đồ nền tối tổng hợp output thật đo con trỏ thông minh trong rust 1.99.1, khối một Box size Box i32 bằng 8 Box mảng u8 1024 bằng 8 dữ liệu nằm heap stack chỉ giữ con trỏ, khối hai Rc strong_count tạo a bằng 1 clone b bằng 2 clone c bằng 3 c hết scope bằng 2 drop b bằng 1 địa chỉ heap giống nhau 0xaaaab7218af0 bằng 0xaaaab7218af0 không sao chép dữ liệu chỉ cộng bộ đếm, khối ba benchmark 100 triệu lần clone cộng drop Rc đếm thường 0,247 giây 2,47 ns mỗi lần Arc đếm nguyên tử 0,362 giây 3,62 ns mỗi lần Arc chậm gấp 1,5 lần, khối bốn lỗi biên dịch E0277 Rc i32 cannot be sent between threads safely the trait Send is not implemented for Rc i32 dùng Rc khi một luồng Arc khi qua luồng

Hình 2: Bốn nhóm số đo thật — kích thước Box, bộ đếm Rc với cùng địa chỉ heap, chi phí clone Rc vs Arc, và lỗi E0277 chặn Rc qua luồng.

Gói lại

Ba con trỏ thông minh, ba mức chi phí, một nguyên tắc chọn: Box cho một chủ trên heap (gần như miễn phí, một con trỏ 8 byte); Rc cho nhiều chủ ở một luồng (clone chỉ +1 bộ đếm, 2,47 ns, cùng địa chỉ heap, không copy dữ liệu); Arc cho nhiều chủ qua luồng (như Rc nhưng đếm nguyên tử, 3,62 ns, đắt hơn 1,5 lần). Khác với iterator ở bài 4, đây là những trừu tượng có chi phí thật — nhưng chi phí ấy lộ ra tường minh trong kiểu bạn chọn, và trình biên dịch dùng chính hệ thống kiểu (Send, qua E0277) để ngăn bạn chọn sai. Bài sau rời con trỏ để sang một cấu trúc bạn chạm mỗi ngày: Vec, và câu chuyện nó lớn lên bằng cách cấp phát lại ra sao.

Phần sau