Skip to content
academia.sh

Lesson 12 / 14

Atomic Reference Counting

How the same counting mechanism, built with Arc, produces exactly the same end record — with the difference lying not in behavior but in how the counter is updated — is measured; which context it can be used in is not discussed here, since that is the subject of the Concurrency and Asynchronous Rust course. That shared data can only be read through an Arc, never modified directly, is also shown.

Published Updated

In this lesson

  • Runs code showing that the same counting setup, built with Arc, produces exactly the same end record
  • Explains that the difference between Rc and Arc lies not in behavior but in how the counter is updated
  • Shows with an example that a value shared through Arc can only be read, never modified directly
Contents

The previous lesson measured that reference counting worked with a counter updated like an ordinary integer in a single context, that this counter rose with every clone and fell with every end. This lesson measures a type that does the same counting job, but updates the counter itself in a different way: Arc<T>. One limit needs stating up front: in which context this difference is needed — several threads accessing the same value at the same time — is not this lesson’s subject, it is the subject of Concurrency and Asynchronous Rust. Here only this is measured: does behavior change when the counter is updated atomically, or does it not? What is measured is still a value’s end — not how a running unit stops, the question the Go curriculum’s Concurrency course asks.

This limit’s counterpart in the code is plain: no thread is started anywhere in this lesson. All the runs below, like the previous lesson’s, run start to finish within a single flow of execution. This is not a gap, it is a scoping decision — even though Arc‘s name evokes a usage context, what this lesson measures is only the counter itself, not the environment it is used in. This follows a rule this topic has already applied before: in the Heap-Allocated Single Ownership lesson, why a piece of writing was rejected was described in prose, and the accepted writing was run; here too, the context a type’s name evokes is not narrated, only what is measurable — the counter itself — is run.

Same Counter, Different Update

Arc carries the same interface as Rc:

Rc’s counterpart Arc’s counterpart
Rc::new Arc::new
Rc::clone Arc::clone
Rc::strong_count Arc::strong_count

Each row’s left and right sides are same-named calls on two different types doing the same job: placing a value on the heap and setting the counter to one, producing a copy and raising the counter, reading the counter’s current value. The only difference is how the increment and decrement operations are carried out. An ordinary increment consists of three separate steps: the current value is read, one is added, the result is written back. An atomic increment carries out these three steps as a single indivisible operation that nothing else can interrupt in between. Rc‘s counter uses an ordinary increment, Arc‘s counter uses an atomic increment — but both produce the same mathematical result: one rise per clone, one fall per end. The same distinction holds for decrementing: a copy’s end lowers the counter by one through either an ordinary or an atomic decrement; whichever method is used, the counter’s final value is the same.

This lesson’s measurement reruns the previous lesson’s setup with Arc, in exactly the same form.

In this lesson and the previous one, a copy is called not as shared.clone() but as Rc::clone(&shared) or Arc::clone(&shared). Both produce the same result — Rust allows a method call to be written either as value.clone() or as Type::clone(&value) — but this lesson prefers the second form throughout, because the first form can give the reader the impression of “an operation that deep-copies the content”. In fact what is cloned here is only the counter and the access; the Type::clone(&value) form makes this clearer in the source.

SO8. — the claim that “the same setup produces exactly the same counter sequence with Arc too” is confirmed by the counter values printed in the sequence 1, 2, 3, 2 in the run below — the exact same sequence as the previous lesson’s Rc run.

// atomic_counter.rs — Arc does the same counting job atomically
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;

type Ledger = Rc<RefCell<Vec<String>>>;

struct Trace {
    name: String,
    ledger: Ledger,
}

impl Trace {
    fn new(name: &str, ledger: &Ledger) -> Trace {
        Trace {
            name: name.to_string(),
            ledger: Rc::clone(ledger),
        }
    }
}

impl Drop for Trace {
    fn drop(&mut self) {
        self.ledger.borrow_mut().push(self.name.clone());
    }
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));
    {
        let shared: Arc<Trace> = Arc::new(Trace::new("atomic-shared", &ledger));
        println!("counter once built: {}", Arc::strong_count(&shared));

        let second = Arc::clone(&shared);
        println!("counter after one copy: {}", Arc::strong_count(&shared));

        {
            let _third = Arc::clone(&shared);
            println!("counter after two copies: {}", Arc::strong_count(&shared));
        }
        println!("counter after the inner scope ends: {}", Arc::strong_count(&shared));
        println!("second copy still readable: {}", second.name);
    }
    let record = ledger.borrow();
    println!("record count in the ledger: {}", record.len());
    println!("records: {}", record.join(", "));
}
counter once built: 1
counter after one copy: 2
counter after two copies: 3
counter after the inner scope ends: 2
second copy still readable: atomic-shared
record count in the ledger: 1
records: atomic-shared

The Rc::clone(ledger) call in this run is only used to share the ledger that holds the notes; the sharing being measured is built with Arc on shared. The counter sequence — one, two, three, then two — is exactly the same as the previous lesson’s. When the inner scope closes, _third ends, the counter returns to two, but the Trace value has not ended yet; when the outer scope closes, the remaining two copies end as well and a single record appears in the ledger. Arc does everything Rc does, in the same order, with the same result.

In the last line, reading second.name still gives the text atomic-shared — just as Rc::clone did in the previous lesson, Arc::clone does not recall Trace::new either, it only opens a second access path to the same value. The distinction built in the Cloning Is Not Copying Data section holds here too: Arc::clone, despite the verb in its name, multiplies not the data but the counter and the access to it. In this run too, Trace::new is called only once; second and _third look at the same value, at the same heap location, as shared.

Same Setup, Same Result

When the two lessons’ runs are placed side by side, no difference shows: the counter sequence is the same, the ledger length is the same, the relationship between a copy ending and the counter falling is the same. This is not a coincidence — Arc is a type carrying the same contract as Rc; both enforce the rule that “the value ends when the counter hits zero”. The difference between them is not in behavior, but in the operation that updates the counter itself: one does an ordinary increment/decrement, the other does it atomically. In which context this difference gains meaning — several threads accessing the same counter at the same time — is not taken up here; that question belongs to Concurrency and Asynchronous Rust.

Two other properties measured in the Reference Counting lesson — that every value has its own counter, and that the end depends on how many copies remain rather than which one — can be tested for Arc as well. The run below builds two independent Arc values, drops one’s three copies in an order other than construction order, and shows the other value is unaffected.

// arc_properties.rs — independent counters and order-independence hold for Arc too
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;

type Ledger = Rc<RefCell<Vec<String>>>;

struct Trace {
    name: String,
    ledger: Ledger,
}

impl Trace {
    fn new(name: &str, ledger: &Ledger) -> Trace {
        Trace {
            name: name.to_string(),
            ledger: Rc::clone(ledger),
        }
    }
}

impl Drop for Trace {
    fn drop(&mut self) {
        self.ledger.borrow_mut().push(self.name.clone());
    }
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));
    let first: Arc<Trace> = Arc::new(Trace::new("first-atomic", &ledger));
    let second: Arc<Trace> = Arc::new(Trace::new("second-atomic", &ledger));

    let a = Arc::clone(&first);
    let b = Arc::clone(&first);
    println!(
        "counter after three copies — first: {}, second: {}",
        Arc::strong_count(&first),
        Arc::strong_count(&second)
    );

    drop(a);
    drop(first);
    println!(
        "ledger once two copies are dropped (should still be empty): {}",
        ledger.borrow().join(", ")
    );

    drop(b);
    println!(
        "ledger once the last copy is dropped too: {}",
        ledger.borrow().join(", ")
    );
    println!("second counter still unchanged: {}", Arc::strong_count(&second));

    drop(second);
    let record = ledger.borrow();
    println!("ledger once second is dropped too: {}", record.join(", "));
}
counter after three copies — first: 3, second: 1
ledger once two copies are dropped (should still be empty): 
ledger once the last copy is dropped too: first-atomic
second counter still unchanged: 1
ledger once second is dropped too: first-atomic, second-atomic

When a and the original first binding of the three copies are dropped, the ledger is still empty — because b remains, first-atomic has not ended yet. Once b is dropped too, the ledger fills. Meanwhile second’s counter never changes: the two values keep two independent records. The result is exactly the same as what was seen for Rc in the Reference Counting lesson — only the operation that updates the counter differs. The drop order here was again chosen as a, first, b, outside the construction order (first, then a, then b); the result still did not change, because what decides it is not which copy comes in the drop order, but how many remain.

Two Separate Types, a Visible Choice

Rust offering this as two separate types — Rc and Arc — rather than a single configurable type is a design decision. Which form the counter is updated in is not a runtime flag or a setup option, it is a visible type choice in the source code: whether you build a value with Rc::new or with Arc::new makes readable from the source not who may or may not read that value, but which operation its counter is updated with. This repeats a pattern the course has seen many times before: a behavioral difference is tied not to a runtime decision, but to a type difference. The two types can largely stand in for each other — they work with the same new, clone, strong_count calls — but which one was chosen stays in the source as a permanent piece of information about that value.

This arrangement takes the same shape as the distinction seen in the Ordered and Set Structures lesson of the Rust Fundamentals course: a hash map and an ordered map do the same basic job too, but the choice between them is not a runtime decision, it is a source decision tied to which type is written. The choice between Rc and Arc fits the same mold — both types give the same answer to the question “how many copies are there”, the only difference is how that answer is produced.

The Atomic Counter Protects the Counter, Not the Data

Atomic updating protects only the counter; when it is the shared data itself at stake rather than the counter, Arc gives no additional guarantee. A line that tries to write directly to a field through an Arc does not compile: Arc, just like a cloned Rc, gives only shared access to the value it holds — the same principle by which a reference (&T) allows only reading. The accepted piece of writing only reads the same field.

This constraint is not actually unique to Arc; the same rule held for Rc in the previous lesson too, it was just not spelled out there because the actual measurement was the counter. The reason it is spelled out here is this: an atomic counter can create the impression that “shared data can now safely be modified”, because the word “atomic” evokes a guarantee. The measurement corrects this impression — the guarantee is only for the counter’s own consistency, not for the content of the value the counter protects. Two copies can read the same data at the same time, but neither can modify it on its own; the need to modify it requires a separate tool.

SO9. — the claim that “several copies can read the same value but none of them can modify it” is confirmed by the run below printing the same value field from both copies, and containing no write line at all.

// shared_data_read_only.rs — only reading is possible through Arc
use std::sync::Arc;

struct Measurement {
    value: i64,
}

fn main() {
    let shared = Arc::new(Measurement { value: 42 });
    let copy = Arc::clone(&shared);

    println!("first read: {}", shared.value);
    println!("read from copy: {}", copy.value);
    println!("counter: {}", Arc::strong_count(&shared));
}
first read: 42
read from copy: 42
counter: 2

Both copies read the same value field and both give the same result, because there is in fact a single Measurement value, and both copies access it. No line in this program writes to the value field — that is not a coincidence, reading is the only operation Arc allows. A line trying to set value to 43 would not compile; this rejection is not additionally run in the program, because it is not placed into a source code block that does not compile. The counter is also read in this run (counter: 2), but this line has no relation to the value field — one tells how many copies there are, the other tells the content of the value the copies look at; they are two independent, separate questions.

This example uses a plain Measurement struct instead of the Trace/ledger rig, because what is measured is not an end record but access itself. The Heap-Allocated Single Ownership lesson showed that reading point.x through a Box works as if the value sat right there; here too, shared.value and copy.value use the same line form. The difference is this: in Box this access was open to both reading and writing, because there was a single owner. In Arc the same access is open only to reading, because ownership is shared and a mechanism that tracks who writes to a shared value and when is not yet in place.

When the three measurements come together, this emerges: at the counter level, Arc is a strengthened form of Rc, but this strengthening never spreads to the shared data itself. However robustly the counter is updated, whether the data is modifiable is a separate question, and neither of these two types answers it yet.

Reading this topic’s first three lessons together shows a progression. Heap-Allocated Single Ownership established that a value can sit on the heap while its owner is still singular. Reference Counting made that singularity extensible with a counter — ownership was now a countable set. This lesson offered how that counter is updated as a variable choice, but did not change the ownership and access model itself: however many copies there are, none of them can modify the value. Across three lessons the container changed, the counter changed, but the right to write to the value inside never arose — how that right is gained is the topic’s next and final question. What the three lessons share is this: in each of them a value’s lifetime is still readable from the source, none of them breaks this determinism — only the form of ownership and the right of access widen, step by step.

Summary

  • Arc<T> carries the same interface as Rc<T> and enforces the same “the value ends when the counter hits zero” rule.
  • The end record and counter sequence the two types produce are exactly the same — in both, the value’s life ends with its container; the difference is not in behavior, but in the counter being updated atomically.
  • In which context the atomic update is needed is not this lesson’s subject; it belongs to Concurrency and Asynchronous Rust.
  • Data shared through Arc can only be read; writing directly to a field does not compile.
  • The atomic counter protects not the data but only itself; that shared data stays unmodifiable is a separate constraint.
  • Every Arc value has its own counter, and the end depends not on which copy but on how many remain — this principle is the same as with Rc.

Next Step

This lesson measured that a shared value can be read but not modified. The next question is unavoidable: what happens if a shared value needs to be modified? The Variables and Immutability lesson left a boundary open: a binding’s immutability and a field inside it being modifiable were separate questions, and that lesson only drew the boundary, it did not close it. The next lesson — this topic’s fourth — closes that boundary by measuring how borrow checking can move from compile time to runtime, and what cost that move brings.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close