Craftznake

Arc and Mutex

Jun 20, 2026

Me Note:

This post is my definitive take to rewinding and solidifying how Arc and Mutex operate under the hood, drawing from my own engineering experiences and perspective.

When writing concurrent software in Rust, we will inevitably encounter the Arc and Mutex types. While Mutex is a familiar primitive across many programming languages, Arc is a distinct concept that often requires a fresh mental model. Crucially, neither type can be fully understood without anchoring them directly to Rust's foundational ownership model.

When it comes to sharing data across concurrent boundaries, we, as developers generally choose between two paradigms: shared memory or message passing.

While message passing (e.g., using channel, like Go channel) is frequently believed as the idiomatic approach to concurrency, the safety delta between the two models shrinks significantly in Rust. Because the compiler mathematically guarantees that we cannot encounter a data race in safe Rust, to me, personally, the choice between channels and shared memory shifts from a question of safety to a question of architectural convenience.

However, if we choose the shared-memory route, we will quickly find wer code constrained by the borrow checker unless we leverage Arc and Mutex.

  • Arc is an atomically reference-counted smart pointer designed to safely share read-only values across thread boundaries.
  • Mutex is a synchronization wrapper that grants exclusive, safe mutability of an underlying type across threads.

To understand why these primitives are non-negotiable, we must first revisit the core rules of ownership.


The Core Laws of Ownership

At its absolute baseline, Rust’s memory safety guarantees rest on three invariant rules:

  1. Exclusive Ownership: Every value has exactly one owner at any given time.
  2. Aliased Read-Only Access: We can have an arbitrary number of immutable references (&T) to a value.
  3. Exclusive Mutable Access: We can have exactly one mutable reference (&mut T) to a value, completely isolating it from other reads or writes.

To see how these laws govern multi-threaded environments, consider a simple scenario: a User struct with an owned String field representing a username, where we spawn a background thread to print a message.

use std::thread::spawn;
#[derive(Debug)]
struct User {
name: String,
}
fn main() {
let user = User { name: "craftznake".to_string() };
spawn(move || {
println!("Hello from the foo: {}", user.name);
}).join().unwrap();
}

Nothing wrong with this implementation, the program compiles and prints error. Just incase someone don't know what does move mean, it mean we move the data used inside the thread to the thread, which change the ownership of the data, including the user instance to the thread. Now, imaging that we need to add another thread that also want to access the user.

use std::thread::spawn;
#[derive(Debug)]
struct User {
name: String,
}
fn main() {
let user = User { name: "craftznake".to_string() };
let thread_1 = spawn(move || {
println!("Hello from the foo: {}", user.name);
});
let thread_2 = spawn(move || {
println!("Hello from the bar: {}", user.name);
});
thread_1.join.unwrap();
thread_2.join.unwrap();
}

With this code, we will see something similar

error[E0382]: use of moved value: `user.name`
--> src/main.rs:15:20
...

What does this mean? The error reads "use of moved value user.name". We first move the value to the first thread in thread_1 and then we try to do the same thing with the second thread in thread_2. If we look at the ownership rules above, this shouldn't be surprising. A value can have only one owner. With the current version of the code, we need to "move" the value to the first thread if we want to use it, and thus we can't move it to the other thread. It already changed ownership. Someone might think: if we don't mutate the data, then why not just use multiple shared references? Let's give that a shot.

use std::thread::spawn;
#[derive(Debug)]
struct User {
name: String,
}
fn main() {
let user = User { name: "craftznake".to_string() };
let thread_1 = spawn(|| {
println!("Hello from the foo: {}", &user.name);
});
let thread_2 = spawn(|| {
println!("Hello from the bar: {}", &user.name);
});
thread_1.join.unwrap();
thread_2.join.unwrap();
}

So now, instead of move the user instance into the thread, how about we amde the threads borrow the user value immutably, or in other words, get a shared ref. With this, we get the following:

error[E0373]: closure may outlive the current function, but it borrows `user.name`, which is owned by the current function
--> src/main.rs:15:20

Now, the error says that the closure might outlive the func, which mean, the Rust compiler can't guarantee that the closure in the thread will finish before the main func finished. Threads are borrowing the data, but it's still explicitly owned by the main func. In this case, if the main func finishes, the user struct goes out of scope and the memory is dropped. Imagine that, if it was allowed to share the value with threads in that manner, there might be a scenario when a thread is trying to read freed memory, which is undefined behavior, and Rust certaintly doesn't want that. We might imagine, the above implementation makes sense at some point, as long as us, developer could gurantee our data model, and we will ensure everything work in the controlled manner, but that isn't a case to the compiler. Instead of writing the compiler that accepts all possible programming styles, which is indeed impossible at some points, writing a compiler that will reject all invalid program at a cost of being overly strict is something considered easier. We have a way to works exactly with above case, by using scoped threads, but that will not be discussed closely in this writing, just to save someone some time.

Arc to the picture

In case someone don't know, Arc stands for "atomic ref counter". It is a smart pointer enabling sharing data between threads. The way it work, in high level, is wrap a value we're trying to share and act as apointer to it, specifically, it keeps track of all of the copies of the pointer and as soon as the last pointer goes out of scope, it can safely free the memory. Our problem, in the previous section, could be cleanly resolved using this:

use std::thread::spawn;
#[derive(Debug)]
struct User {
name: String,
}
fn main() {
let user_arc = Arc::new(User { name: "craftznake".to_string() });
let user_1 = user_arc.clone();
let thread_1 = spawn(move || {
println!("Hello from the foo: {}", user_1.name);
});
let user_2 = user_arc.clone();
let thread_2 = spawn(|| {
println!("Hello from the bar: {}", user_2.name);
});
thread_1.join.unwrap();
thread_2.join.unwrap();
}

This time, we will have three pointers to the user value, one created when an Arc is created, one created by cloning user_1 before moving it to the first thread, and one created by cloning user_2 and move it to the second thread. As long as any of these pointers is alive, Rust will not free the memory holds by user struct.

Deep Dive: The Mechanics of Send and Sync

The simple examples above only scratch the surface of how Rust handles multi-threaded safety. To understand how Arc works its magic, we need to look under the hood at its trait bounds. If we look at the standard library implementation of Arc, we will notice that it implements the Send and Sync traits. To understand why it does this—and why it matters—we must look at the definitions of these two core markers. As defined in the Rustonomicon:

  • Send: A type is Send if it is safe to transfer ownership of it to another thread.
  • Sync: A type is Sync if it is safe to share references of it between multiple threads simultaneously. Formally, a type T is Sync if and only if a shared reference to it, &T, is Send. Conceptually, both Send and Sync act as marker traits. They do not contain any methods, nor do they require we to implement any custom logic. Instead, they serve as explicit compile-time flags to notify the compiler about our type's thread-safety characteristics. By default, Send and Sync are automatically derived (auto-traits). This means that if every single field within our custom struct or enum implements Send, our entire type is automatically marked as Send by the compiler. Conversely, if we need to explicitly prevent a type from being moved across threads, Rust allows we to opt out. While the language typically uses internal non-Send primitives to do this automatically, we can explicitly flag a type as non-Send using the experimental negative_impls feature. For example, the following code will fail to compile:
#![feature(negative_impls)]
#[derive(Debug)]
struct User {}
impl !Send for User {}
fn main() {
let user = User {};
spawn(move || {
print("{user:#}");
});
}

Modifying data with Mutex

We've gone through some examples and concept regarding to shared read/ref. But we've not address the question, how about concurrent write. It's when Mutex comes into picture. Mutex in many languages are treated like semaphores. We create mutex object and we can guard a certain piece of code with the mutex is only accisble from one thread at a time.

use std::time::Duration;
use std::{thread, thread::sleep};
use std::sync::{Arc, Mutex};
struct User {
name: String
}
fn main() {
let user_original = Arc::new(Mutex::new(User { name: String::from("craftznake") }));
let user = user_original.clone();
let t1 = thread::spawn(move || {
let mut locked_user = user.lock().unwrap();
locked_user.name = String::from("another name");
// after locked_user goes out of scope, mutex will be unlocked again,
// but we can also explicitly unlock it with:
// drop(locked_user);
});
let user = user_original.clone();
let t2 = thread::spawn(move || {
sleep(Duration::from_millis(10));
// it will print: Hello another name
println!("Hello {}", user.lock().unwrap().name);
});
t1.join().unwrap();
t2.join().unwrap();
}

Let's go over it. in the first line of the main() function we create an instance of the User struct and we wrap it with a Mutex and an Arc. With an Arc we can easily clone the pointer and thus share the mutex between threads. In the 1st thread we can see the mutex is locked and since that moment the underlying value can be used exclusively by this thread. Then we modify the value in the next line. The mutex is unlocked once the locked guard goes out of scope or we manually drop it with drop(locked_user).

In the second thread we wait 10ms and print the name, which should be the name updated in the first thread. This time locking is done in one line, so the mutex will be dropped in the same statement.

Related Articles

  • Mutex: The rust doc about Mutex
  • The Rusttonomicon: Every awful details that we need to understand when writing Unsafe Rust program.
© 2026 Craftznake.

Craftznake

Arc and Mutex

Jun 20, 2026

Me Note:

This post is my definitive take to rewinding and solidifying how Arc and Mutex operate under the hood, drawing from my own engineering experiences and perspective.

When writing concurrent software in Rust, we will inevitably encounter the Arc and Mutex types. While Mutex is a familiar primitive across many programming languages, Arc is a distinct concept that often requires a fresh mental model. Crucially, neither type can be fully understood without anchoring them directly to Rust's foundational ownership model.

When it comes to sharing data across concurrent boundaries, we, as developers generally choose between two paradigms: shared memory or message passing.

While message passing (e.g., using channel, like Go channel) is frequently believed as the idiomatic approach to concurrency, the safety delta between the two models shrinks significantly in Rust. Because the compiler mathematically guarantees that we cannot encounter a data race in safe Rust, to me, personally, the choice between channels and shared memory shifts from a question of safety to a question of architectural convenience.

However, if we choose the shared-memory route, we will quickly find wer code constrained by the borrow checker unless we leverage Arc and Mutex.

  • Arc is an atomically reference-counted smart pointer designed to safely share read-only values across thread boundaries.
  • Mutex is a synchronization wrapper that grants exclusive, safe mutability of an underlying type across threads.

To understand why these primitives are non-negotiable, we must first revisit the core rules of ownership.


The Core Laws of Ownership

At its absolute baseline, Rust’s memory safety guarantees rest on three invariant rules:

  1. Exclusive Ownership: Every value has exactly one owner at any given time.
  2. Aliased Read-Only Access: We can have an arbitrary number of immutable references (&T) to a value.
  3. Exclusive Mutable Access: We can have exactly one mutable reference (&mut T) to a value, completely isolating it from other reads or writes.

To see how these laws govern multi-threaded environments, consider a simple scenario: a User struct with an owned String field representing a username, where we spawn a background thread to print a message.

use std::thread::spawn;
#[derive(Debug)]
struct User {
name: String,
}
fn main() {
let user = User { name: "craftznake".to_string() };
spawn(move || {
println!("Hello from the foo: {}", user.name);
}).join().unwrap();
}

Nothing wrong with this implementation, the program compiles and prints error. Just incase someone don't know what does move mean, it mean we move the data used inside the thread to the thread, which change the ownership of the data, including the user instance to the thread. Now, imaging that we need to add another thread that also want to access the user.

use std::thread::spawn;
#[derive(Debug)]
struct User {
name: String,
}
fn main() {
let user = User { name: "craftznake".to_string() };
let thread_1 = spawn(move || {
println!("Hello from the foo: {}", user.name);
});
let thread_2 = spawn(move || {
println!("Hello from the bar: {}", user.name);
});
thread_1.join.unwrap();
thread_2.join.unwrap();
}

With this code, we will see something similar

error[E0382]: use of moved value: `user.name`
--> src/main.rs:15:20
...

What does this mean? The error reads "use of moved value user.name". We first move the value to the first thread in thread_1 and then we try to do the same thing with the second thread in thread_2. If we look at the ownership rules above, this shouldn't be surprising. A value can have only one owner. With the current version of the code, we need to "move" the value to the first thread if we want to use it, and thus we can't move it to the other thread. It already changed ownership. Someone might think: if we don't mutate the data, then why not just use multiple shared references? Let's give that a shot.

use std::thread::spawn;
#[derive(Debug)]
struct User {
name: String,
}
fn main() {
let user = User { name: "craftznake".to_string() };
let thread_1 = spawn(|| {
println!("Hello from the foo: {}", &user.name);
});
let thread_2 = spawn(|| {
println!("Hello from the bar: {}", &user.name);
});
thread_1.join.unwrap();
thread_2.join.unwrap();
}

So now, instead of move the user instance into the thread, how about we amde the threads borrow the user value immutably, or in other words, get a shared ref. With this, we get the following:

error[E0373]: closure may outlive the current function, but it borrows `user.name`, which is owned by the current function
--> src/main.rs:15:20

Now, the error says that the closure might outlive the func, which mean, the Rust compiler can't guarantee that the closure in the thread will finish before the main func finished. Threads are borrowing the data, but it's still explicitly owned by the main func. In this case, if the main func finishes, the user struct goes out of scope and the memory is dropped. Imagine that, if it was allowed to share the value with threads in that manner, there might be a scenario when a thread is trying to read freed memory, which is undefined behavior, and Rust certaintly doesn't want that. We might imagine, the above implementation makes sense at some point, as long as us, developer could gurantee our data model, and we will ensure everything work in the controlled manner, but that isn't a case to the compiler. Instead of writing the compiler that accepts all possible programming styles, which is indeed impossible at some points, writing a compiler that will reject all invalid program at a cost of being overly strict is something considered easier. We have a way to works exactly with above case, by using scoped threads, but that will not be discussed closely in this writing, just to save someone some time.

Arc to the picture

In case someone don't know, Arc stands for "atomic ref counter". It is a smart pointer enabling sharing data between threads. The way it work, in high level, is wrap a value we're trying to share and act as apointer to it, specifically, it keeps track of all of the copies of the pointer and as soon as the last pointer goes out of scope, it can safely free the memory. Our problem, in the previous section, could be cleanly resolved using this:

use std::thread::spawn;
#[derive(Debug)]
struct User {
name: String,
}
fn main() {
let user_arc = Arc::new(User { name: "craftznake".to_string() });
let user_1 = user_arc.clone();
let thread_1 = spawn(move || {
println!("Hello from the foo: {}", user_1.name);
});
let user_2 = user_arc.clone();
let thread_2 = spawn(|| {
println!("Hello from the bar: {}", user_2.name);
});
thread_1.join.unwrap();
thread_2.join.unwrap();
}

This time, we will have three pointers to the user value, one created when an Arc is created, one created by cloning user_1 before moving it to the first thread, and one created by cloning user_2 and move it to the second thread. As long as any of these pointers is alive, Rust will not free the memory holds by user struct.

Deep Dive: The Mechanics of Send and Sync

The simple examples above only scratch the surface of how Rust handles multi-threaded safety. To understand how Arc works its magic, we need to look under the hood at its trait bounds. If we look at the standard library implementation of Arc, we will notice that it implements the Send and Sync traits. To understand why it does this—and why it matters—we must look at the definitions of these two core markers. As defined in the Rustonomicon:

  • Send: A type is Send if it is safe to transfer ownership of it to another thread.
  • Sync: A type is Sync if it is safe to share references of it between multiple threads simultaneously. Formally, a type T is Sync if and only if a shared reference to it, &T, is Send. Conceptually, both Send and Sync act as marker traits. They do not contain any methods, nor do they require we to implement any custom logic. Instead, they serve as explicit compile-time flags to notify the compiler about our type's thread-safety characteristics. By default, Send and Sync are automatically derived (auto-traits). This means that if every single field within our custom struct or enum implements Send, our entire type is automatically marked as Send by the compiler. Conversely, if we need to explicitly prevent a type from being moved across threads, Rust allows we to opt out. While the language typically uses internal non-Send primitives to do this automatically, we can explicitly flag a type as non-Send using the experimental negative_impls feature. For example, the following code will fail to compile:
#![feature(negative_impls)]
#[derive(Debug)]
struct User {}
impl !Send for User {}
fn main() {
let user = User {};
spawn(move || {
print("{user:#}");
});
}

Modifying data with Mutex

We've gone through some examples and concept regarding to shared read/ref. But we've not address the question, how about concurrent write. It's when Mutex comes into picture. Mutex in many languages are treated like semaphores. We create mutex object and we can guard a certain piece of code with the mutex is only accisble from one thread at a time.

use std::time::Duration;
use std::{thread, thread::sleep};
use std::sync::{Arc, Mutex};
struct User {
name: String
}
fn main() {
let user_original = Arc::new(Mutex::new(User { name: String::from("craftznake") }));
let user = user_original.clone();
let t1 = thread::spawn(move || {
let mut locked_user = user.lock().unwrap();
locked_user.name = String::from("another name");
// after locked_user goes out of scope, mutex will be unlocked again,
// but we can also explicitly unlock it with:
// drop(locked_user);
});
let user = user_original.clone();
let t2 = thread::spawn(move || {
sleep(Duration::from_millis(10));
// it will print: Hello another name
println!("Hello {}", user.lock().unwrap().name);
});
t1.join().unwrap();
t2.join().unwrap();
}

Let's go over it. in the first line of the main() function we create an instance of the User struct and we wrap it with a Mutex and an Arc. With an Arc we can easily clone the pointer and thus share the mutex between threads. In the 1st thread we can see the mutex is locked and since that moment the underlying value can be used exclusively by this thread. Then we modify the value in the next line. The mutex is unlocked once the locked guard goes out of scope or we manually drop it with drop(locked_user).

In the second thread we wait 10ms and print the name, which should be the name updated in the first thread. This time locking is done in one line, so the mutex will be dropped in the same statement.

Related Articles

  • Mutex: The rust doc about Mutex
  • The Rusttonomicon: Every awful details that we need to understand when writing Unsafe Rust program.
© 2026 Craftznake.