Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

10.4 Atomic and Safety

Mutexes and channels are the right tool for complex shared data, but they carry overhead: locking involves at minimum one atomic read-modify-write and potentially a kernel sleep/wake cycle. For simple shared values (e.g., a stop flag, an error counter, a sequence number) atomic types provide a lighter-weight alternative.

Rust’s std::sync::atomic module provides types including AtomicBool, AtomicI32, AtomicU32, AtomicUsize, and AtomicPtr. Each operation on these types is guaranteed to be indivisible at the hardware level, meaning no other thread can observe a partial update. No lock is needed.

Every atomic operation takes a std::sync::atomic::Ordering argument that controls how the CPU and compiler may reorder instructions around the atomic access.

OrderingMeaning
RelaxedAtomic with no ordering guarantees between other operations. Safe for counters that need only eventual consistency.
AcquireOn a load: all subsequent operations see writes that a releasing thread performed before its Release store.
ReleaseOn a store: all preceding operations are visible to threads that subsequently perform an Acquire load on the same atomic.
AcqRelCombines Acquire (on load) and Release (on store). Used for read-modify-write operations like compare_exchange.
SeqCstStrongest: enforces a single global order across all threads. The safest starting point if unsure.

For most edge applications, SeqCst is the safest starting choice. Optimise to weaker orderings only when you have profiled and the semantics are fully clear.

A common pattern on edge devices is a global stop flag that the main thread sets when a shutdown signal arrives (e.g., from a button press, a network command, or a watchdog timeout). Worker threads poll this flag in their loops.

use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::Duration;
fn main() {
let stop_flag = Arc::new(AtomicBool::new(false));
let stop_flag_clone = Arc::clone(&stop_flag);
let worker = thread::spawn(move || {
let mut count = 0;
while !stop_flag_clone.load(Ordering::SeqCst) {
count += 1;
println!("Worker tick {}", count);
thread::sleep(Duration::from_millis(100));
}
println!("Worker stopped after {} ticks", count);
});
// Main thread simulates a shutdown signal after 350 ms
thread::sleep(Duration::from_millis(350));
stop_flag.store(true, Ordering::SeqCst);
println!("Stop signal sent");
worker.join().unwrap();
}

This gives output similar to:

Worker tick 1
Worker tick 2
Worker tick 3
Stop signal sent
Worker stopped after 3 ticks

The AtomicBool is wrapped in Arc so both the main thread and the worker thread can hold a reference to it. Unlike Mutex, there is no lock() call (load and store are single atomic instructions that execute without blocking).

Rust’s compile-time guarantees for concurrency are largely powered by two fundamental marker traits: Send and Sync. These traits don’t have any methods to implement; instead, they act as flags that tell the compiler whether a type is safe to use in concurrent contexts.

A type T is Send if it is safe to transfer ownership of a value of type T from one thread to another.

  • This ensures that moving a value to another thread won’t lead to data corruption or other undefined behaviour.
  • Automatic Implementation: Most primitive types (like integers, booleans, characters) are Send. Collections like Vec<T> and String are Send if their contained types (T) are also Send.
  • The common use when you use move closures with thread::spawn(), the data being moved into the new thread must be Send.

Example: String is Send

use std::thread;
fn main() {
let my_string = String::from("This string can be sent!");
let handle = thread::spawn(move || {
// Ownership of my_string is moved to this thread
println!("{}", my_string);
});
handle.join().unwrap();
}

This gives the output:

This string can be sent!

String is Send because its internal data (heap-allocated bytes) can be safely transferred to another thread.

A type T is Sync if it is safe to share a reference (&T) to a value of type T between threads. This means that if &T is Send, then T is Sync.

  • This prevents data races when multiple threads have immutable references to the same data, or when one thread has a mutable reference and others have immutable references. Rust’s borrowing rules (one mutable XOR, many immutable) are enforced at compile time for Sync types.
  • Automatic Implementation: Most primitive types are Sync. Collections like Vec<T> and String are Sync if their contained types (T) are also Sync.
  • Types that are shared via Arc<T> must have T implement Sync. For example, Arc<Mutex<T>> works because Mutex<T> is Sync (it provides internal synchronisation).

Example: Arc<Mutex<i32>> is Sync:

use std::thread;
use std::sync::{Mutex, Arc};
fn main() {
let counter = Arc::new(Mutex::new(0)); // Mutex<i32> is Sync
let mut handles = vec![];
for _ in 0..3 {
let counter_clone = Arc::clone(&counter); // Cloning Arc means sharing references
let handle = thread::spawn(move || {
let mut num = counter_clone.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final count: {}", *counter.lock().unwrap());
}

This code gives the output:

Final count: 3

Here, Mutex<i32> is Sync because its internal locking mechanism ensures that even though multiple threads have references to it, only one can mutate the i32 at a time.

Some types are explicitly not Send or Sync because they inherently violate thread safety without additional synchronisation.

  • Raw Pointers (*const T, *mut T): These are neither Send nor Sync because they offer no safety guarantees. Using them requires unsafe code, where the programmer is responsible for ensuring thread safety.
  • Rc<T> (Reference Counted): Unlike Arc<T>, Rc<T> is not Send or Sync because its reference count is not atomic. If multiple threads were to modify the count concurrently, it would lead to a data race. Rc is designed for single-threaded shared ownership.
  • RefCell<T>: RefCell<T> allows “interior mutability” (mutating data through an immutable reference) but performs runtime borrow checks. While a RefCell<T> can be Send (if T is Send), it is never Sync because its runtime checks are not thread-safe. Multiple threads could attempt to borrow the data simultaneously, bypassing the safety checks. RefCell is for single-threaded scenarios or data protected by a Mutex.

If your custom type contains any non-Send or non-Sync types, your custom type will also generally not be Send or Sync unless you explicitly implement synchronisation mechanisms (like Mutex or RwLock) or use unsafe code to justify their thread safety.

As a simple example: Rc<T> is a reference-counted smart pointer used for shared ownership within a single thread; it lets multiple parts of your program hold clones of the same data, and automatically frees the data when the last Rc is dropped, but it is not thread-safe and therefore not Send or Sync. The following minimal code example will result in an error:

use std::rc::Rc;
use std::thread;
fn main() {
let value = Rc::new(42);
thread::spawn(move || {
// ERROR: Rc<T> cannot be sent between threads safely
println!("Value = {}", value);
})
.join()
.unwrap();
}

Rc<T> uses non-atomic reference counting, which is unsafe to update from multiple threads. Therefore, it is neither Send nor Sync. Rc<T> is used when you need shared ownership of data within a single thread and you want that sharing to be as lightweight as possible. It is simpler and faster than Arc<T> because it does not use atomic operations. Clearly, Arc<T> is safer across threads, but if you don’t need that safety, Rc<T> is the faster, simpler choice.

This chapter focused on OS Threads (provided by std::thread), which are managed by the operating system kernel. However, in the world of Rust and especially for edge computing, you will frequently encounter Async Rust (async/await).

OS Threads (std::thread)Async Rust (tokio, embassy)
MechanismPreemptive multitasking (OS switches)Cooperative multitasking (Tasks yield)
OverheadHigher (Stack allocation, context switch)Lower (Tasks share a small number of threads)
BlockingFine to block (Only affects one thread)Never block (Will halt the entire executor)
Best forCPU-bound tasks, simple parallelismI/O-bound tasks, thousands of connections
Edge contextLinux-based SBCs (Raspberry Pi)Bare-metal MCUs (ESP32, STM32) via Embassy

On a powerful device like a Raspberry Pi, threads are often the simplest starting point. On a resource-constrained microcontroller, async runtimes like Embassy allow you to handle tens of concurrent sensors and network tasks with extremely low memory overhead.

Concept Match

Match the Concurrency Concepts

Drag each definition into its matching concept slot, then click Submit. Tap × to return a placed card to the pool.

thread::spawn
drag a definition here…
Arc<Mutex<T>>
drag a definition here…
mpsc::channel
drag a definition here…
AtomicBool
drag a definition here…
Sync
drag a definition here…

Definition Pool

Marker trait that permits shared references (&T) to cross thread boundaries; automatically implemented for types with interior synchronisation.
A boolean that can be loaded and stored by multiple threads without a lock, suitable for stop flags and simple inter-thread signalling.
Creates a new OS thread; takes a closure (optionally move) and returns a JoinHandle used to wait for the thread to finish.
Combines atomic reference counting with mutual exclusion to let multiple threads share and mutate the same value safely.
Creates a multiple-producer, single-consumer message queue; returns a (Sender<T>, Receiver<T>) pair.
Quiz
Select 0/1

On a single-core microcontroller, which statement best describes the relationship between concurrency and parallelism?

Quiz
Select 0/2

Which of the following types are NOT Send and therefore cannot be moved safely into a spawned thread? Select all that apply.

Quiz
Select 0/1

A mutex's lock() method returns a MutexGuard<T> rather than a plain reference. What is the primary benefit of this design?

Quiz
Select 0/2

Which statements correctly describe when to prefer channels (mpsc) over shared state (Arc<Mutex<T>>) for inter-thread communication? Select all that apply.