Mục lục
Mục Tiêu Bài Học
Sau bài học, bạn sẽ:
- Hiểu chính xác "memory safe" của Rust gồm những gì (ngăn UAF, double-free, data race, dangling pointer) và không gồm những gì (không bảo đảm "không leak").
- Nhận diện được trường hợp leak phổ biến nhất trong code Rust an toàn: vòng
Rc— hai (hoặc nhiều)Rctrỏ vào nhau bằng strong reference khiếnstrong_countkhông bao giờ về 0. - Viết được code minh hoạ leak: in
Rc::strong_counttrước/sau khi tạo cycle để thấy count không giảm khi binding ra khỏi scope. - Áp dụng được pattern fix: một chiều dùng strong
Rc(giữ ownership), chiều ngược lại dùngWeak(không tăng strong count) — ôn lại bài 218. - Hiểu hai API "leak có chủ ý":
Box::leak(b)chuyểnBox<T>thành&'static Tdùng cho global config;mem::forget(value)bỏ quaDropdùng khi FFI hand-off resource cho C. - Biết các tool phổ biến để phát hiện leak trong code Rust: Valgrind, heaptrack, cargo-leakcheck, và LeakSanitizer (
-Zsanitizer=leaktrên nightly).
Bài này tổng kết Nhóm 27 — Smart Pointers. Tiếp theo sang Nhóm 28 — Concurrency, Threads.
Rust Safe ≠ No Leak
"Rust safe" là một tập hợp guarantee được định nghĩa hẹp. Cụ thể, safe Rust loại trừ các hành vi sau ở compile time hoặc runtime panic:
- Use-after-free (UAF): truy cập memory đã giải phóng.
- Double-free: giải phóng cùng allocation hai lần.
- Data race: hai thread cùng mutate một biến không sync.
- Dangling pointer: pointer trỏ tới vùng nhớ không còn hợp lệ.
- Out-of-bounds index: panic thay vì silent corrupt.
Bạn sẽ thấy memory leak không nằm trong danh sách này. Lý do triết lý: leak là không gọi destructor — nó không làm hỏng dữ liệu nào đang sống, không cho phép đọc bytes lung tung, không tạo behavior khó dự đoán. Leak chỉ là logic error: chương trình tiêu thêm RAM mà không dùng tới. Tệ nhưng vẫn an toàn theo định nghĩa hẹp của Rust.
Điều này có hệ quả thực tế: bạn có thể viết code safe Rust thuần (không có unsafe nào) mà vẫn leak vô tận memory cho đến khi OS kill process. Compiler không cảnh báo. cargo clippy không cảnh báo (trừ vài trường hợp đặc biệt). Phát hiện leak hoàn toàn là trách nhiệm của bạn — qua review, qua tool, hoặc qua việc thấy RSS tăng đều khi process chạy lâu.
Rust Book tài liệu hoá rõ điều này ở chương Reference Cycles Can Leak Memory. Bạn không "vi phạm safety" — bạn chỉ vô tình giữ data sống lâu hơn cần thiết.
Vòng Rc — Trường Hợp Leak Phổ Biến
Cơ chế Rc<T> dựa trên strong_count: khi count về 0, data được drop. Nếu bạn tạo cấu trúc trong đó hai (hoặc nhiều) Rc trỏ vào nhau bằng strong reference, count không bao giờ về 0 — kể cả khi không còn binding ngoài cycle đó.
Ví dụ trực quan: parent giữ Rc<Child>, đồng thời child giữ Rc<Parent> (cả hai strong). Khi bạn drop binding ngoài cùng:
- Binding
parentdrop → countParentgiảm 1, nhưngChildbên trong vẫn còn giữ mộtRc<Parent>→ countParentvẫn ≥ 1. - Count
Parentkhông về 0 →Parentkhông drop →Rc<Child>bên trong nó không drop → countChildkhông giảm →Childkhông drop. - Kết quả: cả hai allocation kẹt lại cho đến khi process kết thúc.
Đây là leak hợp lệ trong định nghĩa safety của Rust — không có UAF, không có double-free. Chỉ là memory không bao giờ được giải phóng. Pattern này xuất hiện trong: doubly-linked list (mỗi node giữ prev và next), graph có chu trình, parent-child tree khi child cần biết parent.
Demo Code Vòng Rc
Ví dụ đơn giản: hai Node trỏ vào nhau qua RefCell<Option<Rc<Node>>> để có thể "nối" sau khi cả hai đã tồn tại.
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Debug)]
struct Node {
value: i32,
other: RefCell<Option<Rc<Node>>>,
}
impl Drop for Node {
fn drop(&mut self) {
println!("Drop Node value = {}", self.value);
}
}
fn main() {
let a = Rc::new(Node { value: 1, other: RefCell::new(None) });
let b = Rc::new(Node { value: 2, other: RefCell::new(None) });
// a -> b
*a.other.borrow_mut() = Some(Rc::clone(&b));
// b -> a (tạo cycle)
*b.other.borrow_mut() = Some(Rc::clone(&a));
println!("strong_count(a) = {}", Rc::strong_count(&a)); // 2
println!("strong_count(b) = {}", Rc::strong_count(&b)); // 2
// Kết thúc main: a, b drop binding → count giảm 1 mỗi cái → còn lại 1
// Không cái nào về 0 → Drop KHÔNG chạy → leak
}
Chạy chương trình bạn sẽ thấy: hai dòng strong_count = 2 được in, nhưng không có dòng Drop Node value = ... nào — chứng tỏ destructor không chạy. Hai allocation kẹt cho đến khi process exit. Compiler không cảnh báo; cargo run báo success.
Quan sát: lúc main kết thúc, binding a drop → count a còn lại 1 (vì b.other vẫn giữ một Rc trỏ vào a). Tương tự cho b. Mãi mãi mắc kẹt ở 1 — không bao giờ về 0.
Fix Bằng Weak
Cách fix kinh điển: chỉ một chiều giữ strong (ownership thực sự), chiều ngược lại dùng Weak<T> (không tăng strong_count). Xem lại Bài 218 về Weak để nhớ chi tiết. Ý chính: Weak là "non-owning reference"; muốn truy cập data phải gọi upgrade() trả về Option<Rc<T>> — None nếu data đã drop, Some nếu còn sống.
use std::cell::RefCell;
use std::rc::{Rc, Weak};
#[derive(Debug)]
struct Node {
value: i32,
// Cha giữ con bằng strong (ownership thực sự)
children: RefCell<Vec<Rc<Node>>>,
// Con tham chiếu cha bằng Weak (không tăng strong_count)
parent: RefCell<Weak<Node>>,
}
impl Drop for Node {
fn drop(&mut self) {
println!("Drop Node value = {}", self.value);
}
}
fn main() {
let parent = Rc::new(Node {
value: 10,
children: RefCell::new(vec![]),
parent: RefCell::new(Weak::new()),
});
let child = Rc::new(Node {
value: 20,
children: RefCell::new(vec![]),
parent: RefCell::new(Rc::downgrade(&parent)), // weak ref tới parent
});
parent.children.borrow_mut().push(Rc::clone(&child));
println!("strong(parent) = {}, weak(parent) = {}",
Rc::strong_count(&parent), Rc::weak_count(&parent));
// strong = 1 (chỉ binding `parent` giữ), weak = 1 (con trỏ ngược)
}
Khi main kết thúc: child drop → strong của child còn 1 (parent vẫn giữ); parent drop → strong của parent về 0 → Drop chạy → Vec<Rc<Node>> bên trong drop → strong của child về 0 → Drop của child chạy. Bạn sẽ thấy hai dòng "Drop Node value = ..." in ra — không leak.
Quy tắc thiết kế: trong cấu trúc cây hoặc graph, xác định ai sở hữu ai. Chiều "owner → owned" dùng Rc, chiều ngược lại (back-pointer, sibling-pointer) dùng Weak.
Box::leak() Intentional Leak
Không phải leak nào cũng là bug. Box::leak là API chính thức để "leak có chủ đích": chuyển Box<T> thành &'static mut T (hoặc &'static T sau khi reborrow), sống trọn đời chương trình.
fn load_config() -> &'static str {
let b: Box<String> = Box::new(std::fs::read_to_string("config.toml").unwrap());
// Chuyển Box thành &'static — leak có chủ đích
Box::leak(b).as_str()
}
fn main() {
let cfg: &'static str = load_config();
println!("config = {}", cfg);
// cfg sống đến khi process exit — đúng ý đồ
}
Use case điển hình: global config / lookup table được load runtime (vì giá trị tới từ file, env, hoặc network — không biết lúc compile), nhưng bạn muốn các phần khác của chương trình tham chiếu nó qua &'static để tránh phải truyền tham số khắp nơi. Vì config sống trọn đời process, leak vài KB là hành vi đúng — không phải bug. Một số framework (như once_cell, OnceLock, tracing-subscriber) dùng pattern này nội bộ.
Lưu ý: Box::leak chỉ nên dùng cho giá trị bạn chắc chắn muốn sống đến process exit. Gọi lặp đi lặp lại trong loop sẽ leak thật sự (mỗi lần thêm bytes vào heap không bao giờ giải phóng).
mem::forget() Skip Drop
std::mem::forget(value) nhận một value, "quên" nó đi: binding đi vào hàm, hàm trả về (), và Drop không chạy. Nếu value sở hữu resource trên heap (vd Vec, String, Box), resource đó kẹt lại — leak.
Vì sao Rust lại cấp API "cố tình bỏ Drop"? Có hai use case thực sự:
- FFI handoff: bạn alloc bằng Rust, hand pointer cho C, và C sẽ tự gọi
freesau đó. Nếu Rust drop ở phía Rust thì sẽ double-free.mem::forgetđảm bảo Rust không động vào nữa. - Bypass
Droptrong một số code unsafe nội bộ, ví dụstddùng để implement các pattern như "move out of dropped struct".
// Ví dụ FFI handoff: alloc Vec ở Rust, gửi pointer + len cho C, C tự free.
unsafe extern "C" {
fn c_consume_buffer(ptr: *mut u8, len: usize);
}
fn hand_off_to_c() {
let buf: Vec<u8> = vec![1, 2, 3, 4];
let ptr = buf.as_ptr() as *mut u8;
let len = buf.len();
unsafe { c_consume_buffer(ptr, len); }
// C đã nhận quyền sở hữu — Rust KHÔNG được drop nữa,
// nếu drop sẽ double-free khi C gọi free.
std::mem::forget(buf);
}
Cảnh báo: mem::forget phá vỡ kỳ vọng RAII của Rust. Nếu value đại diện một resource (file handle, lock guard, socket), forget nó sẽ rò rỉ resource đó — không chỉ memory, mà cả file descriptor / kernel lock. Chỉ dùng khi bạn chắc chắn có code khác giải phóng thay. Trong code Rust thuần (không FFI), gần như không có lý do hợp lệ để gọi mem::forget.
Tool Detect Leak
Vì compiler không bắt leak, bạn cần tool ngoài. Các lựa chọn phổ biến trong hệ sinh thái Rust:
- Valgrind (memcheck) — Linux/macOS, kinh điển cho C/C++; chạy được binary Rust nhưng output đôi khi noisy do allocator của Rust khác C. Lệnh:
valgrind --leak-check=full ./target/debug/myapp. - heaptrack — Linux, ghi trace alloc/dealloc, view bằng
heaptrack_gui, dễ tìm callsite gây leak; nhẹ hơn Valgrind nhiều. - cargo-leakcheck — wrap quanh Valgrind/Sanitizer, chạy
cargo testvới detect leak; tiện cho test runner. - LeakSanitizer (LSan) — phần của LLVM, tích hợp Rust qua flag
-Zsanitizer=leakchỉ trên nightly. Nhanh hơn Valgrind nhiều, output gọn, in stack trace tới allocation bị leak.
# Yêu cầu Rust nightly + target host
rustup install nightly
rustup component add rust-src --toolchain nightly
# Chạy test với LeakSanitizer
RUSTFLAGS="-Zsanitizer=leak" cargo +nightly test \
--target x86_64-unknown-linux-gnu \
-Zbuild-std --release
# Hoặc chạy binary cụ thể
RUSTFLAGS="-Zsanitizer=leak" cargo +nightly run \
--target x86_64-unknown-linux-gnu \
-Zbuild-std
LSan in báo cáo dạng: "Direct leak of 32 byte(s) in 1 object(s) allocated from: ... Rc::new at src/main.rs:15" — đủ để truy ngược về đoạn code tạo cycle. Lưu ý LSan có một số false positive với Box::leak intentional — cần whitelist (qua LSAN_OPTIONS hoặc suppression file). Với production code, kết hợp LSan trong CI test stage là cách tốt nhất để bắt regression sớm.
Tổng Kết
- Nhóm 27 đi qua:
Box<T>(heap, 1 owner),Derefcoercion,Droptrait,Rc<T>(shared single-thread),RefCell<T>(interior mutability),Rc<RefCell<T>>pattern,Weak<T>(phá cycle),Cow<'_, T>(clone-on-write), decision tree chọn smart pointer, và bài hôm nay về memory leak. - "Memory safe" của Rust hẹp: ngăn UAF/double-free/data race nhưng không ngăn leak. Leak là logic error, không violate safety.
- Nguồn leak phổ biến: vòng
Rc— strong reference vòng tròn khiếnstrong_countkhông bao giờ về 0. Fix: một chiều strong, chiều ngược lạiWeak. Box::leakchuyểnBox<T>→&'static T— intentional leak, useful cho global config init runtime.mem::forgetskipDrop— chỉ dùng cho FFI handoff. Cẩn thận RAII bị bypass (leak cả file descriptor / lock).- Detect leak: Valgrind, heaptrack, cargo-leakcheck, LeakSanitizer (
-Zsanitizer=leaknightly). Tích hợp vào CI để bắt regression.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Compiler Rust có cảnh báo khi bạn tạo vòng
Rckhông? Vì sao? - Nếu code dưới đây chạy 1 triệu lần thì hậu quả là gì?
let s = Box::leak(Box::new(format!("{}", i))); - Tại sao
mem::forgetđược xem là "safe" Rust (hàm không cóunsafe) mặc dù nó bỏ quaDrop? - Trong cấu trúc parent-child tree, nên dùng
RchayWeakcho con trỏ parent? Vì sao? - Bạn nghi ngờ một service Rust đang chạy bị leak (RSS tăng đều theo giờ). Bạn sẽ dùng tool nào và đi theo bước gì?
Đáp án
- Không. Compiler chỉ cảnh báo vi phạm safety (UAF, data race, borrow rules...) — vòng
Rckhông vi phạm safety, chỉ là logic error. Phát hiện hoàn toàn do code review hoặc tool runtime (Valgrind, LSan). - Leak ~1 triệu
Stringtrên heap, mỗi lần thêm vài chục bytes mà không bao giờ giải phóng — RSS process tăng đều cho đến khi OS kill (OOM).Box::leakchỉ an toàn khi biết số lần leak là hữu hạn nhỏ. - Vì leak memory không vi phạm safety guarantee của Rust (không có UAF, không có data race). Skipping
Droptệ về RAII / resource management, nhưng không tạo undefined behavior — đủ điều kiện để là safe API. Tài liệustd::mem::forgetnói rõ "this function is not unsafe". - Con trỏ parent nên là
Weak<Node>. Parent đã sở hữu con (quaRc<Node>), nếu con cũng sở hữu parent bằng strongRcthì tạo cycle → leak.Weakkhông tăngstrong_count, phá cycle; truy cập parent quaupgrade(). - Cách tiếp cận: (1) tái hiện locally hoặc trong staging với tải nhỏ; (2) chạy binary qua
heaptrackđể trace allocation theo thời gian — viewheaptrack_guisẽ chỉ ra callsite alloc nhiều mà không dealloc; (3) nếu muốn nhanh hơn, dùngRUSTFLAGS="-Zsanitizer=leak"trên nightly để bắt direct leak qua test có khả năng tái hiện; (4) sau khi xác định callsite, kiểm tra có phảiRccycle,Box::leaktrong loop, hay long-lived collection không bao giờ pop. Fix tương ứng (Weak, refactor scope, drop element định kỳ).
Bài Tiếp Theo
Bài 222: thread::spawn & JoinHandle — mở đầu Nhóm 28 (Concurrency, Threads). Học cách spawn OS thread bằng thread::spawn(|| { ... }), nhận JoinHandle, gọi .join() để chờ thread kết thúc và lấy giá trị trả về (hoặc bắt panic). Đây là tiền đề cho Send/Sync, Arc, Mutex ở các bài sau.
