10.4 Atomic and Safety

Atomic Operations
Section titled “Atomic Operations”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.
Memory Orderings
Section titled “Memory Orderings”Every atomic operation takes a std::sync::atomic::Ordering argument that controls how the CPU and compiler may reorder instructions around the atomic access.
| Ordering | Meaning |
|---|---|
Relaxed | Atomic with no ordering guarantees between other operations. Safe for counters that need only eventual consistency. |
Acquire | On a load: all subsequent operations see writes that a releasing thread performed before its Release store. |
Release | On a store: all preceding operations are visible to threads that subsequently perform an Acquire load on the same atomic. |
AcqRel | Combines Acquire (on load) and Release (on store). Used for read-modify-write operations like compare_exchange. |
SeqCst | Strongest: 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.
Stop-Flag Example
Section titled “Stop-Flag Example”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 1Worker tick 2Worker tick 3Stop signal sentWorker stopped after 3 ticksThe 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).
Thread Safety Traits: Send and Sync
Section titled “Thread Safety Traits: Send and Sync”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.
The Send Trait
Section titled “The Send Trait”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 likeVec<T>andStringareSendif their contained types (T) are alsoSend. - The common use when you use move closures with
thread::spawn(), the data being moved into the new thread must beSend.
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.
The Sync Trait
Section titled “The Sync Trait”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
Synctypes. - Automatic Implementation: Most primitive types are
Sync. Collections likeVec<T>andStringareSyncif their contained types (T) are alsoSync. - Types that are shared via
Arc<T>must haveTimplementSync. For example,Arc<Mutex<T>>works becauseMutex<T>isSync(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: 3Here, 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.
Types That Are Not Send or Sync
Section titled “Types That Are Not Send or Sync”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 neitherSendnorSyncbecause they offer no safety guarantees. Using them requiresunsafecode, where the programmer is responsible for ensuring thread safety. Rc<T>(Reference Counted): UnlikeArc<T>,Rc<T>is notSendorSyncbecause its reference count is not atomic. If multiple threads were to modify the count concurrently, it would lead to a data race.Rcis 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 aRefCell<T>can beSend(ifTisSend), it is neverSyncbecause its runtime checks are not thread-safe. Multiple threads could attempt to borrow the data simultaneously, bypassing the safety checks.RefCellis for single-threaded scenarios or data protected by aMutex.
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.
Summary: Threads vs. Async Rust
Section titled “Summary: Threads vs. Async Rust”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) | |
|---|---|---|
| Mechanism | Preemptive multitasking (OS switches) | Cooperative multitasking (Tasks yield) |
| Overhead | Higher (Stack allocation, context switch) | Lower (Tasks share a small number of threads) |
| Blocking | Fine to block (Only affects one thread) | Never block (Will halt the entire executor) |
| Best for | CPU-bound tasks, simple parallelism | I/O-bound tasks, thousands of connections |
| Edge context | Linux-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.
🧩Knowledge Check
Section titled “🧩Knowledge Check”Match the Concurrency Concepts
On a single-core microcontroller, which statement best describes the relationship between concurrency and parallelism?
Which of the following types are NOT Send and therefore cannot be moved safely into a spawned thread? Select all that apply.
A mutex's lock() method returns a MutexGuard<T> rather than a plain reference. What is the primary benefit of this design?
Which statements correctly describe when to prefer channels (mpsc) over shared state (Arc<Mutex<T>>) for inter-thread communication? Select all that apply.
© 2026 Derek Molloy, Dublin City University. All rights reserved.