Skip to Content
Course content

115: Atomic Types in std::sync::atomic

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Up until now, whenever we've needed to share a piece of mutable state across threads, we've reached for a Mutex or a RwLock. For most things, that's the right call. But there are times when using a Mutex feels like using a sledgehammer to crack a nut. I'm talking about those tiny pieces of state—a counter, a boolean flag, or a status code—where the overhead of locking and unlocking a mutex actually costs more than the operation itself.

The overhead of the safe bet

Imagine we're building a high-throughput web server and we want to keep track of the total number of requests processed across all worker threads. The "safe" way we've learned is to wrap a usize in an Arc<Mutex<usize>>. It looks like this:

use std::sync::{Arc, Mutex};
use std::thread;

let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
    let counter = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        for _ in 0..1000 {
            let mut num = counter.lock().unwrap();
            *num += 1;
        }
    }));
}

This works perfectly, but it's inefficient. Every single time a thread wants to increment that number, it has to request a lock, potentially put the thread to sleep if another thread holds it, and then unlock it. We're spending more time managing the lock than actually adding one to a number. If this is in the hot path of your application, you're creating a massive bottleneck where every thread is just waiting in line to touch one integer.

Leaning on the hardware

This is where std::sync::atomic comes in. Atomic types—like AtomicUsize, AtomicBool, and AtomicI32—don't use locks. Instead, they leverage special instructions built directly into your CPU. When you tell an AtomicUsize to increment, the CPU handles the "read-modify-write" cycle as a single, indivisible operation. No other thread can sneak in halfway through.

Here is how I'd rewrite that request counter using atomics:

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::thread;

let counter = Arc::new(AtomicUsize::new(0));
let mut handles = vec![];

for _ in 0..10 {
    let counter = Arc::clone(&counter);
    handles.push(thread::spawn(move || {
        for _ in 0..1000 {
            counter.fetch_add(1, Ordering::SeqCst);
        }
    }));
}

Notice we didn't call .lock(). We used fetch_add. This is significantly faster because it avoids the OS kernel's involvement in thread synchronization. We're talking about a direct conversation between the code and the hardware.

The trade-off: Memory Ordering

You probably noticed that weird Ordering::SeqCst argument. This is the "price" you pay for leaving the safety of a Mutex. Because modern CPUs and compilers love to reorder instructions to squeeze out performance, you have to explicitly tell Rust how strict the synchronization needs to be.

  • SeqCst (Sequentially Consistent): This is the gold standard. It guarantees that all threads see all operations in the exact same order. It's the safest choice and the one I recommend using until you have a very specific reason not to.
  • Relaxed: This is the fastest. It only guarantees that the operation itself is atomic, but it makes no promises about the order in which other memory operations are seen. It's great for counters where you don't care if Thread A sees the update a few nanoseconds after Thread B.
  • Acquire/Release: These are the middle ground, used for building custom locks or signaling mechanisms between threads.

I'll be honest: memory ordering is one of the deepest rabbit holes in computer science. Don't feel like you need to master the formal memory model today. For 99% of use cases, SeqCst is what you want. If you're optimizing for every single CPU cycle and move to Relaxed, just be aware that you're stepping into a world where "weird" bugs can happen on different CPU architectures (like ARM vs x86).




📋 Practical Task

Implementing a Concurrent Active-Connection Tracker

You are simulating a server that tracks how many clients are currently connected. Because clients connect and disconnect constantly across many threads, using a Mutex would be too slow.

Your Task:

  • Create a struct called ConnectionTracker that holds an AtomicUsize.
  • Implement a method increment_connections(&self) that increases the count by 1.
  • Implement a method decrement_connections(&self) that decreases the count by 1.
  • Implement a method get_count(&self) -> usize that returns the current number of connections.
  • In your main function, spawn 5 threads. Each thread should simulate 100 clients connecting and then disconnecting (increment then decrement).
  • Finally, print the total count. If implemented correctly, the final count should be 0.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.