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 asRc<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
Arccan 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
Arcvalue has its own counter, and the end depends not on which copy but on how many remain — this principle is the same as withRc.
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.