Danh sách bài viết

Bài 221: Memory Leak Vẫn Có Trong Rust An Toàn — Vòng Rc

Bài 221 của series Rust Cơ Bản — bài cuối Nhóm 27 Smart Pointers. Rust được quảng cáo là "memory safe" — nhưng "safe" có nghĩa cụ thể: ngăn use-after-free, double-free, data race, dangling pointer. Nó không bao gồm "không leak". Leak là một logic error, không violate safety guarantee. Bài đi qua các nguồn leak hợp lệ: vòng Rc (parent ↔ child cả hai dùng strong Rc khiến strong_count không bao giờ về 0 → data không drop, leak trọn đời chương trình), cách fix bằng Weak (một chiều strong giữ ownership, chiều ngược lại weak để phá cycle); Box::leak() như intentional leak chuyển Box<T> thành &'static T (useful cho global config init runtime); mem::forget() skip Drop (dùng khi FFI handoff resource cho C/C++); và các tool detect leak: Valgrind, heaptrack, cargo-leakcheck, LeakSanitizer (-Zsanitizer=leak trên nightly).

09/06/2026
9 phút đọc
1 lượt xem
1

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) Rc trỏ vào nhau bằng strong reference khiến strong_count không bao giờ về 0.
  • Viết được code minh hoạ leak: in Rc::strong_count trướ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ùng Weak (không tăng strong count) — ôn lại bài 218.
  • Hiểu hai API "leak có chủ ý": Box::leak(b) chuyển Box<T> thành &'static T dùng cho global config; mem::forget(value) bỏ qua Drop dù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=leak trên nightly).

Bài này tổng kết Nhóm 27 — Smart Pointers. Tiếp theo sang Nhóm 28 — Concurrency, Threads.

2

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.

3

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 parent drop → count Parent giảm 1, nhưng Child bên trong vẫn còn giữ một Rc<Parent> → count Parent vẫn ≥ 1.
  • Count Parent không về 0 → Parent không drop → Rc<Child> bên trong nó không drop → count Child không giảm → Child khô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ữ prevnext), graph có chu trình, parent-child tree khi child cần biết parent.

4

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.

5

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.

6

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).

7

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 free sau đó. 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 Drop trong một số code unsafe nội bộ, ví dụ std dù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.

8

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 test với detect leak; tiện cho test runner.
  • LeakSanitizer (LSan) — phần của LLVM, tích hợp Rust qua flag -Zsanitizer=leak chỉ 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.

9

Tổng Kết

  • Nhóm 27 đi qua: Box<T> (heap, 1 owner), Deref coercion, Drop trait, 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ến strong_count không bao giờ về 0. Fix: một chiều strong, chiều ngược lại Weak.
  • Box::leak chuyển Box<T>&'static T — intentional leak, useful cho global config init runtime.
  • mem::forget skip Drop — 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=leak nightly). Tích hợp vào CI để bắt regression.
10

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Compiler Rust có cảnh báo khi bạn tạo vòng Rc không? Vì sao?
  2. 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)));
  3. Tại sao mem::forget được xem là "safe" Rust (hàm không có unsafe) mặc dù nó bỏ qua Drop?
  4. Trong cấu trúc parent-child tree, nên dùng Rc hay Weak cho con trỏ parent? Vì sao?
  5. 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
  1. Không. Compiler chỉ cảnh báo vi phạm safety (UAF, data race, borrow rules...) — vòng Rc khô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).
  2. Leak ~1 triệu String trê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::leak chỉ an toàn khi biết số lần leak là hữu hạn nhỏ.
  3. Vì leak memory không vi phạm safety guarantee của Rust (không có UAF, không có data race). Skipping Drop tệ về RAII / resource management, nhưng không tạo undefined behavior — đủ điều kiện để là safe API. Tài liệu std::mem::forget nói rõ "this function is not unsafe".
  4. Con trỏ parent nên là Weak<Node>. Parent đã sở hữu con (qua Rc<Node>), nếu con cũng sở hữu parent bằng strong Rc thì tạo cycle → leak. Weak không tăng strong_count, phá cycle; truy cập parent qua upgrade().
  5. 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 — view heaptrack_gui sẽ chỉ ra callsite alloc nhiều mà không dealloc; (3) nếu muốn nhanh hơn, dùng RUSTFLAGS="-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ải Rc cycle, Box::leak trong loop, hay long-lived collection không bao giờ pop. Fix tương ứng (Weak, refactor scope, drop element định kỳ).
11

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.