Following Arc::clone from a line of Rust down to a single machine instruction. Article 2 of **Below the Abstraction* — a series that takes everyday Rust abstractions and follows them down to the kernel. Every number and every trace below was produced by running the programs shown, on the machine described in the appendix.*
Why Arc Exists
In the last article we created OS threads and watched Linux build them with clone3. One detail from that investigation matters here more than anything else. Four threads in one process had byte-identical memory maps:
$ diff <(cat /proc/$PID/task/$PID/maps) <(cat /proc/$PID/task/$TID/maps) \
&& echo "identical"
identical
Every thread sees the same address space. A pointer in one thread is a valid pointer in all of them.
Which raises the question this article is about: if several threads can reach the same heap allocation, who is allowed to free it?
Start with a value that has exactly one owner:
let message = String::from("hello from Rust");
Move it into a thread:
use std::thread;
fn main() {
let message = String::from("hello");
thread::spawn(move || {
println!("{message}");
})
.join()
.unwrap();
}
The worker now owns message. The main thread cannot use it any more. That is not the threading system being awkward — that is Rust's ownership model doing exactly its job. One owner, one drop, no ambiguity.
But real systems need something ownership alone does not give you:
- A configuration object read by twenty worker threads.
- Routing state shared across a server's request handlers.
- A cache used by many handlers at once.
- A database connection pool referenced from every task.
Conceptually:
Shared Config
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Worker 1 Worker 2 Worker 3
│ │ │
▼ ▼ ▼
Requests Requests Requests
We do not want to copy the whole configuration for each worker. We want several parts of the program to share ownership of one allocation, and for that allocation to be freed exactly once, when the last of them is finished.
That is the problem Arc<T> solves.
What Is Arc<T>?
Arc stands for:
Atomically Reference Counted
It is a pointer that provides shared ownership of a heap-allocated value. The idea in one picture:
multiple Arc handles
↓
same heap allocation
For example:
use std::sync::Arc;
fn main() {
let value = Arc::new(String::from("hello"));
let a = Arc::clone(&value);
let b = Arc::clone(&value);
println!("{value}");
println!("{a}");
println!("{b}");
}
This does not create three Strings. It creates one, and three handles to it:
Stack
value ───────┐
a ───────────┼───────┐
b ───────────┘ │
▼
Heap allocation
┌──────────────────┐
│ strong count = 3 │
│ weak count │
│ │
│ String │
│ "hello" │
└──────────────────┘
That diagram is the mental model. Later in this article we will read those two counts out of memory directly and check that the picture is accurate — it turns out to be right in shape and slightly wrong in one detail.
A note on vocabulary
Since this series goes low-level, let me define terms as they come up rather than assume them.
Heap allocation — a block of memory obtained at runtime (ultimately via malloc, which as we saw in article 1 sometimes becomes an mmap syscall). It lives until something explicitly frees it, unlike stack memory which disappears when the function returns. This is why sharing across threads needs the heap: a spawned thread can outlive the function that created it, so it cannot borrow that function's stack.
Handle — a small value you hold that refers to something bigger elsewhere. An Arc<T> is a handle; the T is elsewhere.
Why Not Just Use &T?
Before reaching for Arc, it is fair to ask whether a plain reference would do.
let value = String::from("hello");
let reference = &value;
This compiles because the compiler can prove reference cannot outlive value.
thread::spawn breaks that proof. A spawned thread is an independent OS task — article 1 showed Linux scheduling it separately, with its own TID and its own 2 MiB stack. It can still be running long after the function that spawned it has returned and its stack frame is gone. So Rust will not let you hand it a borrow of that stack.
Arc<T> sidesteps the problem by giving each participant something it genuinely owns, while the actual value sits on the heap where nobody's stack frame can take it away.
Why Not Rc<T>?
Rust has a second reference-counting pointer:
Rc<T> = Reference Counted
Arc<T> = Atomically Reference Counted
They do the same job. The difference is one word: atomically.
Rc updates its count with an ordinary CPU increment. That is fine inside one thread and unsafe across several. Arc updates its count with an atomic instruction, which is safe across threads and costs more.
The compiler enforces the distinction. Here is the same program written with Rc and handed to thread::spawn:
error[E0277]: `Rc<Config>` cannot be sent between threads safely
--> threads_rc.rs:18:36
|
18 | handles.push(thread::spawn(move || {
| ------------- ^------
| | |
| ______________________|_____________within this `{closure@threads_rc.rs:18:36: 18:43}`
| | |
| | required by a bound introduced by this call
...
= help: within `{closure@threads_rc.rs:18:36: 18:43}`, the trait `Send` is not implemented for `Rc<Config>`
note: required because it's used within this closure
note: required by a bound in `spawn`
error: aborting due to 1 previous error
(Trimmed in the middle where it echoes the closure body; the rest is verbatim.)
What that error is really saying
The error mentions Send. It is worth stopping here, because Send and its partner Sync are how Rust decides what is allowed near a thread — and most explanations of them are one sentence long and help nobody.
Here is the plain version.
Rust needs to answer two questions about every type:
Question one: is it safe to hand this value to another thread?
That is Send. If a type is Send, you can move it from thread A to thread B and thread B may use it. Most types are fine here. A String is Send — hand it over, the receiving thread now owns it, the sending thread has given it up, nobody is confused.
Question two: is it safe for two threads to look at this value at the same time?
That is Sync. If a type is Sync, two threads may hold &T references to the same value simultaneously and nothing bad happens. A u32 sitting behind a shared reference is Sync — both threads can read it all day, because reading cannot break anything.
You never write impl Send for MyType. The compiler works it out for you: if everything inside your type is Send, your type is Send. It only becomes something you think about when it goes wrong, which is exactly what happened above.
So why is Rc not Send?
Because of the counter. Rc bumps its count with a plain, non-atomic increment. If Rc were Send, you could move one to another thread, and then two threads would be incrementing and decrementing the same ordinary integer at the same time. That is the lost-update problem from the previous section, and it ends with the allocation being freed while somebody still holds a pointer to it.
So the library says: not Send. Not "discouraged", not "be careful" — the type simply cannot cross a thread boundary, and the compiler stops you.
Compare the two:
Send? |
Sync? |
meaning | |
|---|---|---|---|
Rc<T> |
no | no | one thread only, both for moving and for sharing |
Arc<T> |
yes* | yes* | can be moved between threads and shared by them |
* when T itself is Send + Sync — Arc can only be as thread-safe as the thing inside it. This is the same point as the "Arc does not make T thread-safe" section coming up.
And this is the part I find genuinely impressive. The distinction between "safe to share across threads" and "not safe" is not a convention in a style guide, or a comment, or a lint you can ignore. It is in the type system. The unsafe version does not compile. You cannot ship this bug.
Keep that error in mind, because it is going to close a loop. By the end of this article you will have seen the exact machine instruction that is the whole difference between the type that compiles and the type that does not. It is one word long.
What "Reference Counting" Actually Means
Reference counting is a very old idea and a simple one. The allocation carries a number: how many owners currently exist. Owners appearing bump it up; owners leaving bump it down; whoever takes it to zero cleans up.
let data = Arc::new(42); // strong = 1
let data2 = Arc::clone(&data); // strong = 2
let data3 = Arc::clone(&data); // strong = 3
drop(data2); // strong = 2
drop(data3); // strong = 1
drop(data); // strong = 0 -> destroy the value
The model:
Arc::new(T)
↓
strong = 1
Arc::clone()
↓
strong += 1
drop(Arc)
↓
strong -= 1
strong reaches zero
↓
drop T
Nothing surprising so far. The surprising part is that += 1 and -= 1 cannot be ordinary arithmetic once more than one thread is involved.
What "Atomic" Actually Means
Say the count is 2, and two threads clone at the same moment. An ordinary increment is not one operation — the CPU has to read the value, add one, and write it back. Three steps. Two threads can interleave them:
Thread A reads 2
Thread B reads 2
Thread A writes 3
Thread B writes 3
Two new owners were created. The count says 3. It should say 4.
This is called a lost update, and for a reference count it is fatal. The count is now permanently one too low, so the allocation will be freed while a real owner still holds a pointer to it. That owner then reads freed memory — a use-after-free, the exact class of bug Rust exists to prevent.
An atomic read-modify-write (RMW) fixes it. The hardware performs read, add, and write as one indivisible step: no other core can observe or interleave with the middle of it.
CPU 0 shared count CPU 1
fetch_add(1) ───────► 2 ◄─────── fetch_add(1)
↓
4
Arc uses atomic RMW for both directions. Rc does not. That is the whole difference — and it is a real one, not a formality.
How that atomicity is achieved differs by CPU architecture, and we will look at the actual instruction later in this article rather than speculate about it.
Arc Does Not Make T Thread-Safe
This is the most common misconception about Arc, and it is worth being blunt about.
Arc<T> gives you shared ownership. It does not give you synchronized mutation.
let counter = Arc::new(0);
You cannot write *counter += 1 from several threads. Arc hands out &T — a shared reference — and shared references do not permit arbitrary mutation. That is not Arc being restrictive; it is the only sound thing it can do, because Arc has no idea how to make your type safe to mutate concurrently.
For shared mutable state you combine Arc with something that does know:
Arc<Mutex<T>>
Arc<RwLock<T>>
Arc<AtomicU64>
The separation of responsibility is clean, and worth memorising:
Arc
│
└── Who owns this allocation, and when is it freed?
Mutex / RwLock / Atomic
│
└── How is concurrent access to the value synchronized?
Do not merge those two jobs in your head. Nearly every confused question about Arc comes from merging them.
The compiler checks this too
Remember the asterisk on the Send/Sync table earlier — Arc<T> is only thread-safe if T is. Here is what that looks like when you get it wrong.
RefCell<T> gives you mutation through a shared reference, which sounds like exactly what we want. It enforces the borrow rules at runtime with a counter instead of at compile time. But that counter is an ordinary integer, not an atomic one — the same problem as Rc. So RefCell is not Sync: two threads must not look at one at the same time.
Wrap it in an Arc and try to send it to a thread anyway:
let shared = Arc::new(RefCell::new(0));
let a = Arc::clone(&shared);
thread::spawn(move || {
*a.borrow_mut() += 1;
});
error[E0277]: `RefCell<i32>` cannot be shared between threads safely
|
= help: the trait `Sync` is not implemented for `RefCell<i32>`
= note: if you want to do aliasing and mutation between multiple threads,
use `std::sync::RwLock` instead
= note: required for `Arc<RefCell<i32>>` to implement `Send`
Read that last line slowly, because it is the whole idea in one sentence. Arc<RefCell<i32>> is not Send — you cannot move it to a thread — because RefCell<i32> is not Sync. The Arc did not fix anything. It faithfully passed the question through to the type inside it, and the answer came back no.
Arc is a wrapper around ownership, not a thread-safety spray. And the compiler even tells you the fix: use RwLock.
Why Arc<Mutex<T>> is everywhere
Arc<Mutex<HashMap<String, String>>>
Two mechanisms, stacked:
Thread 1 ── Arc ──┐
Thread 2 ── Arc ──┼──► Mutex ──► HashMap
Thread 3 ── Arc ──┘
Arc lets several independently spawned threads own the same Mutex. The Mutex controls exclusive access to the HashMap. Remove the Arc and you cannot share it; remove the Mutex and you cannot mutate it safely.
It is a common pattern and a reasonable default. It is not automatically the right architecture — article 3 benchmarks when it is and when RwLock or sharding beats it.
The Question
For this article:
What actually changes in memory when we create, clone, move, and drop an
Arc<T>? And what changes when several OS threads clone and drop the sameArcat once?
Concretely, the things to find out:
Arc allocation layout
strong reference count
weak reference count
pointer identity
clone behaviour
drop behaviour
atomic increments and decrements
memory ordering
cache-line effects
cross-thread ownership
The Hypothesis
Arc::clonedoes not clone the innerT. It creates another owning pointer to the same allocation and atomically increments a shared strong count. The last owner to drop destroys the value and frees the memory. Being atomic, those updates cost more than ordinary ones — and cost more still when several CPU cores touch the same count.
Now let's test each clause.
Experiment 1 — Prove Shared Allocation and Reference Counting
Start with the smallest program that can settle it. No dependencies:
cargo new rust-arc-internals
cd rust-arc-internals
src/main.rs:
use std::sync::Arc;
#[derive(Debug)]
struct Data {
value: u64,
}
impl Drop for Data {
fn drop(&mut self) {
println!("Dropping Data {{ value: {} }}", self.value);
}
}
fn main() {
let original = Arc::new(Data { value: 42 });
println!("after Arc::new");
print_arc_state("original", &original);
let clone_a = Arc::clone(&original);
println!("\nafter clone_a");
print_arc_state("original", &original);
print_arc_state("clone_a", &clone_a);
let clone_b = Arc::clone(&original);
println!("\nafter clone_b");
print_arc_state("original", &original);
print_arc_state("clone_a", &clone_a);
print_arc_state("clone_b", &clone_b);
drop(clone_a);
println!("\nafter dropping clone_a");
print_arc_state("original", &original);
print_arc_state("clone_b", &clone_b);
drop(clone_b);
println!("\nafter dropping clone_b");
print_arc_state("original", &original);
println!("\nleaving main");
}
fn print_arc_state(name: &str, arc: &Arc<Data>) {
println!(
"{name}: ptr={:p}, strong={}, weak={}, value={}",
Arc::as_ptr(arc),
Arc::strong_count(arc),
Arc::weak_count(arc),
arc.value,
);
}
Three details in that program are deliberate.
Arc::clone(&original) rather than original.clone(). Both work. The first makes it obvious at the call site that you are cloning the handle, not the data. In concurrent code that distinction is the difference between a pointer copy and a deep copy, and it is worth spelling out.
Arc::as_ptr returns a raw pointer to the inner value. We only print it — never dereference it. If three handles print the same address, they refer to one allocation, and the "three Data values" theory is dead.
A custom Drop. This is the strongest evidence available. Reference counts are just numbers we are being told; a destructor running is an event we can observe. If Dropping Data prints exactly once, at exactly the right moment, the model is confirmed by behaviour rather than by report.
Predict before you run
Worth answering for yourself first:
- Will all three
Arcs print the same pointer? - What is
strong_countafter each clone? - Does cloning invoke
Data's own clone? (It cannot —Datadoes not implementClone.) - When does
Data::droprun? - How many times does it run?
- What does
weak_countshow when we never created aWeak? - Does
drop(clone_a)free the allocation?
The output
after Arc::new
original: ptr=0x563fea01ed70, strong=1, weak=0, value=42
after clone_a
original: ptr=0x563fea01ed70, strong=2, weak=0, value=42
clone_a: ptr=0x563fea01ed70, strong=2, weak=0, value=42
after clone_b
original: ptr=0x563fea01ed70, strong=3, weak=0, value=42
clone_a: ptr=0x563fea01ed70, strong=3, weak=0, value=42
clone_b: ptr=0x563fea01ed70, strong=3, weak=0, value=42
after dropping clone_a
original: ptr=0x563fea01ed70, strong=2, weak=0, value=42
clone_b: ptr=0x563fea01ed70, strong=2, weak=0, value=42
after dropping clone_b
original: ptr=0x563fea01ed70, strong=1, weak=0, value=42
leaving main
Dropping Data { value: 42 }
Every clause of the hypothesis, confirmed:
-
One address,
0x563fea01ed70, printed by all three handles at every stage. One allocation, three pointers. - The count tracks handles exactly: 1, 2, 3, then 2, then 1.
-
Dropping Dataprints once, afterleaving main— whenoriginal, the final handle, goes out of scope at the end ofmain. Dropping the two clones destroyed nothing. -
weak_countis 0 throughout, since we never made aWeak.
Note the ordering of the last two lines. leaving main is the last statement in the function; the destructor runs after it, as original goes out of scope. Rust drops locals at the end of the scope, in reverse declaration order. Small thing, but if you have ever wondered why a "shutting down" log line appears before the cleanup it is describing, that is why.
Experiment 2 — The Same Arc Across OS Threads
Single-threaded proof is nice; the whole point of Arc is threads. So:
use std::sync::Arc;
use std::thread;
struct Config { name: String }
impl Drop for Config {
fn drop(&mut self) { println!(" Config::drop ran for {}", self.name); }
}
fn main() {
let cfg = Arc::new(Config { name: "prod-eu-west-1".into() });
println!("main thread: handle at {:p} on the stack, points to {:p} on the heap",
&cfg as *const _, Arc::as_ptr(&cfg));
println!("strong before spawning = {}", Arc::strong_count(&cfg));
let mut handles = Vec::new();
for i in 0..4 {
let cfg = Arc::clone(&cfg); // one increment, once per thread
handles.push(thread::spawn(move || {
println!(" worker {i}: tid={:?} handle at {:p} heap {:p} strong(snapshot)={}",
thread::current().id(),
&cfg as *const _,
Arc::as_ptr(&cfg),
Arc::strong_count(&cfg));
}));
}
for h in handles { h.join().unwrap(); }
println!("strong after all workers joined = {}", Arc::strong_count(&cfg));
println!("dropping the last handle now:");
}
Output:
main thread: handle at 0x7ffedfc480b8 on the stack, points to 0x55a027533af0 on the heap
strong before spawning = 1
worker 1: tid=ThreadId(3) handle at 0x7fe28effdcf0 heap 0x55a027533af0 strong(snapshot)=4
worker 0: tid=ThreadId(2) handle at 0x7fe28f1fecf0 heap 0x55a027533af0 strong(snapshot)=3
worker 2: tid=ThreadId(4) handle at 0x7fe28edfccf0 heap 0x55a027533af0 strong(snapshot)=2
worker 3: tid=ThreadId(5) handle at 0x7fe28ebfbcf0 heap 0x55a027533af0 strong(snapshot)=2
strong after all workers joined = 1
dropping the last handle now:
Config::drop ran for prod-eu-west-1
There is a lot in there.
Each thread's handle lives on its own stack; the value lives in one shared place. The stack addresses are 0x7fe28effdcf0, 0x7fe28f1fecf0, 0x7fe28edfccf0, 0x7fe28ebfbcf0 — spread about 2 MiB apart, which is exactly the per-thread stack spacing article 1 measured. The heap address is 0x55a027533af0 for all four. Four private handles, one shared allocation.
The workers did not run in order. Worker 1 printed first. Nothing schedules threads in spawn order — the kernel picks, as article 1's PSR column showed.
The counts are 4, 3, 2, 2 — and this is the important one. They are not "wrong". Each is a truthful reading of the count at the instant that thread looked, while other threads were concurrently dropping their handles. Two threads happened to read 2.
This is the first genuinely concurrent lesson of the article: Arc::strong_count is a snapshot, not a fact. By the time the value reaches your variable it may already be stale. It is useful in tests and while debugging; any logic of the form if Arc::strong_count(&x) == 1 { ...assume exclusive... } is a race. (Rust gives you Arc::get_mut and Arc::try_unwrap for that, which do the check atomically.)
The last drop is still exactly one drop. Four threads incremented, four decremented as they exited, main dropped the last handle, and Config::drop ran once. No coordination, no locking, no leader election — just a counter that every participant agrees on.
That is a distributed-systems idea running inside a single process: no node knows who is last, but the shared counter means exactly one of them finds out.
Experiment 3 — What Is Actually in That Allocation?
We have proved there is one allocation and that a number inside it tracks owners. Now let's look at it directly.
The counters are private fields, but the standard library declares the header with #[repr(C)], which fixes the field order. That means we can compute where they are from the data pointer:
#[repr(C)]
struct FakeArcInner<T> { strong: AtomicUsize, weak: AtomicUsize, data: T }
fn counts<T>(a: &Arc<T>) -> (usize, usize) {
unsafe {
let data: *const T = Arc::as_ptr(a);
let inner = (data as *const u8).sub(16) as *const FakeArcInner<()>;
(
(*inner).strong.load(Ordering::Relaxed),
(*inner).weak.load(Ordering::Relaxed),
)
}
}
To be clear: this is layout-dependent pointer arithmetic that depends on an implementation detail. It is fine for an experiment and has no place in real code. We are doing it because reading the bytes is more convincing than reading the docs.
sizes / alignment
size_of::<Arc<u32>>() = 8
size_of::<Arc<[u8; 4096]>>() = 8
size_of::<Rc<u32>>() = 8
size_of::<&u32>() = 8
size_of::<Arc<[u32]>>() = 16 (unsized: fat pointer)
size_of::<Option<Arc<u32>>>() = 8 (niche optimisation)
size_of::<AtomicUsize>() = 8
align_of::<AtomicUsize>() = 8
one Arc<u32> holding 42
Arc::as_ptr(&a) = 0x560b747d1d70 (points at the DATA)
&a as *const _ = 0x7ffdc796b830 (the Arc handle itself, on the stack)
header starts at = 0x560b747d1d60
strong/weak read from the header = (1, 1)
Arc::strong_count / weak_count = (1, 0)
Four things to take from this.
An Arc<T> is eight bytes — the size of a pointer. Arc<[u8; 4096]> is also eight bytes. The handle does not grow with the payload, because the payload is not in the handle. Moving an Arc moves one machine word.
The one exception in that list is Arc<[u32]> at sixteen bytes. A slice has no fixed length known at compile time, so the pointer has to carry the length alongside the address. Two words instead of one. Rust calls that a fat pointer, and you get one whenever the thing you are pointing at has a size only known at runtime — slices and trait objects, mostly.
Option<Arc<T>> is also eight bytes. The compiler knows an Arc's pointer is never null, so it uses null to represent None. This is called a niche optimisation: the type already had an impossible value lying around, so the enum tag can hide inside it. Option<Arc<T>> costs nothing over Arc<T>, which is why idiomatic Rust uses it freely.
The pointer aims at the data, not at the start of the allocation. as_ptr gives 0x…d70; the header begins 16 bytes earlier at 0x…d60. Dereferencing an Arc therefore needs no arithmetic at all — the pointer is already at the value. The bookkeeping sits behind you. That is a deliberate design choice favouring the common operation.
The raw weak count is 1 while Arc::weak_count() reports 0. This is the one place my mental model was wrong, and it is not a bug. All the strong handles collectively hold a single implicit weak reference. It exists because the value and the memory are freed at two different moments.
When the strong count hits zero, the value is destroyed — its destructor runs. But the memory block cannot go back to the allocator yet, because a Weak might still be pointing at it, and that Weak needs somewhere valid to look when someone calls upgrade(). So the block is only released when the weak count hits zero too. The implicit weak reference is what holds the block open for as long as any strong handle exists. Arc::weak_count subtracts that implicit one so the number matches the count of Weaks you created.
Watching both counters move confirms it:
after two clones
Arc::as_ptr(&a) = 0x560b747d1d70
Arc::as_ptr(&b) = 0x560b747d1d70
Arc::as_ptr(&c) = 0x560b747d1d70 <- all three point at the same allocation
header counts = (3, 1)
after one downgrade (Weak)
header counts = (3, 2) (strong, weak)
strong_count=3 weak_count=1
after dropping the two clones
header counts = (1, 2)
after dropping the Weak
header counts = (1, 1)
The standard library's own definition matches the bytes we just read:
#[repr(C, align(2))]
struct ArcInner<T: ?Sized> {
strong: Atomic<usize>,
weak: Atomic<usize>,
data: T,
}
So the diagram from the top of the article was right, with one correction — the weak count starts at 1, not 0:
Heap allocation
┌──────────────────┐
offset 0 │ strong = 3 │
offset 8 │ weak = 1 │ <- the implicit one
offset 16 │ String "hello" │ <- Arc::as_ptr points HERE
└──────────────────┘
Experiment 4 — What Does Arc::new Allocate?
Rather than reason about the size, let's log it.
Rust lets you swap out the global allocator — the thing that actually hands out heap memory. If we replace it with one that prints every allocation and free, we can watch Arc::new do its work:
unsafe impl GlobalAlloc for Logger {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
let p = unsafe { System.alloc(l) };
if LOG.load(Ordering::Relaxed) {
emit(format_args!(" alloc size={:<5} align={:<3} -> {:p}\n",
l.size(), l.align(), p));
}
p
}
// dealloc likewise
}
One trap, which I fell into: the logger must not allocate. My first version used format!, which allocates, which re-enters the logger, which allocates again:
Arc<u8> (payload 1 bytes, align 1)
thread 'main' (2577) has overflowed its stack
fatal runtime error: stack overflow, aborting
That is the guard page from article 1 catching runaway recursion — the same PROT_NONE page, doing the same job. The fix is to format into a fixed stack buffer and call write directly.
With that, here is the real cost of Arc::new. Each block creates an Arc, clones it, drops the clone, then drops the original:
Arc<()>: payload 0 bytes, align 1
alloc size=16 align=8 -> 0x560a0e179d60
free size=16 align=8 <- 0x560a0e179d60
Arc<u32>: payload 4 bytes, align 4
alloc size=24 align=8 -> 0x560a0e179d60
free size=24 align=8 <- 0x560a0e179d60
Arc<[u8; 100]>: payload 100 bytes, align 1
alloc size=120 align=8 -> 0x560a0e179480
free size=120 align=8 <- 0x560a0e179480
Arc<Aligned64>: payload 64 bytes, align 64
alloc size=128 align=64 -> 0x560a0e179d80
free size=128 align=64 <- 0x560a0e179d80
Arc<str> from "hello, arc" (10 bytes, unsized)
alloc size=32 align=8 -> 0x560a0e179ae0
free size=32 align=8 <- 0x560a0e179ae0
Collected:
Arc<T> |
payload | allocated | overhead |
|---|---|---|---|
() |
0 | 16 | 16 |
u8 |
1 | 24 | 23 |
u32 |
4 | 24 | 20 |
u64 |
8 | 24 | 16 |
[u8; 100] |
100 | 120 | 20 |
Aligned64 (align(64)) |
64 | 128 | 64 |
str ("hello, arc") |
10 | 32 | 22 |
Sixteen bytes of header, then the payload, rounded up for alignment. An Arc<u32> allocates 24 bytes to hold 4 — 83% of it is bookkeeping. That is fine for a config object and expensive for a million small nodes. If you find yourself writing Arc<u32> at scale, consider one Arc over a slab of values instead of one Arc per value.
The align(64) row is the one to remember. If you mark a struct #[repr(align(64))] — a common trick to give it its own cache line, which article 8 is about — the data field has to start at offset 64, so the header padding grows from 16 bytes to 64. You quadrupled the per-object overhead. That may well be the right trade. Just know you made it.
Cloning allocates nothing. Every block shows exactly one alloc and one free despite a clone in between. The hypothesis said clone copies a pointer and bumps a number; the allocator log agrees.
Experiment 5 — What Does clone Compile To?
Now the part I actually wanted to see.
A quick note on method, since this is where the article goes properly low-level. Disassembly means taking the compiled binary and printing the machine instructions it contains, which is what objdump does. It is the ground truth: whatever the source says, this is what the CPU will run.
Arc::clone is generic and normally gets inlined into its caller, which leaves no separate function to look at. So we force one:
#[unsafe(no_mangle)]
#[inline(never)]
pub extern "C" fn arc_clone(a: &Arc<u64>) -> Arc<u64> { Arc::clone(a) }
#[unsafe(no_mangle)]
#[inline(never)]
pub extern "C" fn rc_clone(a: &Rc<u64>) -> Rc<u64> { Rc::clone(a) }
Compile with optimisations and disassemble:
$ rustc -O -C panic=abort disasm.rs -o disasm
$ objdump -d --no-show-raw-insn -M intel disasm
0000000000013e80 <arc_clone>:
13e80: mov rax,QWORD PTR [rdi]
13e83: lock inc QWORD PTR [rax]
13e87: jle 13e8a <arc_clone+0xa>
13e89: ret
13e8a: ud2
0000000000013eb0 <rc_clone>:
13eb0: mov rax,QWORD PTR [rdi]
13eb3: inc QWORD PTR [rax]
13eb6: je 13eb9 <rc_clone+0x9>
13eb8: ret
13eb9: ud2
Line them up:
Rc::clone inc QWORD PTR [rax]
Arc::clone lock inc QWORD PTR [rax]
The entire difference between Rc and Arc is the four-letter lock prefix.
Same instruction. Same operand. Same three-instruction function. Rc is not a simpler algorithm — it is the same algorithm with the atomicity removed. Everything written about Arc being "the thread-safe one", and that whole Send compile error from earlier, reduces on x86-64 to one prefix byte.
Reading the instructions one at a time:
-
mov rax, QWORD PTR [rdi]— load the pointer out of theArchandle.rdiholds the function's first argument. -
lock inc QWORD PTR [rax]— atomically add one to the value at that address. That address is the strong count, at offset 0 of the header, exactly where experiment 3 found it. Thelockprefix tells the CPU to make this read-modify-write indivisible with respect to other cores. -
jle/ud2— the overflow guard.ud2is an undefined instruction: executing it crashes the process immediately. If the count ever exceededisize::MAX, wrapping around toward zero would free memory that other threads still hold, so the library chooses to abort instead.
From the source:
const MAX_REFCOUNT: usize = (isize::MAX) as usize;
Note it is isize::MAX, not usize::MAX. Half the range is deliberately given away so the check can be a single cheap signed comparison — the jle above. You cannot hit this by accident; you could hit it with mem::forget in a loop.
And drop:
0000000000013e90 <arc_drop>:
13e90: mov rax,QWORD PTR [rdi]
13e93: lock dec QWORD PTR [rax]
13e97: jne 13ea1 <arc_drop+0x11>
13e99: mov rdi,QWORD PTR [rdi]
13e9c: jmp 13d30 <...Arc$LT$T$C$A$GT$9drop_slow...>
13ea1: ret
Four instructions in the common case. Decrement; if the result is not zero, return. Only when it reaches zero does it jump to drop_slow, which runs the value's destructor and releases the memory.
That structure — tiny hot path, expensive path moved out of line — is what you would hand-write if you were implementing this yourself in C. The compiler produced it for you.
So the full picture of a clone:
Arc::clone(&a)
↓
load the pointer (1 instruction)
↓
lock inc the strong count (1 instruction, atomic)
↓
check for overflow, return (2 instructions)
Three or four instructions, no branches taken, no function calls, no allocation. On paper, nearly free. The rest of this article is about why "on paper" is doing a lot of work in that sentence.
Experiment 6 — The Memory Orderings
Here is something that always looked arbitrary to me. Increment and decrement are mirror images, but Arc treats them differently:
-
cloneincrements withRelaxedordering. -
dropdecrements withReleaseordering, then performs anAcquirefence.
What is a memory ordering? Modern CPUs and compilers reorder memory operations for speed. An ordering is a constraint you attach to an atomic operation telling them what they may not reorder around it. Three of them matter here:
-
Relaxed— no constraint at all. Just make the arithmetic atomic and nothing else. -
Release— "everything I did before this point must be visible to whoever observes this operation." -
Acquire— the matching half. "Everything the other side did before releasing is now visible to me."
Release and Acquire come in pairs. One thread publishes, another thread picks up. Neither is useful alone.
Crucially, ordering is not about the count being correct — atomicity handles that. It is about the data the count protects.
The standard library explains the increment:
"Using a relaxed ordering is alright here, as knowledge of the original reference prevents other threads from erroneously deleting the object... Increasing the reference counter can always be done with
memory_order_relaxed: New references to an object can only be formed from an existing reference, and passing an existing reference from one thread to another must already provide any required synchronization."
let old_size = self.inner().strong.fetch_add(1, Relaxed);
The argument is worth restating slowly, because it is genuinely subtle. To clone an Arc, you must already hold one. However you got it — a channel, a Mutex, a thread::spawn closure — that handover already established the necessary ordering. The increment therefore publishes nothing new that another thread must observe in a particular order. So it can be as weak as an atomic gets.
Dropping is not like that. When you drop, you are announcing "I am finished touching this value" to whichever thread happens to drop last — because that thread is going to run the destructor. Everything you did to the value must be visible to it before it starts tearing the value down. That is precisely a release. And the thread that sees the count hit zero needs the matching acquire before it may safely destroy anything:
#[cfg(not(sanitize = "thread"))]
macro_rules! acquire {
($x:expr) => {
atomic::fence(Acquire)
};
}
#[cfg(sanitize = "thread")]
macro_rules! acquire {
($x:expr) => {
$x.load(Acquire)
};
}
(A nice detail: the thread-sanitizer build uses a load instead of a fence, because TSan models fences poorly and would report false positives. The ordering is chosen partly for the benefit of a debugging tool.)
So what do these orderings cost? I isolated the two operations into a tiny #![no_std] crate so I could see the generated code without the rest of Arc around it:
#[unsafe(no_mangle)]
pub fn clone_op(strong: &AtomicUsize) -> usize {
strong.fetch_add(1, Ordering::Relaxed)
}
#[unsafe(no_mangle)]
pub fn drop_op(strong: &AtomicUsize) -> bool {
if strong.fetch_sub(1, Ordering::Release) != 1 { return false; }
fence(Ordering::Acquire);
true
}
On x86-64:
clone_op:
movl $1, %eax
lock xaddq %rax, (%rdi)
retq
drop_op:
lock decq (%rdi)
sete %al
jne .LBB1_2
#MEMBARRIER
.LBB1_2:
retq
Look closely at #MEMBARRIER. That is an assembler comment. The Acquire fence generates zero instructions.
That is not the compiler ignoring you. x86-64 has a strongly ordered memory model (often called TSO, for Total Store Order): the hardware already refuses to reorder loads with loads or stores with stores, and a lock-prefixed read-modify-write is already a full barrier. The fence is genuinely redundant on this architecture, so it costs nothing.
Compare against the strictest possible version to confirm:
drop_op_seqcst:
lock decq (%rdi)
sete %al
retq
SeqCst and Release-plus-fence emit the same real work here.
An honest caveat. This tells you something about x86-64, not about orderings in general. On a weakly ordered architecture — AArch64, RISC-V, POWER — Relaxed, Release and SeqCst compile to genuinely different instructions with genuinely different costs, and the Acquire fence becomes a real barrier. I wanted to show that contrast with actual output, but this machine has only the x86-64 standard library installed and I could not reach the toolchain server to add an AArch64 target. Rather than paste assembly I did not generate, I am marking it unmeasured. If you are on an ARM Mac, rustc -O --emit asm --target aarch64-apple-darwin on that snippet will show you what I could not.
What this does establish: on x86-64, Arc's careful ordering choices are free. The cost of Arc is not the orderings. It is the lock prefix — and specifically, what that prefix does to the other cores.
Experiment 7 — What Does It Cost?
Single thread, nothing contended, 20 million iterations of each operation:
single thread, uncontended, 20000000 iterations each
*arc (deref) 0.44 ns/op
Arc::strong_count (load) 0.56 ns/op
Rc::clone + drop 4.60 ns/op
Arc::clone + drop 12.89 ns/op
Arc::downgrade + drop Weak 17.96 ns/op
Weak::upgrade + drop Arc 18.01 ns/op
Reading through an Arc costs 0.44 ns — one pointer dereference, no atomic, effectively free. Cloning and dropping costs 12.89 ns, about 29x more than reading. And Arc is 2.8x the cost of Rc for the identical operation. That factor of 2.8 is the price of one lock prefix.
The ratios are the useful part. Thirteen nanoseconds is not much in absolute terms — article 1 measured thread creation at roughly 37 *micro*seconds, three thousand times more. But it is a lot compared with the thing people assume it is comparable to. Arc::clone is not "basically free, like a borrow". It is thirty times a borrow.
And that is the uncontended number.
Stressing the Design — More Than One Core
Everything so far was one thread. Now the question that decides whether your server scales: what happens when several threads clone the same Arc?
Three cases. clone private: each thread has its own separate Arc, so each touches its own counter. clone shared: all threads clone one Arc, hammering one counter. deref shared: all threads read through one shared Arc without cloning.
median of 5 runs, 6000000 clone+drop pairs per thread per run
threads clone private clone shared ratio deref shared
1 12.91 ns 12.93 ns 1.0x 0.64 ns
2 13.39 ns 60.76 ns 4.5x 0.62 ns
4 27.90 ns 119.73 ns 4.3x 1.54 ns
8 53.03 ns 252.25 ns 4.8x 1.35 ns
Three findings, and the middle one is the point of the article.
Sharing the counter costs about 4.5x
At two threads, a clone goes from 13.4 ns to 60.8 ns. The instruction did not change. What changed is the cache line.
A cache line is the unit in which CPUs move memory — 64 bytes on this machine (getconf LEVEL1_DCACHE_LINESIZE says so). Cores do not fetch individual bytes; they fetch lines into their own private caches. To write to a line, a core must hold it exclusively — no other core may have a copy.
So when two cores both want to lock inc the same counter, the line containing it must bounce between them. Core 0 takes exclusive ownership, increments, then core 1 must take it away, and so on. Neither core can proceed while the other holds it.
Core 0 cache Core 1 cache
┌──────────────┐ ┌──────────────┐
│ strong count │ ◄────► │ strong count │
└──────────────┘ └──────────────┘
▲ ▲
└── the line ping-pongs ─┘
The atomic operation is fast. Acquiring the line is not. This is cache coherence showing up in your latency numbers, and it is why "just wrap it in an Arc" can quietly put a ceiling on how well a hot path scales.
Read-only sharing is free
The deref shared column stays under 2 ns at every thread count. Many cores may hold the same line simultaneously as long as they are only reading — coherence only forces exclusivity for writes.
So sharing immutable data across threads costs nothing. Sharing a counter costs a great deal. The awkward part is that Arc makes you do the second in order to get the first.
The clone private column rises too — and that is article 1's fault
This machine has 2 cores. At 4 and 8 threads the private numbers roughly double and quadruple: 12.9, 13.4, 27.9, 53.0. That is not atomics. That is exactly the oversubscription curve from article 1 — throughput holds, per-unit latency degrades in proportion, because there are more runnable threads than cores.
Which is why the ratio column exists. Dividing shared by private cancels the scheduling effect and isolates the contention.
The Mistake That Shows Up in Production
Here is a shape I have written myself more than once. Some shared thing every request needs — config, a routing table, a connection pool, a compiled regex set — behind an Arc. Then, inside the request loop:
let per_msg = Arc::clone(&cfg); // the mistake
acc = acc.wrapping_add(handle(&per_msg, i));
instead of simply borrowing what you already own:
acc = acc.wrapping_add(handle(&cfg, i));
Both compile. Both are correct. One of them makes every worker thread write to the same cache line on every single message.
3000000 messages per thread, shared Arc<Config>
threads clone per message borrow the Arc overhead
1 12.60 ns 0.81 ns 15.6x
2 80.79 ns 0.64 ns 126.1x
4 165.67 ns 1.16 ns 142.8x
At one thread the unnecessary clone costs 16x. At two threads, 126x. At four, 143x.
Look at how the two columns behave differently. The borrow column is flat — it does not care how many threads are running, because reading shared immutable data scales perfectly. The clone column gets dramatically worse with concurrency.
The penalty for this mistake grows with the number of cores you add. You will not find it on a laptop running a single-threaded test. You will find it when you deploy to a 32-core box and discover that scaling stops at four.
The fix is not to avoid Arc. It is to clone at the right granularity: once per task, not once per message.
for _ in 0..workers {
let cfg = Arc::clone(&cfg); // one atomic increment, once
thread::spawn(move || {
for msg in inbox {
handle(&cfg, msg); // borrow inside the loop
}
});
}
One increment per thread instead of one per message. That is the difference between the two columns above.
A habit that falls out of this: write functions that take &T, not Arc<T>. If a function does not need to keep the value after it returns, it does not need a reference count — it needs a borrow. Putting Arc<T> in a signature is an instruction to every caller to perform an atomic read-modify-write, and most of them did not need to.
Weak, Cycles, and the Leak Rust Does Not Prevent
Reference counting has one classic failure mode, and Rust does not save you from it.
If two objects hold Arcs to each other, they keep each other alive forever:
Parent ──Arc──► Child
▲ │
└─────Arc──────┘
Neither count can reach zero, because each is held up by the other.
struct Node { name: &'static str, peer: Mutex<Option<Arc<Node>>> }
impl Drop for Node {
fn drop(&mut self) { println!(" Drop::drop ran for {}", self.name); }
}
two nodes pointing at each other with Arc:
strong_count(a) = 2, strong_count(b) = 2
...leaving the scope now
(nothing printed above? then neither destructor ran)
Nothing printed. Both handles went out of scope, both counts fell from 2 to 1, neither reached zero, neither destructor ran, and that memory is gone for the life of the process. No unsafe, no warning, no panic.
Rust's safety guarantees do not include "no leaks". Leaking is safe. It is just not what you wanted.
Weak<T> is the fix. A Weak points at the allocation without owning the value — it increments the weak count, not the strong one, so it cannot keep the value alive. Make the back-edge weak and the cycle breaks:
same shape, but the back-edge is a Weak:
strong_count(a) = 1, weak_count(a) = 1
...leaving the scope now
Drop::drop ran for B
Drop::drop ran for A
And a Weak tells you honestly when the thing is gone:
what a Weak sees after the last strong reference goes:
while alive: w.upgrade() = Some(99)
after drop: w.upgrade() = None
strong_count via Weak = 0
upgrade() is the entire point of Weak: a fallible promotion back to an Arc that returns None rather than handing you a dangling pointer.
That check is not free. From the cost table, Weak::upgrade plus dropping the resulting Arc costs 18.01 ns against 12.89 ns for a plain clone — because upgrade cannot be a blind increment. It has to atomically check that the strong count is non-zero and increment it in one indivisible step, or it would lose the race against a concurrent final drop.
Where this matters: parent/child trees, observer registries, caches holding handles to objects they must not keep alive, and graphs generally. If your object graph can contain a cycle, one direction must be Weak.
What I Could Not Measure Here
Two gaps, stated rather than papered over.
AArch64 code generation. As above — no cross-target standard library, no reachable toolchain server. "The orderings are free" is a claim about x86-64 only.
Strong/weak false sharing. The two counters sit at offsets 0 and 8, which I confirmed are inside the same 64-byte line:
strong at offset 0 -> 0x55c8d6339d60
weak at offset 8 -> 0x55c8d6339d68
64-byte line of strong = 0x55c8d6339d40
64-byte line of weak = 0x55c8d6339d40
same cache line? true
In principle, then, a thread doing downgrade/upgrade should interfere with a thread doing clone, even though they touch different words — that is what false sharing means. I built that benchmark and could not interpret the result.
The problem: downgrade is simply a more expensive operation than clone — 18 ns against 13 ns even with no contention at all. My benchmark reported the slower of the two threads, so it was mostly measuring that difference rather than any interference between them. I could publish the numbers, but they would not mean what the heading claimed.
Article 8 is about false sharing specifically and will do this properly, with matched operations. I would rather this article be shorter and correct.
Production Connection
API servers and gRPC services. Arc<AppState> is the standard pattern and it is a good one — until the state is cloned per request rather than per connection or per worker. The 126x figure above is what that costs at two threads. Clone at task boundaries; borrow inside them.
Databases, caches, vector stores. Arc<[u8]> or Arc<str> for shared immutable pages, embeddings, or interned keys is excellent: reads scale perfectly and the payload is never copied. But mind the allocation table — 16 bytes of header means Arc per small object is heavy. Prefer one Arc over a slab of values rather than an Arc around each value.
Message brokers and Kafka-style consumers. Passing Arc<Message> down a pipeline is right — no copying regardless of payload size. Watch the fan-out: if N consumers each clone the same Arc per message, that is N atomic RMWs on one cache line per message, and article 1's backpressure chain gains a new first link.
Low-latency and trading systems. 13 ns uncontended, 61 ns contended, and the contended figure is variable because it depends on which core last owned the line. If your tail-latency budget is in the hundreds of nanoseconds, a contended Arc clone on the hot path is both a cost and a variance source. Clone once at setup; pass &T thereafter.
Blockchain nodes and AI infrastructure. Both are the same shape: one large immutable thing — a state snapshot, a loaded model, a mempool view — read by many workers. That is Arc at its best, provided the clone count is proportional to workers, not to operations.
Everywhere. If your object graph can contain a cycle, one direction must be Weak.
Conclusion
The hypothesis mostly held. Arc is a pointer to a heap header of two counters plus the value; clone bumps a count without touching the data; the last drop frees. What the investigation added:
-
An
Arc<T>is 8 bytes, and so isOption<Arc<T>>. The pointer aims at the data; the counters live 16 bytes behind it. -
The header is 16 bytes — 24 allocated for a
u32, so 83% overhead. Analign(64)payload pushes header overhead to 64 bytes. -
Arc::cloneandRc::clonediffer by exactly one thing:lock incversusinc. Three instructions each, plus an overflow guard that aborts atisize::MAX. -
Arc::dropis four instructions in the common case. Destroying the value and freeing the memory happen in a separate function it jumps to only when the count hits zero. -
On x86-64 the memory orderings are free. The
Acquirefence emits#MEMBARRIER— a comment, not an instruction. The orderings still matter; they just do not cost anything on this architecture. -
Uncontended: 12.89 ns to clone and drop — 2.8x an
Rc, and 29x a plain deref at 0.44 ns. - Contended: about 4.5x worse, and the penalty persists as threads increase. Read-only sharing, by contrast, costs nothing at any thread count.
-
Arc::strong_countis a snapshot, not a fact. Four threads read 4, 3, 2, 2 from the same counter. - Cloning per message instead of per task cost 126x at two threads, and got worse with more cores — the worst possible property for a bug to have.
-
Cycles of
Arcleak silently. Safe, warning-free, permanent.
The thing I keep returning to is that lock prefix. One prefix on one instruction is the entire difference between the single-threaded and the multi-threaded reference count. And the reason it is expensive has nothing to do with the instruction itself — it is that a cache line can only be owned by one core at a time.
Arc does not really cost you an atomic. It costs you exclusive ownership of a cache line, on every clone, contested with every other thread doing the same.
Next
Article 3: Mutex vs RwLock in Rust — Benchmarking Real Contention. We can now share ownership across threads, but everything shared so far has been immutable. The moment we want to mutate shared state we need mutual exclusion — and the two obvious choices behave very differently under load.
Note where Arc<Mutex<T>> puts things: the lock sits in the same allocation as the reference count we just measured, quite possibly in the same cache line. The contention effects from this article do not go away when you add a lock. They compound.
Then article 4 follows a blocked Mutex into the kernel, and lands back on the same futex syscall article 1 found inside join().
Appendix: Environment and Reproducibility
kernel: 6.18.44-fc-v21 (x86_64)
distro: Ubuntu 24.04.4 LTS
rustc: 1.95.0 (59807616e 2026-04-14)
glibc: 2.39-0ubuntu8.7
objdump: GNU binutils 2.42
cpu: Intel(R) Xeon(R) Processor @ 2.80GHz, 2 cores
cache line: 64 bytes (getconf LEVEL1_DCACHE_LINESIZE)
On comparing with article 1: this is not the same host. Article 1 ran on a machine reporting 2.10 GHz; this one reports 2.80 GHz, and the container migrated mid-session. Every timing in this article was re-run on the machine above so the numbers are internally consistent. Do not compare absolute nanoseconds across the two articles — ratios are comparable, raw times are not.
| File | Purpose |
|---|---|
exp1/ |
the cargo project from experiment 1 |
threads_arc.rs |
the same Arc across four OS threads |
threads_rc.rs |
the Rc version, for the Send compile error |
layout.rs |
reads the strong/weak header directly; sizes and pointer identity |
alloc_log.rs |
logging #[global_allocator]; real Arc::new allocation sizes |
disasm.rs |
#[no_mangle] wrappers for arc_clone / arc_drop / rc_clone / rc_drop |
orderings.rs |
#![no_std] isolation of the two atomic ops, for --emit asm
|
ops.rs |
per-operation cost table |
bench3.rs |
contention: private vs shared vs read-only, median of 5 |
realistic.rs |
clone-per-message vs borrow, across thread counts |
cycle.rs |
Arc cycle leak, Weak back-edge, upgrade() after drop |
addrs.rs |
cache-line placement of the two counters |
Commands:
$ cargo run # experiment 1
$ rustc -O threads_arc.rs -o threads_arc && ./threads_arc
$ rustc -O -C panic=abort disasm.rs -o disasm
$ objdump -d --no-show-raw-insn -M intel disasm | awk '/<arc_clone>:/,/^$/'
$ rustc -O --emit asm --target x86_64-unknown-linux-gnu orderings.rs -o -
$ MALLOC_ARENA_MAX=1 ./ops
$ MALLOC_ARENA_MAX=1 ./bench3
$ MALLOC_ARENA_MAX=1 ./realistic
MALLOC_ARENA_MAX=1 is inherited from article 1: glibc otherwise gives each thread its own malloc arena, which distorts memory accounting. It does not affect the timings here, but it keeps the runs comparable.
Caveats:
- Two cores. The contention numbers would likely be worse on a bigger machine. More cores means more competition for the line, and on a multi-socket server the two cores fighting over it may be on physically separate chips, which makes each transfer much more expensive.
- Virtualised. Timing under a hypervisor is noisier than bare metal, which is why the contention table is a median of five runs.
-
x86-64 only. Everything about
lockprefixes and free fences is specific to this memory model. -
The single-thread table is one run. Re-running
ops.rsmoves figures by roughly ±1% (Arc::clonecame out at 12.89 and 12.99 ns on consecutive runs). The contention table is a median because it is far noisier. -
Layout poking is not an API. The
sub(16)arithmetic works becauseArcInnerisrepr(C)and I checked the source. It is fine for an experiment and wrong for real code. - Addresses vary per run. The pointer values in different sections come from different runs of different programs, so they will not match each other — only the relationships within a single output block are meaningful.
Thanks for Reading
If you got this far — thank you.
Article 1 ended with me admitting I had the cost model backwards: I assumed thread creation was the expensive part, and the measurements said blocking was. This one had a smaller version of the same thing. I expected Arc's memory orderings to be where the cost lived, because that is the part everyone writes about. On x86-64 they are free, and the cost turned out to be somewhere much less glamorous: one cache line, and which core owns it.
I also had the weak count wrong, and only found out by reading the bytes.
If you spot something wrong here, I would much rather hear it now than leave it standing while eight more articles get built on top of it. And if you run the contention table on a machine with real core counts, I would like to see it — 4.5x on two cores is almost certainly the friendly version.
Next up — Article 3: Mutex vs RwLock in Rust: Benchmarking Real Contention.
- GitHub: GITHUB_URL
- LinkedIn: LINKEDIN_URL
Top comments (0)