Skip to Content
Course content

195: Marker Traits: Send, Sync, Copy

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

Marker traits are a bit of a trip when you first encounter them because, unlike almost every other trait we've used, they don't have any methods. You don't implement a function to satisfy them. Instead, they are signals to the compiler—essentially labels that say, "This type has this specific property," which allows the compiler to let you do things that would otherwise be dangerous.

To make this concrete, let's build a simple asynchronous command dispatcher. We want to be able to send commands to a background worker thread and have those commands be easy to duplicate for logging purposes.

Making commands trivial to duplicate

I'll start by defining a Command enum. In a real app, this might be "SaveFile" or "RestartServer," but for our example, we'll keep it simple. I want to be able to pass this command to a logger and then still send it to the worker thread without calling .clone() manually every single time.

#[derive(Debug, Clone, Copy)]
enum Command {
    Quit,
    MoveTo(i32, i32),
    Resize(u32, u32),
}

By adding Copy here, I'm telling Rust that Command is a "POD" (Plain Old Data) type. Since it only contains integers, a bitwise copy is perfectly safe. Now, when I assign a Command to a new variable, Rust won't move the value; it'll just copy it. It's a small quality-of-life win, but it's exactly what the Copy marker trait is for.

Moving our commands across thread boundaries

Now, let's get into the multi-threading side of things. I want to spawn a thread that listens for these commands. To do this, I need a way to send data from the main thread to the worker thread. This is where the Send marker trait comes into play.

Most types in Rust are Send by default. If a type is Send, it means it's safe to transfer ownership of that value to another thread. Since our Command enum is just a few integers, the compiler automatically marks it as Send.

use std::thread;

fn main() {
    let cmd = Command::MoveTo(10, 20);

    thread::spawn(move || {
        println!("Worker received: {:?}", cmd);
    }).join().unwrap();
}

This works seamlessly. The move keyword transfers ownership of cmd to the new thread, and because Command is Send, the compiler gives us the green light.

The Rc mistake

Here is where I usually trip up when I'm prototyping. Let's say I want the command to carry some shared configuration data—maybe a string that describes the environment. My first instinct might be to use Rc (Reference Counted) because I want multiple parts of my program to hold a handle to this config.

Watch what happens when I try this:

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

struct Job {
    cmd: Command,
    config: Rc<String>,
}

fn main() {
    let job = Job {
        cmd: Command::Quit,
        config: Rc::new("Production".to_string()),
    };

    thread::spawn(move || {
        println!("Processing {:?} in {}", job.cmd, job.config);
    }).join().unwrap();
}

If you try to compile this, the compiler will scream at you. It'll tell you that Rc<String> cannot be sent between threads safely. Why? Because Rc is not Send. The internal reference counter in Rc is not atomic; if two threads tried to increment it at the same time, you'd have a data race, and the memory would corrupt. The Send marker trait is what prevents this catastrophe from happening at runtime by catching it at compile time.

Safe sharing with Arc and Sync

To fix this, I need to swap Rc for Arc (Atomic Reference Counted). Arc is essentially the thread-safe version of Rc.

But there's a second marker trait involved here: Sync. A type is Sync if it's safe to share a reference to it (&T) between multiple threads. In short: T is Sync if and only if &T is Send.

By switching to Arc, we satisfy both requirements. Arc uses atomic operations for the counter, so it's Send, and because the data inside it is immutable by default, it's also Sync.

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

struct Job {
    cmd: Command,
    config: Arc<String>,
}

fn main() {
    let job = Job {
        cmd: Command::Quit,
        config: Arc::new("Production".to_string()),
    };

    thread::spawn(move || {
        println!("Processing {:?} in {}", job.cmd, job.config);
    }).join().unwrap();
}

Now it compiles. I didn't have to implement any methods; I just chose a type that had the correct marker traits for my architectural needs. That's the power of marker traits: they turn complex concurrency rules into simple type-checking errors.




📋 Practical Task

Fixing the Multi-Threaded Configuration Shared State

You are working on a system that monitors server health. You have a MonitorConfig struct that needs to be shared across three different worker threads. However, the current implementation is using Rc, which is causing a compilation error because Rc does not implement the Send marker trait.

Your Task:

  • Modify the code to use Arc instead of Rc so that the configuration can be safely moved into the spawned threads.
  • Ensure the Command enum implements the Copy trait so it can be passed around without explicit cloning.
use std::rc::Rc;
use std::thread;

#[derive(Debug, Clone)]
enum Command {
    CheckDisk,
    CheckCpu,
}

struct MonitorConfig {
    id: u32,
    label: String,
}

fn main() {
    let config = Rc::new(MonitorConfig {
        id: 1,
        label: "Main-Server".to_string(),
    });

    let cmd = Command::CheckCpu;

    let mut handles = vec![];

    for i in 0..3 {
        let thread_config = config.clone();
        // The line below currently fails to compile
        let handle = thread::spawn(move || {
            println!("Thread {} checking {:?} on {}", i, cmd, thread_config.label);
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.