Ownership trong Rust: an toàn bộ nhớ mà không cần GC
1. Tổng quan
Ownership là cơ chế lõi giúp Rust đảm bảo an toàn bộ nhớ mà không cần garbage collector (GC) như Java hay Go, và không cần bạn tự malloc/free như C. Bài này giải thích ba quy tắc ownership, khái niệm borrowing, lifetime, và các lỗi compile-time thường gặp. Đọc xong, bạn sẽ hiểu vì sao Rust “khó tính” lúc biên dịch nhưng đổi lại không có dangling pointer, double free hay data race tại runtime.
2. Vấn đề: quản lý bộ nhớ không có GC
Các ngôn ngữ như Java dùng GC để dọn rác — tiện nhưng tốn CPU và gây pause không đoán trước được. C/C++ cho bạn toàn quyền nhưng dễ double free, use-after-free, memory leak. Rust chọn hướng thứ ba: compiler theo dõi ai “sở hữu” dữ liệu nào, và tự động giải phóng khi chủ sở hữu hết phạm vi (scope). Không runtime overhead, không cần GC thread.
fn main() {
let s = String::from("Non La Dev");
println!("{s}");
} // s ra khỏi scope tại đây, Rust tự động gọi drop(s) giải phóng heap memory
Dòng String::from cấp phát vùng nhớ trên heap. Khi biến s ra khỏi dấu }, Rust tự chèn lời gọi drop — tương tự finally { free(s) } nhưng do compiler sinh ra, không cần bạn viết tay.
3. Ba quy tắc Ownership
Rust có đúng ba quy tắc, học thuộc là nắm được 80% cơ chế:
- Mỗi giá trị trong Rust có một biến gọi là owner.
- Tại một thời điểm, chỉ có một owner duy nhất.
- Khi owner ra khỏi scope, giá trị bị drop (giải phóng).
3.1. Move semantics
fn main() {
let s1 = String::from("hello");
let s2 = s1; // ownership "move" từ s1 sang s2
println!("{s2}");
// println!("{s1}"); // lỗi compile: s1 đã bị move
}
Khác với Java (gán biến chỉ copy reference, cả hai cùng trỏ một object, GC lo phần còn lại), Rust coi let s2 = s1 là chuyển quyền sở hữu. Sau dòng đó, s1 không còn hợp lệ. Nếu bỏ comment dòng cuối, bạn sẽ nhận lỗi:
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:20
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{s1}");
| ^^^^ value borrowed here after move
Điều này ngăn chặn tận gốc lỗi double free: nếu cả s1 và s2 cùng giữ pointer tới cùng vùng heap, khi cả hai ra khỏi scope, Rust sẽ cố drop hai lần — gây crash. Compiler chặn ngay từ lúc biên dịch.
3.2. Clone khi muốn giữ cả hai
Nếu bạn thực sự cần hai bản độc lập, gọi .clone() tường minh:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // deep copy vùng heap
println!("{s1} - {s2}"); // cả hai đều hợp lệ
}
.clone() cấp phát một vùng heap mới và copy nội dung sang. Chi phí runtime rõ ràng, và Rust buộc bạn gọi tường minh để không ai vô tình clone một cấu trúc dữ liệu khổng lồ mà không hay biết — khác với nhiều ngôn ngữ copy ngầm.
3.3. Copy trait cho kiểu dữ liệu đơn giản
fn main() {
let x = 5;
let y = x; // copy, không phải move
println!("x = {x}, y = {y}"); // cả hai vẫn dùng được
}
Các kiểu nguyên thủy như i32, bool, char, hay tuple chứa toàn kiểu đó implement trait Copy. Vì chúng có kích thước cố định và nằm hoàn toàn trên stack, việc copy rẻ hơn move nên Rust tự động copy thay vì vô hiệu hóa biến gốc.
Lưu ý: một type không thể vừa implement
Copyvừa implementDrop. Nếu type có tài nguyên cần dọn dẹp (heap, file handle…), nó không được phépCopy.
4. Ownership khi truyền vào hàm
Truyền biến vào hàm cũng tuân theo move semantics y hệt gán biến:
fn print_order(order: String) {
println!("Order: {order}");
} // order bị drop ở đây, không phải ở caller
fn main() {
let order = String::from("ORDER-001");
print_order(order);
// println!("{order}"); // lỗi: order đã bị move vào hàm
}
Khi gọi print_order(order), ownership của order chuyển vào tham số của hàm. Hàm kết thúc, giá trị bị drop bên trong hàm — main không còn quyền dùng order nữa. Đây là lý do borrowing (mục tiếp theo) gần như luôn cần thiết khi bạn chỉ muốn “mượn đọc” dữ liệu mà không chuyển quyền sở hữu.
5. Borrowing: mượn mà không chiếm quyền sở hữu
5.1. Immutable reference (&T)
fn print_order(order: &String) {
println!("Order: {order}");
}
fn main() {
let order = String::from("ORDER-001");
print_order(&order); // mượn, không move
println!("Vẫn dùng được: {order}");
}
Dấu & tạo một reference — mượn dữ liệu mà không lấy ownership. Hàm print_order chỉ đọc, không sở hữu order. Sau khi hàm chạy xong, main vẫn toàn quyền dùng biến gốc.
5.2. Mutable reference (&mut T)
fn add_discount(total: &mut f64) {
*total -= 10.0;
}
fn main() {
let mut total = 100.0;
add_discount(&mut total);
println!("Total: {total}"); // Total: 90
}
&mut cho phép mượn để sửa đổi. Để gọi được, biến gốc phải khai báo mut. Dấu * dùng để dereference và gán giá trị mới vào vùng nhớ mà reference trỏ tới.
5.3. Quy tắc borrowing
Rust áp hai luật mượn tại compile-time, gọi chung là borrow checker:
- Tại một thời điểm, chỉ được có một mutable reference, HOẶC nhiều immutable reference — không được trộn lẫn.
- Reference không bao giờ được sống lâu hơn dữ liệu gốc (tránh dangling reference).
fn main() {
let mut total = 100.0;
let r1 = &total;
let r2 = &total; // OK: nhiều immutable reference cùng lúc
println!("{r1} {r2}");
let r3 = &mut total; // lỗi nếu r1, r2 vẫn còn được dùng sau điểm này
println!("{r3}");
}
Nếu r1, r2 không còn được dùng sau dòng println! đầu, compiler (nhờ Non-Lexical Lifetimes) cho phép r3 mượn mutable bên dưới. Nhưng nếu bạn cố dùng r1 sau khi r3 xuất hiện, sẽ gặp:
error[E0502]: cannot borrow `total` as mutable because it is also borrowed as immutable
Quy tắc này triệt tiêu data race ngay tại compile-time: không thể vừa có ai đó đọc vừa có ai đó ghi cùng lúc lên cùng một vùng nhớ.
6. Lifetime: đảm bảo reference không trỏ vào “hư không”
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let order1 = String::from("ORDER-001");
let order2 = String::from("ORDER-002-LONG");
let result = longest(order1.as_str(), order2.as_str());
println!("Dài hơn: {result}");
}
Ký hiệu 'a là lifetime annotation, nói với compiler: “reference trả về sống không lâu hơn cả x lẫn y”. Rust không tự đoán được quan hệ sống-chết giữa nhiều reference trong trường hợp này, nên bạn phải khai báo tường minh. Phần lớn thời gian Rust tự suy luận lifetime (lifetime elision) nên bạn ít khi phải viết 'a thủ công — chỉ cần khi hàm nhận nhiều reference và trả về một trong số đó.
error[E0106]: missing lifetime specifier
--> src/main.rs:1:33
|
1 | fn longest(x: &str, y: &str) -> &str {
| ^ expected named lifetime parameter
Nếu thiếu 'a, compiler báo lỗi trên vì không biết reference trả về gắn với x hay y.
7. Khi cần nhiều owner: Rc và RefCell
Ownership đơn-chủ-sở-hữu đôi khi quá cứng nhắc, ví dụ khi xây cấu trúc dữ liệu dạng cây hay đồ thị cần nhiều node cùng tham chiếu một giá trị.
<!-- Không cần dependency, Rc/RefCell nằm trong std -->
use std::cell::RefCell;
use std::rc::Rc;
struct Customer {
name: String,
balance: RefCell<f64>,
}
fn main() {
let customer = Rc::new(Customer {
name: String::from("Nón Lá"),
balance: RefCell::new(0.0),
});
let c1 = Rc::clone(&customer);
let c2 = Rc::clone(&customer);
*c1.balance.borrow_mut() += 50.0;
*c2.balance.borrow_mut() += 30.0;
println!("{} balance = {}", customer.name, customer.balance.borrow());
println!("Số reference: {}", Rc::strong_count(&customer));
}
Rc<T> (Reference Counted) cho phép nhiều owner cùng trỏ một dữ liệu bất biến, đếm số reference tại runtime, tự drop khi count về 0. RefCell<T> cho phép mutable borrow tại runtime thay vì compile-time (interior mutability) — đổi lại, nếu bạn vi phạm quy tắc borrowing (ví dụ hai lần borrow_mut() cùng lúc), chương trình sẽ panic thay vì báo lỗi compile.
Nón Lá balance = 80
Số reference: 3
Lưu ý:
Rc/RefCellchỉ dùng cho single-thread. Nếu cần chia sẻ dữ liệu giữa nhiều thread, dùngArc(atomic reference count) kết hợpMutexhoặcRwLock. Đừng dùngRc<RefCell<T>>tràn lan — nó làm mất đi lợi thế “borrow checker bắt lỗi lúc compile” vốn là điểm mạnh nhất của Rust.
8. Viết test kiểm chứng hành vi ownership
#[derive(Clone)]
struct Order {
id: String,
amount: f64,
}
fn apply_discount(order: &mut Order, percent: f64) {
order.amount -= order.amount * percent / 100.0;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_keep_original_order_untouched_when_cloned() {
let original = Order { id: "ORD-1".into(), amount: 100.0 };
let mut copy = original.clone();
apply_discount(&mut copy, 10.0);
assert_eq!(original.amount, 100.0); // bản gốc không đổi
assert_eq!(copy.amount, 90.0);
}
#[test]
fn should_mutate_through_mutable_borrow() {
let mut order = Order { id: "ORD-2".into(), amount: 200.0 };
apply_discount(&mut order, 25.0);
assert_eq!(order.amount, 150.0);
}
}
Chạy cargo test, kết quả:
running 2 tests
test tests::should_keep_original_order_untouched_when_cloned ... ok
test tests::should_mutate_through_mutable_borrow ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured
Test đầu tiên chứng minh .clone() tạo bản độc lập — sửa copy không ảnh hưởng original. Test thứ hai xác nhận &mut thực sự thay đổi dữ liệu gốc thông qua reference.
9. Lỗi thường gặp
- Use after move: cố dùng biến sau khi đã move vào chỗ khác (hàm, biến mới, struct). Khắc phục bằng
.clone()nếu cần hai bản, hoặc đổi sang truyền reference (&T). - Cannot borrow as mutable more than once: đang giữ một
&mutnhưng lại tạo thêm&hoặc&mutkhác cùng lúc. Thu hẹp scope của reference cũ (dùng block{}hoặc để nó hết “sống” sớm hơn) để giải quyết. - Dangling reference: trả về reference trỏ tới biến local đã bị drop khi hàm kết thúc. Rust chặn hoàn toàn lỗi này tại compile-time — bạn sẽ thấy lỗi
missing lifetime specifierhoặcborrowed value does not live long enough. - Lạm dụng
.clone()để né borrow checker: hợp lệ về mặt compile nhưng tốn hiệu năng không cần thiết. Hãy coi mỗi.clone()là một câu hỏi: “mình có thực sự cần bản sao độc lập không, hay chỉ đang né việc hiểu ownership?”
10. Khi nào ownership nghiêm ngặt là không cần thiết
Nếu bạn viết script ngắn, prototype nhanh, hoặc dữ liệu luôn nhỏ và không có yêu cầu hiệu năng/an toàn cao, chi phí học và tuân thủ borrow checker có thể không đáng — ngôn ngữ như Python hay Go sẽ cho bạn tốc độ phát triển nhanh hơn. Ownership tỏa sáng nhất ở hệ thống cần hiệu năng cao và an toàn bộ nhớ nghiêm ngặt: hệ điều hành, embedded, game engine, hoặc service backend đòi hỏi độ trễ thấp mà không chấp nhận GC pause.
11. Kết luận
Ownership là nền tảng giúp Rust đạt được điều tưởng chừng mâu thuẫn: an toàn bộ nhớ như ngôn ngữ có GC, nhưng hiệu năng ngang C/C++. Ba quy tắc owner-move-drop, cơ chế borrowing với luật một-mutable-hoặc-nhiều-immutable, và lifetime annotation phối hợp với nhau để compiler bắt lỗi memory-unsafe ngay lúc biên dịch thay vì để crash lúc chạy. Ban đầu borrow checker có thể khiến bạn “cãi nhau” với compiler khá nhiều, nhưng một khi đã quen, bạn sẽ viết code ít bug hơn hẳn mà không cần runtime nào canh gác phía sau.