Danh sách bài viết

Bài 231: Arc<T> — Atomic Reference Count

Bài 231 của series Rust Cơ Bản — Arc<T> (Atomically Reference Counted) là smart pointer multi-thread của Rust — interface giống hệt Rc<T> nhưng counter dùng atomic instruction để hai thread có thể cùng clone/drop mà không đụng nhau. Bài giải thích vì sao Arc<T> là Send + Sync khi T: Send + Sync, cú pháp Arc::new/Arc::clone với phép tăng count atomic, cách gửi Arc qua thread::spawn với move closure, cost atomic so với non-atomic (~10-100x chậm hơn Rc về relative nhưng vẫn nằm trong vùng nano-giây), use case kinh điển: share config hoặc read-only data cho N worker thread không cần lock, preview Arc<Mutex<T>> — pattern phổ biến nhất khi cần shared mutable cross-thread (chi tiết ở Bài 233), và Arc::downgrade tạo Weak<T> để phá cycle trong môi trường multi-thread (tương tự như Rc/Weak single-thread).

09/06/2026
10 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 Arc<T> là smart pointer reference-counted phiên bản atomic — interface giống Rc<T> nhưng counter dùng atomic instruction nên an toàn khi nhiều thread cùng thao tác.
  • Dùng được Arc::new(value) để tạo allocation chia sẻ, và Arc::clone(&arc) để tạo thêm owner mới (mỗi clone tăng count thêm 1 qua phép cộng atomic).
  • Biết Arc<T>Send + Sync khi T: Send + Sync — đây là lý do compiler cho phép truyền vào thread::spawn với move closure.
  • Hiểu cost của Arc: atomic increment chậm hơn non-atomic ~10-100 lần về tỉ lệ, nhưng tuyệt đối vẫn rất nhanh (chỉ vài nano-giây) — không phải bottleneck thực tế.
  • Biết use case kinh điển: share read-only config / data lớn cho N worker thread mà không cần lock.
  • Biết pattern Arc<Mutex<T>> là cách phổ biến nhất để có shared mutable cross-thread — chi tiết ở Bài 233.
  • Dùng Arc::downgrade(&arc) để lấy Weak<T> — tương tự cặp Rc/Weak nhưng cho multi-thread, phá cycle trong graph có chu trình.
2

Arc<T> Là Gì

Arc<T> nằm trong std::sync, viết tắt của Atomically Reference Counted. Ý tưởng và API y hệt Rc<T> (Bài 215): mỗi allocation đi kèm strong + weak counter, ai cũng là owner ngang nhau, ai drop cuối thì lo dọn dẹp. Khác biệt duy nhất nhưng then chốt: các phép cộng/trừ counter là atomic.

Vì sao điều đó quan trọng? Khi hai thread cùng Rc::clone một lúc, hai phép cộng usize bình thường có thể chồng chéo trên CPU và counter bị sai. Sai count đồng nghĩa với double-free (giải phóng quá sớm — dangling reference) hoặc memory leak (không bao giờ về 0). Để chặn ở compile time, Rc được declare !Send!Sync — không thể đưa cross-thread.

Arc giải bài toán đó bằng cách dùng AtomicUsize cho counter — phép cộng/trừ được CPU đảm bảo nguyên tử, hai thread cùng tăng cũng cho kết quả đúng. Vì vậy Arc<T> implement Send + Sync khi T: Send + Sync — bạn có thể yên tâm move nó vào closure cho thread::spawn.

Quy tắc thực dụng:

  • Single-thread: dùng Rc<T> — rẻ hơn, đủ dùng.
  • Multi-thread: dùng Arc<T> — đắt hơn một chút nhưng an toàn (và compiler cũng sẽ ép bạn dùng Arc nếu cần Send).

Tên gọi nhấn mạnh "Atomic" cũng là lời nhắc: chỉ thay Rc bằng Arc khi thực sự cần — đừng phòng thân, overhead atomic luôn tồn tại.

3

Cú Pháp Arc::new

Khởi tạo một Arc bằng Arc::new:

use std::sync::Arc;

fn main() {
    let arc = Arc::new(5);
    println!("arc = {}", arc);                       // 5 — Arc deref tự động
    println!("count = {}", Arc::strong_count(&arc)); // 1
}

Phân tích:

  • Arc::new(5) cấp một allocation trên heap chứa: value 5 + strong count atomic (khởi tạo = 1) + weak count atomic (= 0). Trả về Arc<i32>.
  • Arc<T> implement Deref trả về &T, nên dùng method/operator của T trực tiếp như khi cầm reference thường.
  • Arc::strong_count(&arc) là associated function (không phải method), trả về số strong reference hiện tại — tiện cho debug.

Lưu ý cú pháp use std::sync::Arc;Arc nằm trong module sync (gần với Mutex, RwLock), khác với Rcstd::rc. Đây là lời nhắc rõ ràng: chỉ cần đụng tới Arc, bạn đang ở vùng đất concurrency.

Giống Rc: Arc::new nhận value by value (move). Bạn phải có ownership của T trước, không thể tạo Arc từ một reference có sẵn — bản chất allocation phải copy value vào heap kèm header counter.

4

Arc::clone Tăng Count Atomic

Tạo thêm owner bằng Arc::clone:

use std::sync::Arc;

fn main() {
    let arc = Arc::new(String::from("hello"));
    let a = Arc::clone(&arc);
    let b = Arc::clone(&arc);

    println!("count = {}", Arc::strong_count(&arc)); // 3
    println!("{}, {}, {}", arc, a, b);
}

Mỗi Arc::clone(&arc) chỉ làm hai việc:

  1. Tăng strong count thêm 1 bằng atomic fetch_add với ordering Relaxed — phép tăng nguyên tử ở mức CPU.
  2. Trả về Arc mới trỏ tới cùng allocation.

Vẫn là O(1), vẫn không deep clone String. Khác biệt duy nhất so với Rc::clone: phép cộng nguyên tử thay vì phép cộng thường — đắt hơn một chút (xem mục 6).

Convention vẫn là Arc::clone(&arc) chứ không phải arc.clone() — đọc rõ ý đồ "tăng reference count", tránh nhầm với deep clone của T. Đây là điều Rust Book và clippy đều khuyến nghị, và lại càng quan trọng trong context concurrency, nơi mỗi cost nhỏ đều có thể bị săm soi khi tuning.

// Cả hai cùng kết quả, nhưng:
let b = arc.clone();         // Mơ hồ: có thể là deep clone của T?
let c = Arc::clone(&arc);   // Rõ ràng: chỉ tăng reference count atomic

Khi mỗi binding Arc ra khỏi scope, Drop trigger phép fetch_sub atomic giảm count đi 1. Owner cuối cùng (count chuyển từ 1 về 0) chạy Drop của T và giải phóng heap allocation — vẫn đúng "nguyên tắc cuối tắt đèn", không sớm không muộn.

5

Send Across Thread

Đây là điểm Arc tỏ rõ giá trị so với Rc: clone xong, move vào closure cho thread::spawn được ngay:

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(vec![1, 2, 3, 4, 5]);

    let mut handles = vec![];
    for i in 0..3 {
        let data = Arc::clone(&data);                  // clone trước loop body
        let h = thread::spawn(move || {                 // move clone vào thread
            let sum: i32 = data.iter().sum();
            println!("thread {i}: sum = {sum}");
        });
        handles.push(h);
    }
    for h in handles { h.join().unwrap(); }

    println!("count cuối = {}", Arc::strong_count(&data)); // 1 — các thread đã drop
}

Quan sát:

  • Trước mỗi thread::spawn, ta Arc::clone(&data) rồi move bản clone đó vào closure — tăng count atomic 1 lần, mỗi thread giữ bản Arc riêng.
  • thread::spawn yêu cầu closure phải Send. Closure capture Arc<Vec<i32>>; vì Vec<i32>Send + Sync, Arc<Vec<i32>> cũng Send + Sync → compiler chấp nhận.
  • Khi mỗi thread kết thúc, Arc capture trong closure drop → atomic decrement count. main giữ 1 Arc cuối — đến cuối cùng Vec mới được giải phóng.

Nếu thay Arc bằng Rc, compile sẽ fail với thông báo quen thuộc: "`Rc<Vec<i32>>` cannot be sent between threads safely — the trait `Send` is not implemented". Compiler không cho phép vi phạm safety, đúng tinh thần Bài 230.

6

Cost Arc vs Rc

Atomic operation phải qua memory barrier và đôi khi qua cache coherence protocol giữa các core, nên đắt hơn phép cộng thường. Con số tham khảo:

  • Non-atomic increment (Rc): cỡ ~0.3 ns (1 cycle trên CPU 3 GHz), thường được CPU pipeline che hết.
  • Atomic increment (Arc): cỡ ~5-30 ns trên x86_64 (chậm hơn ~10-100 lần về tỉ lệ).

Nghe "100x chậm hơn" có vẻ kinh hoàng nhưng cần giữ tỉ lệ: Arc::clone vẫn nằm trong vùng nano-giây. Nếu một request handler chỉ clone một số lần hữu hạn, tổng overhead vẫn dưới microsecond — không phải bottleneck. Bottleneck thực tế của Arc thường nằm ở chỗ khác: contention khi nhiều thread cùng tăng/giảm counter trên cùng allocation làm cache line liên tục bounce giữa các core.

Lời khuyên thực dụng:

  • Đừng tối ưu sớm — nếu cần share cross-thread, dùng Arc.
  • Nếu profile thấy Arc contention cao, suy nghĩ: có cần share đến mức đó không? Có thể giảm số lượng clone, hoặc đổi sang pattern khác (channel, sharded data).
  • Trong single-thread, vẫn nên dùng Rc — không có lý do trả overhead atomic vô ích.

Một con số ngữ cảnh: Arc::clone rẻ hơn vài bậc so với một lần Mutex::lock/unlock không tranh chấp (cỡ vài chục đến hàng trăm ns), và rẻ hơn nhiều bậc so với một context switch (cỡ vài microsecond).

7

Use Case: Share Config / Read-Only Data Multi-Thread

Use case kinh điển nhất của Arc: share read-only data cho N worker thread. Vì data không đổi, không cần lock — chỉ cần đảm bảo mọi thread cùng giữ một bản tham chiếu sống.

use std::sync::Arc;
use std::thread;

#[derive(Debug)]
struct Config {
    api_url: String,
    timeout_ms: u64,
    max_retries: u32,
}

fn main() {
    // Load config 1 lần, wrap vào Arc
    let config = Arc::new(Config {
        api_url: String::from("https://api.example.com"),
        timeout_ms: 5000,
        max_retries: 3,
    });

    // Spawn 4 worker, mỗi worker dùng config read-only
    let mut handles = vec![];
    for i in 0..4 {
        let config = Arc::clone(&config);
        let h = thread::spawn(move || {
            // Tất cả các thread đọc cùng 1 Config trên heap
            println!(
                "worker {i}: call {} timeout={}ms retries={}",
                config.api_url, config.timeout_ms, config.max_retries
            );
        });
        handles.push(h);
    }
    for h in handles { h.join().unwrap(); }
}

Tại sao dùng Arc<Config> chứ không clone Config cho mỗi thread?

  • Tiết kiệm bộ nhớ: nếu Config chứa string lớn, danh sách rule, từ điển — clone 4 bản tốn 4x bộ nhớ. Arc share đúng 1 bản, mỗi thread giữ con trỏ 8 byte.
  • Cache friendly: tất cả thread đọc cùng địa chỉ memory → CPU cache hit cao, không có invalidation vì không ai mutate.
  • Code rõ ràng: muốn nói "đây là singleton shared, không thay đổi" thì Arc<Config> diễn đạt trực tiếp.

Lưu ý: pattern này chỉ áp dụng khi data thực sự không đổi sau khi tạo Arc. Nếu cần update (reload config khi nhận signal), bạn cần Arc<RwLock<Config>> (Bài 234) hoặc arc-swap crate.

8

Arc<Mutex<T>> Cho Shared Mutable

Khi cần nhiều thread cùng mutate một data, chỉ Arc là chưa đủ — Arc<T> chỉ cho ra &T (immutable). Pattern phổ biến nhất là gói Mutex<T> bên trong ArcArc<Mutex<T>>: Arc lo share ownership cross-thread, Mutex lo exclusive write access.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0_u64));

    let mut handles = vec![];
    for _ in 0..8 {
        let counter = Arc::clone(&counter);
        let h = thread::spawn(move || {
            for _ in 0..1000 {
                let mut g = counter.lock().unwrap();
                *g += 1;                              // mutate dưới lock
            }
        });
        handles.push(h);
    }
    for h in handles { h.join().unwrap(); }

    println!("counter = {}", *counter.lock().unwrap()); // 8000
}

Đọc nhanh: 8 thread, mỗi thread tăng counter 1000 lần dưới lock; kết quả cuối luôn là 8000, không bị race.

Pattern này phổ biến đến mức bạn sẽ gặp nó mỗi lần đụng concurrency Rust:

  • Shared counter / stats — request count, error count, latency histogram.
  • Shared queue / buffer — N producer push, M consumer pop.
  • Shared cache — lookup miss thì lock, insert, unlock.

Cảnh báo nhỏ: Mutex không miễn phí (lock/unlock cỡ vài chục ns không tranh chấp, hàng trăm ns khi có contention), và lock càng to thì throughput càng thấp. Bài 232 sẽ đi sâu vào Mutex<T>, Bài 233 sẽ trình bày kỹ pattern Arc<Mutex<T>> cùng các lưu ý về poisoning, deadlock, và giữ lock càng ngắn càng tốt.

9

Weak Variant — Arc::downgrade

Cũng như Rc/rc::Weak, Arc có cặp đôi sync::Weak<T> — weak reference không sở hữu, không giữ data sống, chỉ cho biết "data đang còn hay đã chết". Dùng để phá cycle trong graph (parent ↔ child) khi cả hai đều là Arc.

use std::sync::{Arc, Weak};

fn main() {
    let strong = Arc::new(String::from("data"));
    let weak: Weak<String> = Arc::downgrade(&strong);

    println!("strong = {}", Arc::strong_count(&strong)); // 1
    println!("weak   = {}", Arc::weak_count(&strong));   // 1

    // Nâng cấp weak về Arc (chỉ thành công nếu data còn sống)
    if let Some(arc) = weak.upgrade() {
        println!("upgrade OK: {}", arc);
    }

    drop(strong);
    // Sau khi strong cuối cùng drop, upgrade trả None
    assert!(weak.upgrade().is_none());
}

Đặc điểm:

  • Arc::downgrade(&arc) -> Weak<T> tăng weak count atomic, không tăng strong count.
  • Weak::upgrade(&self) -> Option<Arc<T>> trả Some nếu còn ít nhất 1 strong reference; trả None nếu strong đã về 0 (data đã bị drop).
  • Weak không giữ data sống — đúng vai trò "tham chiếu yếu", không cản trở giải phóng.

Use case: parent Arc<Node> chứa Vec<Arc<Node>> children. Nếu child cũng muốn tham chiếu ngược lên parent, dùng Weak<Node> — tránh cycle parent ↔ child gây leak. Y hệt pattern Rc/Weak ở Bài 218, chỉ khác là an toàn cross-thread.

10

Tổng Kết

  • Arc<T> (Atomically Reference Counted, ở std::sync) là phiên bản multi-thread của Rc<T> — counter dùng atomic instruction nên hai thread cùng clone/drop đều an toàn.
  • Arc<T>Send + Sync khi T: Send + Sync — đó là điều cho phép truyền qua thread::spawn với move closure mà compiler chấp nhận.
  • Arc::new(value) wrap value vào heap kèm counter atomic; Arc::clone(&arc) tăng strong count atomic 1, trả về Arc mới trỏ cùng allocation — O(1).
  • Cost: atomic increment ~5-30 ns trên x86_64, chậm hơn non-atomic ~10-100x về tỉ lệ nhưng vẫn nằm trong vùng nano-giây — không phải bottleneck thực tế, trừ khi có contention cực cao.
  • Use case nổi bật: share read-only config / data lớn cho N worker thread — không cần lock vì không ai mutate; tiết kiệm bộ nhớ và cache friendly.
  • Arc<Mutex<T>> là pattern phổ biến nhất cho shared mutable cross-thread — Arc lo ownership, Mutex lo exclusive write. Chi tiết ở Bài 233.
  • Arc::downgrade(&arc) -> Weak<T> tạo weak reference không sở hữu — phá cycle trong graph multi-thread, dùng upgrade() để check còn sống.
  • Quy tắc chọn: single-thread + 1 owner → Box; single-thread + n owner → Rc; multi-thread + n owner → Arc; cần mutate trong group cuối → thêm Mutex/RwLock.
11

Bài Tập Củng Cố

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

  1. Vì sao Rc<i32> không gửi được qua thread::spawn còn Arc<i32> thì được? Compiler dựa vào marker trait nào để phân biệt?
  2. Cho đoạn code let a = Arc::new(0); let b = Arc::clone(&a); let c = Arc::clone(&b); drop(b); println!("{}", Arc::strong_count(&a)); — output là bao nhiêu?
  3. Bạn có một Config 200 KB cần chia sẻ cho 16 worker thread, hoàn toàn read-only. Có 2 phương án: (a) clone Config cho mỗi thread; (b) wrap vào Arc<Config> rồi Arc::clone. Mỗi phương án tốn bao nhiêu bộ nhớ thêm? Nên chọn phương án nào?
  4. Vì sao chỉ Arc<T> chưa đủ khi nhiều thread cần mutate data? Pattern phổ biến nhất để giải quyết là gì?
  5. Khi nào nên dùng Weak<T> thay vì Arc<T> trong môi trường multi-thread? Cho 1 ví dụ.
Đáp án
  1. Rc dùng counter không atomic — nếu hai thread cùng clone sẽ race counter → unsafe. Để chặn ở compile time, Rc được declare !Send + !Sync; closure chứa Rc không Sendthread::spawn reject. Arc dùng counter atomic, an toàn → implement Send + Sync (khi T: Send + Sync) → compiler chấp nhận. Compiler dựa vào hai trait SendSync.
  2. Output là 2. Sau let b = Arc::clone(&a) count = 2; sau let c = Arc::clone(&b) count = 3; sau drop(b) count = 2. Còn lại ac giữ → strong count = 2.
  3. (a) clone 16 bản tốn thêm 16 × 200 KB ≈ 3.2 MB cho data + chi phí allocation. (b) Arc::new 1 lần (1 × 200 KB + ~16 byte header counter), Arc::clone 16 lần chỉ tốn 16 × 8 byte con trỏ trên stack = 128 byte — gần như miễn phí. Nên chọn (b) trong gần như mọi trường hợp read-only, vừa tiết kiệm bộ nhớ, vừa cache friendly, vừa diễn đạt rõ ý "share".
  4. Arc<T> chỉ cho Deref::Target = T trả về &T (immutable) — không có DerefMut. Nhiều thread cùng có &mut T trên cùng data sẽ vi phạm borrow rule (data race). Pattern phổ biến nhất: gói trong Mutex<T>Arc<Mutex<T>>. Arc lo share ownership cross-thread, Mutex lo exclusive write access; mỗi lần mutate phải lock().unwrap() để lấy MutexGuard rồi mới deref mut.
  5. Khi cần một tham chiếu không sở hữu để tránh giữ data sống quá lâu hoặc phá cycle. Ví dụ: graph node có cấu trúc parent ↔ child, parent dùng Vec<Arc<Node>> children (strong), child dùng Weak<Node> parent (weak) — tránh cycle (mọi cycle Arc-Arc đều leak vì count không bao giờ về 0).
12

Bài Tiếp Theo

Bài 232: Mutex<T> — Exclusive Lock — học cụ thể std::sync::Mutex: cú pháp Mutex::new(0), gọi lock().unwrap() trả về MutexGuard<T> (deref ra &mut T), guard drop tự động unlock, cơ chế poisoning khi thread giữ lock panic, và các pitfall thường gặp (giữ lock quá lâu, deadlock, lock ordering). Khi vững Mutex, Bài 233 ghép cả hai thành Arc<Mutex<T>> — pattern shared mutable phổ biến nhất.