Smart pointer trong Rust: Box, Rc và Arc
1. Tổng quan
Theo mặc định, mỗi giá trị trong Rust chỉ có một owner, và Rust phải biết kích thước của giá trị khi biên dịch. Nhiều tình huống thực tế không thỏa hai điều kiện này: kiểu dữ liệu đệ quy, danh sách chứa nhiều kiểu khác nhau, hoặc một dữ liệu cần nhiều owner. Bài này trình bày ba smart pointer phổ biến nhất là Box<T>, Rc<T> và Arc<T>. Đọc xong, bạn sẽ biết chọn loại nào cho từng trường hợp, cách kết hợp chúng với Weak, RefCell, Mutex, và những lỗi hay gặp.
2. Smart pointer là gì?
Smart pointer là một struct bọc một con trỏ và cài đặt hai trait quan trọng:
Deref: cho phép bạn dùng smart pointer như một tham chiếu thông thường (*ptr, gọi method trực tiếp).Drop: tự giải phóng tài nguyên khi smart pointer ra khỏi scope.
String và Vec<T> thực ra cũng là smart pointer. Box, Rc và Arc khác ở chỗ chúng tập trung vào cách sở hữu dữ liệu trên heap. Cả ba đều nằm trong thư viện chuẩn. Bạn không cần thêm dependency nào vào Cargo.toml.
3. Box<T>: một owner, dữ liệu trên heap
3.1. Cấp phát cơ bản
fn main() {
let order_total = Box::new(250_000u64);
println!("Tổng: {}", order_total);
let doubled = *order_total * 2;
println!("Gấp đôi: {}", doubled);
}
Box::new cấp phát giá trị 250_000 trên heap. Trên stack chỉ còn con trỏ, có kích thước cố định. println! dùng được ngay vì Box cài đặt Display khi T cài đặt Display. Toán tử * lấy giá trị bên trong nhờ Deref. Khi order_total ra khỏi main, vùng nhớ heap tự được giải phóng.
Tổng: 250000
Gấp đôi: 500000
Bọc một u64 trong Box như ví dụ trên không mang lại lợi ích gì. Đoạn code này chỉ minh họa cú pháp. Box thực sự hữu ích ở hai trường hợp dưới đây.
3.2. Kiểu dữ liệu đệ quy
Giả sử bạn mô tả quy trình xử lý đơn hàng dưới dạng chuỗi các bước:
#[derive(Debug)]
enum OrderStep {
Done,
Next(String, Box<OrderStep>),
}
use OrderStep::{Done, Next};
fn count(step: &OrderStep) -> usize {
match step {
Done => 0,
Next(_, rest) => 1 + count(rest),
}
}
fn main() {
let flow = Next(
"Đặt hàng".into(),
Box::new(Next(
"Thanh toán".into(),
Box::new(Next("Giao hàng".into(), Box::new(Done))),
)),
);
println!("Số bước: {}", count(&flow));
}
Nếu bạn viết Next(String, OrderStep) mà không có Box, trình biên dịch sẽ báo lỗi:
error[E0072]: recursive type `OrderStep` has infinite size
Lý do là một OrderStep chứa một OrderStep khác, cái đó lại chứa tiếp một cái nữa, nên kích thước tính ra là vô hạn. Box<OrderStep> luôn có kích thước bằng một con trỏ, nên kích thước của enum trở thành hữu hạn. Trong hàm count, rest có kiểu &Box<OrderStep>. Rust tự chuyển nó thành &OrderStep nhờ deref coercion.
Số bước: 3
3.3. Trait object
Box<dyn Trait> cho phép bạn lưu nhiều kiểu khác nhau trong cùng một collection:
trait PaymentMethod {
fn pay(&self, amount: u64) -> String;
}
struct Cash;
struct Card {
last4: String,
}
impl PaymentMethod for Cash {
fn pay(&self, amount: u64) -> String {
format!("Tiền mặt: {amount}")
}
}
impl PaymentMethod for Card {
fn pay(&self, amount: u64) -> String {
format!("Thẻ ****{}: {amount}", self.last4)
}
}
fn main() {
let methods: Vec<Box<dyn PaymentMethod>> = vec![
Box::new(Cash),
Box::new(Card { last4: "4242".into() }),
];
for m in &methods {
println!("{}", m.pay(100_000));
}
}
Cash và Card có kích thước khác nhau, nên không thể đặt trực tiếp vào cùng một Vec. Khi bọc trong Box<dyn PaymentMethod>, mọi phần tử đều có cùng kích thước (một fat pointer: con trỏ dữ liệu cộng con trỏ vtable). Lời gọi pay được phân giải lúc runtime.
Tiền mặt: 100000
Thẻ ****4242: 100000
Lưu ý: Nếu tất cả phần tử có cùng kiểu, hãy dùng generic (
impl PaymentMethodhoặc<T: PaymentMethod>) thay vìBox<dyn …>. Generic dùng static dispatch, không tốn cấp phát heap và không cần gọi qua vtable.
4. Rc<T>: nhiều owner trong một thread
4.1. Đếm tham chiếu
Giả sử nhiều Order cùng thuộc một Customer. Bạn không muốn copy thông tin khách hàng vào từng đơn, và cũng không xác định được đơn nào sẽ “sống” lâu nhất để làm owner. Đây là lúc dùng Rc (Reference Counted):
use std::rc::Rc;
struct Customer {
name: String,
}
struct Order {
id: u32,
customer: Rc<Customer>,
}
fn main() {
let alice = Rc::new(Customer { name: "Alice".into() });
println!("Sau khi tạo: {}", Rc::strong_count(&alice));
let o1 = Order { id: 1, customer: Rc::clone(&alice) };
{
let o2 = Order { id: 2, customer: Rc::clone(&alice) };
println!("Trong block: {}", Rc::strong_count(&alice));
println!("Đơn {} của {}", o2.id, o2.customer.name);
}
println!("Sau block: {}", Rc::strong_count(&alice));
println!("Đơn {} của {}", o1.id, o1.customer.name);
}
Rc::clone không copy Customer. Nó chỉ tăng bộ đếm strong count lên 1. Khi o2 bị drop ở cuối block, bộ đếm giảm đi 1. Customer chỉ bị giải phóng khi bộ đếm về 0.
Sau khi tạo: 1
Trong block: 3
Đơn 2 của Alice
Sau block: 2
Đơn 1 của Alice
Theo quy ước, nên viết Rc::clone(&alice) thay vì alice.clone(). Cách viết này cho người đọc thấy đây là thao tác tăng bộ đếm rẻ tiền, không phải deep copy.
4.2. Thay đổi dữ liệu với Rc<RefCell<T>>
Rc<T> chỉ cho bạn tham chiếu bất biến (&T). Nếu nhiều owner cần sửa dữ liệu, hãy kết hợp với RefCell:
use std::cell::RefCell;
use std::rc::Rc;
struct Cart {
items: Vec<String>,
}
fn main() {
let cart = Rc::new(RefCell::new(Cart { items: vec![] }));
let web = Rc::clone(&cart);
let mobile = Rc::clone(&cart);
web.borrow_mut().items.push("Áo".into());
mobile.borrow_mut().items.push("Nón lá".into());
println!("{:?}", cart.borrow().items);
}
RefCell chuyển việc kiểm tra quy tắc borrow từ lúc biên dịch sang lúc runtime. borrow_mut() trả về một guard. Guard này hết hạn ngay cuối câu lệnh, nên lời gọi tiếp theo vẫn hợp lệ.
["Áo", "Nón lá"]
Lưu ý: Nếu bạn gọi
borrow_mut()trong khi mộtborrow()hoặcborrow_mut()khác vẫn còn sống, chương trình sẽ panic vớialready borrowed: BorrowMutError. Hãy giữ guard trong thời gian ngắn nhất có thể. Dùngtry_borrow_mut()nếu bạn muốn xử lý lỗi thay vì panic.
4.3. Vòng tham chiếu và Weak<T>
Nếu Customer giữ danh sách Rc<Order>, và mỗi Order lại giữ Rc<Customer>, hai bộ đếm sẽ không bao giờ về 0. Kết quả là rò rỉ bộ nhớ. Rust không ngăn được lỗi này lúc biên dịch. Cách giải quyết là dùng Weak cho một chiều tham chiếu:
use std::cell::RefCell;
use std::rc::{Rc, Weak};
struct Customer {
name: String,
orders: RefCell<Vec<Rc<Order>>>,
}
struct Order {
id: u32,
customer: Weak<Customer>,
}
fn main() {
let bob = Rc::new(Customer {
name: "Bob".into(),
orders: RefCell::new(vec![]),
});
let order = Rc::new(Order { id: 7, customer: Rc::downgrade(&bob) });
bob.orders.borrow_mut().push(Rc::clone(&order));
if let Some(c) = order.customer.upgrade() {
println!("Đơn {} thuộc {}", order.id, c.name);
}
println!("strong={}, weak={}", Rc::strong_count(&bob), Rc::weak_count(&bob));
drop(bob);
println!("Còn khách? {}", order.customer.upgrade().is_some());
}
Rc::downgrade tạo một Weak. Weak chỉ tăng weak count và không giữ cho dữ liệu sống. Khi cần dùng, bạn gọi upgrade() để nhận về Option<Rc<T>>. Kết quả là None nếu dữ liệu đã bị giải phóng. Sau drop(bob), strong count của Customer về 0 nên Customer bị giải phóng, dù Weak trong order vẫn còn đó.
Đơn 7 thuộc Bob
strong=1, weak=1
Còn khách? false
Quy tắc chung: chiều “cha → con” dùng Rc, chiều “con → cha” dùng Weak.
5. Arc<T>: nhiều owner giữa nhiều thread
5.1. Vì sao Rc không dùng được với thread
Rc cập nhật bộ đếm bằng phép cộng thường, không an toàn khi nhiều thread cùng thao tác. Vì thế Rc không cài đặt trait Send. Nếu bạn truyền Rc vào thread::spawn, trình biên dịch sẽ từ chối:
error[E0277]: `Rc<Customer>` cannot be sent between threads safely
Đây là một điểm mạnh của Rust: lỗi data race bị chặn ngay lúc biên dịch. Arc (Atomically Reference Counted) có API giống hệt Rc, nhưng dùng phép toán atomic để cập nhật bộ đếm.
5.2. Chia sẻ dữ liệu chỉ đọc
use std::sync::Arc;
use std::thread;
struct ShopConfig {
currency: String,
vat_percent: u64,
}
fn main() {
let config = Arc::new(ShopConfig { currency: "VND".into(), vat_percent: 10 });
let handles: Vec<_> = [100_000u64, 200_000]
.into_iter()
.map(|price| {
let config = Arc::clone(&config);
thread::spawn(move || {
let vat = price * config.vat_percent / 100;
println!("{} + VAT = {} {}", price, price + vat, config.currency);
})
})
.collect();
for h in handles {
h.join().unwrap();
}
}
Mỗi thread nhận một bản Arc riêng thông qua move. Mọi bản đều trỏ tới cùng một ShopConfig. Dữ liệu chỉ được đọc nên không cần khóa. Thứ tự hai dòng output có thể đảo nhau giữa các lần chạy.
100000 + VAT = 110000 VND
200000 + VAT = 220000 VND
5.3. Thay đổi dữ liệu với Arc<Mutex<T>>
RefCell không an toàn giữa các thread. Phiên bản tương ứng cho đa thread là Mutex (hoặc RwLock):
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let revenue = Arc::new(Mutex::new(0u64));
let mut handles = Vec::new();
for amount in [100u64, 250, 400] {
let revenue = Arc::clone(&revenue);
handles.push(thread::spawn(move || {
*revenue.lock().unwrap() += amount;
}));
}
for h in handles {
h.join().unwrap();
}
println!("Doanh thu: {}", *revenue.lock().unwrap());
}
lock() trả về một MutexGuard. Khóa tự được nhả khi guard bị drop, ở đây là ngay cuối câu lệnh. unwrap() xử lý trường hợp mutex bị poisoned, tức một thread khác đã panic trong lúc giữ khóa.
Doanh thu: 750
Lưu ý: Với một bộ đếm số nguyên đơn giản,
Arc<AtomicU64>nhanh hơnArc<Mutex<u64>>. Ngoài ra, không nên dùngArcở những nơi chỉ có một thread. Phép toán atomic tốn chi phí hơn phép cộng thường, dù chênh lệch không lớn.
6. Viết test cho hành vi đếm tham chiếu
Rust có sẵn framework test, không cần thêm crate nào. Đặt các test sau vào cuối src/lib.rs hoặc src/main.rs:
#[cfg(test)]
mod tests {
use std::rc::{Rc, Weak};
use std::sync::{Arc, Mutex};
use std::thread;
struct Customer {
name: String,
}
#[test]
fn rc_clone_increases_strong_count_and_drop_decreases_it() {
let alice = Rc::new(Customer { name: "Alice".into() });
let copy = Rc::clone(&alice);
assert_eq!(Rc::strong_count(&alice), 2);
assert!(Rc::ptr_eq(&alice, ©));
drop(copy);
assert_eq!(Rc::strong_count(&alice), 1);
assert_eq!(alice.name, "Alice");
}
#[test]
fn weak_upgrade_returns_none_after_owner_dropped() {
let bob = Rc::new(Customer { name: "Bob".into() });
let weak: Weak<Customer> = Rc::downgrade(&bob);
assert!(weak.upgrade().is_some());
drop(bob);
assert!(weak.upgrade().is_none());
}
#[test]
fn arc_mutex_sums_correctly_across_threads() {
let revenue = Arc::new(Mutex::new(0u64));
let handles: Vec<_> = (1..=100)
.map(|i| {
let revenue = Arc::clone(&revenue);
thread::spawn(move || *revenue.lock().unwrap() += i)
})
.collect();
handles.into_iter().for_each(|h| h.join().unwrap());
assert_eq!(*revenue.lock().unwrap(), 5050);
assert_eq!(Arc::strong_count(&revenue), 1);
}
}
Test thứ nhất kiểm tra bộ đếm, và dùng Rc::ptr_eq để xác nhận hai Rc trỏ cùng một vùng nhớ, tức không có deep copy. Test thứ hai xác nhận Weak không giữ dữ liệu sống. Test thứ ba chạy 100 thread cộng dồn, kết quả phải đúng 5050. Sau khi mọi thread đã join, chỉ còn một strong reference.
Chạy test:
cargo test
running 3 tests
test tests::rc_clone_increases_strong_count_and_drop_decreases_it ... ok
test tests::weak_upgrade_returns_none_after_owner_dropped ... ok
test tests::arc_mutex_sums_correctly_across_threads ... ok
test result: ok. 3 passed; 0 failed
7. Chọn loại nào?
| Nhu cầu | Lựa chọn |
|---|---|
| Một owner, cần heap (kiểu đệ quy, trait object, dữ liệu lớn cần move rẻ) | Box<T> |
| Nhiều owner, một thread, chỉ đọc | Rc<T> |
| Nhiều owner, một thread, cần sửa | Rc<RefCell<T>> |
| Nhiều owner, nhiều thread, chỉ đọc | Arc<T> |
| Nhiều owner, nhiều thread, cần sửa | Arc<Mutex<T>> / Arc<RwLock<T>> |
| Tham chiếu ngược, cache, tránh vòng | Weak<T> |
Một số lỗi hay gặp:
- Dùng smart pointer khi chỉ cần borrow. Nếu hàm chỉ đọc dữ liệu, hãy nhận
&Customerthay vìRc<Customer>. Như vậy hàm linh hoạt hơn và không phải tăng hay giảm bộ đếm. - Lạm dụng
Rc<RefCell<T>>. Code dày đặc pattern này thường là dấu hiệu bạn đang mang tư duy OOP từ Java hay C# sang. Hãy cân nhắc tổ chức lại ownership, hoặc dùng index trongVec(arena pattern). - Vòng tham chiếu.
RcvàArcđều có thể rò rỉ bộ nhớ khi hai đối tượng trỏ qua lại bằng strong reference. Hãy dùngWeakcho một chiều. - Giữ
MutexGuardquá lâu. Đặc biệt nguy hiểm khi giữ guard qua.awaittrong code async, hoặc khi khóa nhiều mutex theo thứ tự khác nhau ở các thread khác nhau, dẫn tới deadlock.
8. Kết luận
Box<T> dùng khi bạn cần đặt dữ liệu lên heap với một owner duy nhất, điển hình là kiểu đệ quy và trait object. Rc<T> cho phép nhiều owner trong một thread, còn Arc<T> làm điều tương tự giữa nhiều thread nhờ bộ đếm atomic. Bản thân các smart pointer này chỉ cho phép đọc. Khi cần thay đổi dữ liệu, bạn kết hợp với RefCell (một thread) hoặc Mutex/RwLock (nhiều thread), và dùng Weak để tránh vòng tham chiếu. Hãy bắt đầu với borrow thông thường, và chỉ chuyển sang smart pointer khi quy tắc ownership mặc định thực sự không đáp ứng được.