Skip to Content
Course content

43: Send and Sync Traits Explained

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

Most of the time, you won't actually write impl Send for MyStruct {}. In fact, you rarely will. These are "marker traits," meaning they don't have any methods to implement; they just tell the compiler, "Hey, this type is safe to behave this way." But when the compiler starts screaming at you because you're trying to pass a variable into std::thread::spawn, you need to understand what's happening under the hood.

Think of a high-end professional cinema camera.

  • Send is like the ability to put that camera in a shipping crate and mail it to a producer in another city. Once it's shipped, you no longer have it; the producer now owns it and controls it.
  • Sync is like putting that camera on a tripod in a studio and letting five different assistants look through the viewfinder at the same time. No one is moving the camera to a new city; they're all just referencing the same physical object in one place.

Now, let's map that directly to Rust. Send means ownership of a value can be transferred between threads. If a type is Send, you can move it into a new thread. Sync means a type can be safely referenced by multiple threads simultaneously. Specifically, T is Sync if and only if a reference to it (&T) is Send. It sounds circular, but it's the core of Rust's concurrency safety.

Moving the Burden across Threads

Most types in Rust are Send. An i32, a String, or a custom struct containing other Send types are all fine to move. I usually only run into Send issues when I'm dealing with raw pointers or some very specific FFI (Foreign Function Interface) bindings.

The most common "aha!" moment comes when you encounter Rc<T>. You know Rc is for reference counting in a single thread. If you try to move an Rc into a thread, the compiler will stop you. Why? Because Rc is not Send. If two threads both held an Rc to the same data and tried to clone it at the same time, they would both try to increment the reference count. Since that counter isn't atomic, you'd have a data race, and the count would get corrupted. The compiler prevents this disaster by marking Rc as !Send (not Send).

Sharing the View safely

Then there's Sync. This is about shared access. If you have a Mutex<T>, you can share it across threads. The Mutex ensures that even though multiple threads have a reference to the lock (meaning the Mutex is Sync), only one thread can actually touch the data inside at a time.

Here is the tricky part: a type can be Send but not Sync. Consider RefCell<T>. You can move a RefCell into another thread (it's Send), but you cannot share a reference to a RefCell across threads (it's not Sync). This is because RefCell performs its borrow checking at runtime using a non-atomic counter. If two threads tried to borrow the same RefCell simultaneously, they'd clash over that internal counter, leading to undefined behavior.

// This is a mental shorthand for how the compiler sees these:
// T: Sync  <==>  &T: Send

// If I can safely send a reference to you, 
// then the original object must be safe to share.

Where the Compiler Saves Your Skin

I've spent plenty of hours staring at compiler errors that look like: the trait bound `Rc<Config>: Send` is not satisfied. When you see this, don't fight the compiler. It's not being pedantic; it's telling you that your data structure is fundamentally incompatible with multi-threading.

Usually, the fix is a direct swap. If you need to move a reference-counted pointer across threads, you swap Rc for Arc (Atomic Reference Counted). Arc uses atomic operations for the counter, which makes it both Send and Sync. It's slightly heavier on performance, but it's the price you pay for not having your program crash randomly in production.




📋 Practical Task

Converting a Reference-Counted Config for Multi-threaded Access

You are working on a system where a Config object is shared across the application. Currently, it uses Rc because the app was single-threaded. However, you've just been asked to implement a background telemetry thread that needs access to this config.

The following code will not compile. Your task is to modify it so that the Config can be safely shared with the spawned thread. You must ensure that the code compiles and runs without changing the logic of how the config is accessed.

use std::rc::Rc;
use std::thread;

struct Config {
    api_key: String,
    timeout: u32,
}

fn main() {
    let config = Rc::new(Config {
        api_key: "SECRET_123".to_string(),
        timeout: 30,
    });

    let config_clone = Rc::clone(&config);

    let handle = thread::spawn(move || {
        println!("Telemetry thread starting with API key: {}", config_clone.api_key);
    });

    handle.join().unwrap();
    println!("Main thread finished.");
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.