Skip to main contentConcurrency: Send, Sync, Arc, and Mutex | IoT Worker

Concurrency: Send, Sync, Arc, and Mutex

In C, the basic threading tools are direct:

pthread_create(&tid, NULL, worker, ctx);
pthread_mutex_lock(&lock);
/* modify shared state */
pthread_mutex_unlock(&lock);

These tools can build reliable systems, but many rules are conventions:

  • whether ctx points to data that may cross threads
  • who owns that data
  • whether multiple threads read and write it at the same time
  • whether locking was forgotten
  • which fields are protected by which lock
  • whether resources outlive callbacks and thread exit paths

Rust does not make concurrency automatically simple. It moves some thread-safety requirements into types:

can this value move to another thread?      -> Send
can references be shared across threads?    -> Sync
shared ownership across threads             -> Arc<T>
mutable shared state across threads         -> Mutex<T>

This article focuses on the threading model most useful for command-line tools and libraries. It does not cover low-level memory ordering or lock-free programming.

Send: Can This Value Move to Another Thread?

A simple thread:

let data = vec![1_u8, 2, 3];

let handle = std::thread::spawn(move || {
    println!("{}", data.len());
});

handle.join().unwrap();

move transfers ownership of data into the new thread. Vec<u8> can do this because it is Send.

Send means:

Values of this type can be safely moved to another thread.

Most ordinary owning types are Send, such as String, Vec<T>, and Box<T>, as long as the contained T also satisfies the requirement.

But not everything can move across threads. Rc<T> is not a thread-safe reference count, so it cannot be sent to another thread:

use std::rc::Rc;

let data = Rc::new(String::from("shared"));

// std::thread::spawn(move || {
//     println!("{data}");
// });

The compiler rejects this. In C, the equivalent question is “is this reference-counted object thread-safe?” Rust puts that answer in the type: use Arc<T> across threads, not Rc<T>.

Sync: Can References Be Shared Across Threads?

Sync means:

If &T can be safely shared by multiple threads, then T is Sync.

Read-only shared data is usually fine:

use std::sync::Arc;

let config = Arc::new(String::from("config"));

let a = Arc::clone(&config);
let b = Arc::clone(&config);

let t1 = std::thread::spawn(move || {
    println!("{a}");
});

let t2 = std::thread::spawn(move || {
    println!("{b}");
});

t1.join().unwrap();
t2.join().unwrap();

String can be shared read-only, and Arc<String> provides thread-safe reference counting.

The first distinction is:

Send -> can the value be moved to another thread?
Sync -> can references be shared by multiple threads?

Most of the time you do not implement these traits manually. Standard library types and ordinary structs derive these capabilities from their fields. Manually writing unsafe impl Send or unsafe impl Sync is rare and usually belongs to wrappers around raw pointers or FFI handles.

Arc Means Shared Ownership Across Threads

In C, if several threads share the same config or state, you often pass the same pointer:

pthread_create(&t1, NULL, worker, shared);
pthread_create(&t2, NULL, worker, shared);

You must ensure shared stays alive until all threads finish.

Rust uses Arc<T> for shared ownership across threads:

use std::sync::Arc;

let config = Arc::new(Config {
    retry: 3,
});

let worker_config = Arc::clone(&config);

let handle = std::thread::spawn(move || {
    println!("{}", worker_config.retry);
});

handle.join().unwrap();

Arc::clone does not copy the underlying Config. It increments the reference count so several owners point to the same value.

Do not read Arc as “the inner value is automatically mutable.” Arc<T> only solves shared ownership across threads. It does not let you freely modify T.

Mutex Protects Mutable Shared State

If several threads must modify the same state, use synchronization. The common Rust shape is Arc<Mutex<T>>:

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

let counter = Arc::new(Mutex::new(0_u32));

let mut handles = Vec::new();

for _ in 0..4 {
    let counter = Arc::clone(&counter);

    handles.push(std::thread::spawn(move || {
        let mut value = counter.lock().unwrap();
        *value += 1;
    }));
}

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

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

Keep the two layers separate:

Arc   -> several threads own the same state
Mutex -> only one thread at a time can modify the state

lock() returns a guard. While the guard exists, the lock is held. When the guard leaves scope, the lock is released automatically.

{
    let mut value = counter.lock().unwrap();
    *value += 1;
} // unlock happens here

This differs from C pthread_mutex_lock / unlock: Rust ties unlocking to scope through a guard and Drop, reducing forgotten unlock bugs.

Mutex Protects Data, Not Just a Code Block

In C, a lock is often a separate field:

struct Shared {
    pthread_mutex_t lock;
    int counter;
};

Rust Mutex<T> wraps the protected data:

struct Shared {
    counter: Mutex<u32>,
}

You cannot directly get a mutable reference to counter without locking. To access the inner data, call lock():

let mut counter = shared.counter.lock().unwrap();
*counter += 1;

This changes code organization. A lock is not just an operation before and after a block; it is part of the data access permission.

If only a few fields need concurrent mutation, lock those fields instead of wrapping the entire struct in one large Mutex.

Mutex Poisoning Does Not Mean the Lock Is Broken

lock().unwrap() returns a Result because Rust mutexes have poisoning.

If a thread panics while holding the lock, the mutex is marked poisoned. Later lock() calls return an error to warn that the shared state may have stopped halfway through an update.

Examples and tests often use:

let mut value = mutex.lock().unwrap();

Library code can handle it explicitly:

let mut value = mutex.lock().map_err(|_| Error::StatePoisoned)?;

Poisoning does not mean the underlying lock cannot be used. It means the protected data may no longer satisfy its business invariants.

Arc<Mutex> Is Not the Default Answer

Wrapping everything in Arc<Mutex<T>> often postpones the design question.

Some code does not need mutable sharing:

let data = load_input();

let handle = std::thread::spawn(move || {
    process(data)
});

Here moving ownership into the thread is enough.

Some code only needs read-only sharing:

let config = Arc::new(config);

No Mutex is needed.

Some code is clearer with message passing:

let (tx, rx) = std::sync::mpsc::channel();

When threads exchange tasks or results, a channel is often clearer than shared locked state.

Ask first:

Can ownership be moved directly?
Is the data only shared read-only?
Do several threads really need to mutate the same state?
Would message passing be simpler?

Use Arc<Mutex<T>> when shared mutable state is truly needed.

Atomics Fit Simple Counters and Flags

C often uses atomics for counters, flags, and lock-free synchronization. Rust has atomics too:

use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};

let ready = AtomicBool::new(false);
ready.store(true, Ordering::Release);

Memory ordering is not the first concurrency topic to master. For most libraries and command-line tools, start with Mutex, channels, and thread joining.

Atomics fit very small shared state: counters, stop flags, status bits. Do not build a complex struct’s consistency around a few atomics unless you understand the memory ordering and invariants.

What to Keep

The first C-to-Rust mapping is:

pthread_create + ctx        -> thread::spawn(move || ...)
shared read-only pointer     -> Arc<T>
shared mutable pointer + mutex -> Arc<Mutex<T>>
pthread_mutex_lock/unlock    -> Mutex guard + Drop unlocks automatically
thread-safe reference count  -> Arc<T>
single-thread reference count -> Rc<T>, not cross-thread
can move to a thread         -> Send
can share references across threads -> Sync
simple atomic flags/counters -> Atomic*

Rust concurrency is not “no locks.” It is about making locks, shared ownership, and cross-thread movement visible in types.

When moving from C, share less first: move ownership if possible, use Arc for read-only sharing, and use Arc<Mutex<T>> only when shared mutable state is truly needed. That makes concurrency boundaries clearer than “pass a void *ctx and agree not to misuse it.”

The next step is unsafe and FFI boundaries. Safe abstractions do not solve every low-level problem, but they shrink the part of the code that truly needs unsafe.