Understanding the Sync Trait in Rust

When writing concurrent code in Rust, one of the most important concepts to grasp is the Sync trait. Rust uses Sync and Send to define how data can safely move or be shared between threads. These traits are not just ordinary traits you implement manually; they are built into the language as special “marker traits” that the compiler uses to enforce thread safety rules at compile time. Understanding Sync helps you design safe concurrent programs without data races or undefined behavior.


 

A type is Sync if it can be safely referenced from multiple threads at the same time. In other words, if &T is Send, then T is Sync. This means that if you have a reference to a value, you can share that reference across threads without breaking memory safety. Many types in Rust are automatically Sync because they use internal synchronization or are immutable by nature. For example, primitive types like i32, bool, and f64 are Sync, as are most collections when they contain Sync types.

Consider this simple example:

use std::sync::Arc;
use std::thread;

fn main() {
    let data = Arc::new(42);
    let mut handles = vec![];

    for _ in 0..5 {
        let data_clone = Arc::clone(&data);
        handles.push(thread::spawn(move || {
            println!("Value: {}", data_clone);
        }));
    }

    for h in handles {
        h.join().unwrap();
    }
}

In this example, Arc (atomic reference counting) allows multiple threads to share ownership of the same data. Arc is Sync because its internal reference counter uses atomic operations, ensuring updates are safe across threads. If we replaced Arc with Rc (non-atomic reference counting), the code would not compile because Rc is not Sync. The compiler enforces this rule to prevent potential race conditions.

Rust marks types as Sync automatically when all their components are Sync. For example, a struct containing only Sync fields becomes Sync itself. You can verify this behavior using the is_sync test trick:

use std::sync::Mutex;
fn assert_sync<T: Sync>() {}
fn main() {
    assert_sync::<i32>();
    assert_sync::<Mutex<i32>>();
}

This code compiles because both i32 and Mutex are Sync. The Mutex type is Sync because it uses locking mechanisms internally, so multiple threads can access it safely as long as only one thread holds the lock at a time. However, note that Mutex is Sync even if T is not Sync, since the lock guarantees safe access.

An example of a type that is not Sync is Cell. The Cell type allows interior mutability without any synchronization, meaning multiple threads could mutate it at the same time, causing a race condition. For that reason, Cell and RefCell are neither Sync nor Send. If you try to share them across threads, Rust will reject your code.

For instance:

use std::cell::Cell;
use std::thread;

fn main() {
    let data = Cell::new(10);
    let handle = thread::spawn(move || {
        data.set(20);
    });

    handle.join().unwrap();
}

This fails to compile because Cell is not Sync. The compiler’s error clearly states that “Cell cannot be shared between threads safely.” Rust catches this at compile time, preventing subtle bugs that would appear at runtime in other languages.

If you ever need to share a non-Sync type safely, you can wrap it in synchronization primitives such as Mutex or RwLock. These types provide thread-safe interior mutability by controlling concurrent access.

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

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

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

    for h in handles {
        h.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap());
}

Here, even though i32 itself is Sync, we wrap it in a Mutex to ensure atomic increments. Without it, multiple threads could attempt to modify the same value simultaneously, causing data races. The combination of Arc and Mutex is a common idiom in Rust for shared mutable state across threads.

You rarely need to manually implement Sync, but it is possible for unsafe abstractions. The Rust compiler automatically derives Sync for types where it is safe to do so. If you use unsafe code to manage raw pointers or low-level synchronization, you can implement Sync manually, but only when you can guarantee thread safety yourself. For example:

struct UnsafeWrapper {
    ptr: *const i32,
}

unsafe impl Sync for UnsafeWrapper {}

This tells the compiler that it is safe to share references to UnsafeWrapper across threads. However, marking something Sync incorrectly can introduce undefined behavior, so it must be done with extreme caution.

The Sync trait complements the Send trait. While Send allows moving ownership of a value to another thread, Sync allows multiple threads to hold references to the same value simultaneously. Together, they define the boundaries of what can be safely shared or transferred between threads. Most standard library types are both Send and Sync, except for those explicitly designed for single-threaded use.

In conclusion, the Sync trait is one of the cornerstones of Rust’s concurrency safety model. It defines which types can be shared between threads and ensures that data access remains consistent and race-free. By understanding Sync and using the right primitives such as Arc, Mutex, and RwLock, you can write efficient and safe concurrent programs in Rust without relying on runtime checks or garbage collection.

Comments

Popular posts from this blog

Is Docker Still Relevant in 2025? A Practical Guide to Modern Containerization

Going In With Rust: The Interview Prep Guide for the Brave (or the Mad)

How to Set Up and Run a Minecraft Server with Forge and Mods on OCI Free Tier