Concurrency in Rust — Thread Arc Mutex and Async/Await with Tokio: Code That Won't Hang
> cd .. / HUB_EDITORIALE
Sviluppo di siti web

Concurrency in Rust — Thread Arc Mutex and Async/Await with Tokio: Code That Won't Hang

[2026-07-24] Author: Ing. Calogero Bono
> share
Zenithby Meteora Web The operating system for your business. Social, clients, bookings and invoices in one platform. Gyms, barbers, professionals. Discover Zenith Free demo · no card

Your Rust multi-thread program keeps freezing? Race conditions costing you sleep? You're not alone. At Meteora Web, we deal with performance and reliability daily — and we've seen too many developers approach Rust concurrency with the wrong attitude: "just spawn threads and see." In Rust, concurrency isn't an add-on: it's a language pillar, and if you don't master it, the compiler will make you pay (thankfully). In this guide we cover threads, Arc, Mutex, and async/await with Tokio, with working code and real-world reasoning.

How does concurrency work with threads, Arc and Mutex in Rust?

Rust treats concurrency as an extension of its ownership system. A thread can take ownership of a value, but if multiple threads need to share data, you need a shared, protected reference. Enter Arc (Atomic Reference Counting) and Mutex (Mutual Exclusion).

The sharing problem between threads

Imagine a counter that must be incremented by multiple threads. In C or Python you'd use a lock. In Rust, if you try to pass a &mut to multiple threads, the compiler screams: "you can't share a mutable reference across threads without synchronization". The standard solution is Arc>:

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

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

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

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

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

Explanation: Arc lets multiple threads own a reference. Mutex ensures only one thread accesses the data at a time. lock() returns a MutexGuard that releases the lock when it goes out of scope. It only fails if the thread holding the lock panicked — hence the unwrap() which should be handled properly in production.

Sponsored Protocol

Common mistakes with Arc and Mutex

  • Deadlock: If a thread acquires two mutexes in different order, it can block. Always use the same order or a global lock.
  • MutexGuard held across .await: In async code, never hold a lock across a suspension point — it stalls the entire runtime.
  • Arc::clone() outside the loop: Clone only when needed, not before thread creation.

A real case: we were supporting a client with an order processing system in Rust. Threads kept deadlocking because they had a lock on a Mutex containing a database connection pool. The fix? Separate the pool into a channel (crossbeam_channel) instead of sharing it with Mutex.

When to use async/await with Tokio instead of threads?

Not all concurrency is the same. Physical threads are heavy: each has at least 2 MB stack. In Rust, if you need to handle thousands of I/O-bound operations (HTTP requests, file reads, DB queries), async is the way. Tokio is the most popular runtime for async/await in Rust.

Tokio's event-driven model

With Tokio you don't create threads but lightweight tasks that run on a runtime thread pool. A task can suspend itself when waiting for I/O, allowing other tasks to use the same thread. Result: thousands of concurrent connections with only a handful of threads.

Sponsored Protocol

use tokio::task;

#[tokio::main]
async fn main() {
    let mut handles = vec![];
    for i in 0..100 {
        handles.push(task::spawn(async move {
            println!("Task {} executed", i);
            // Simulate I/O
            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        }));
    }

    for handle in handles {
        handle.await.unwrap();
    }
    println!("All tasks completed");
}

The #[tokio::main] macro starts the runtime. task::spawn creates an async task. await waits for completion. Note: never use std::thread::sleep in an async context — it blocks the thread, not the task. Always use tokio::time::sleep.

Shared state in async context

To share data between async tasks, don't use Arc as above — better tokio::sync::Mutex or channels. Why? Because std::sync::Mutex blocks the thread, while tokio::sync::Mutex suspends the task if the lock is held. In an async runtime, you want suspension, not blocking.

use tokio::sync::Mutex;
use std::sync::Arc;

#[tokio::main]
async fn main() {
    let data = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let data = Arc::clone(&data);
        handles.push(tokio::spawn(async move {
            let mut val = data.lock().await;
            *val += 1;
        }));
    }

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

    println!("Final value: {}", *data.lock().await);
}

Note the use of .lock().await instead of .lock().unwrap(). This lock is async: if the lock is contended, the task suspends and yields to other tasks.

Sponsored Protocol

How to combine threads and async/await in Rust without losing control?

Sometimes you have a mix: CPU-bound operations (which belong on separate threads) and I/O-bound (async). In Rust you can coexist, but be careful: if you launch a CPU-heavy task on a Tokio thread, you block the entire runtime. The solution is spawn_blocking to run blocking work on a dedicated thread pool.

use tokio::task;

#[tokio::main]
async fn main() {
    // CPU-bound operation on separate thread
    let result = task::spawn_blocking(|| {
        let mut sum = 0u64;
        for i in 0..1_000_000 {
            sum += i;
        }
        sum
    }).await.unwrap();

    println!("Sum: {}", result);
}

spawn_blocking returns an async task, but executes the closure on a blocking thread pool, not on the main runtime. In practice: for long CPU work (calculations, cryptography), use separate threads with std::thread or spawn_blocking. For networking, database, files, use async with Tokio.

Communicating between threads and async

To let standard threads and async tasks talk, use channels. Tokio provides mpsc and oneshot async channels, but you can also use std::sync::mpsc with caution. A common pattern: producer thread sends data over a channel to an async consumer task.

Sponsored Protocol

use tokio::sync::mpsc;
use std::thread;

#[tokio::main]
async fn main() {
    let (tx, mut rx) = mpsc::channel(32);

    // Producer thread (blocking)
    thread::spawn(move || {
        for i in 0..10 {
            // Send using blocking_send because we're in a blocking thread
            tx.blocking_send(i).unwrap();
        }
    });

    // Async consumer task
    while let Some(val) = rx.recv().await {
        println!("Received: {}", val);
    }
}

Note: tx.blocking_send is available on tokio::sync::mpsc::Sender for use in blocking threads. It works because internally it uses a blocking lock. This is the correct way to send data from a thread to async.

What are common concurrency mistakes in Rust and how to avoid them?

We've seen many in projects that come to us. Here are the worst.

Deadlock from Mutex held across .await

If in async code you use std::sync::Mutex and hold the lock while doing .await, you block the entire runtime thread — a deadlock waiting to happen. Rule: in async code only use tokio::sync::Mutex if you must hold a lock across an await, but ideally avoid holding locks during awaits altogether. Prefer channels or lock-free data structures.

Panic on lock failure

We often see lock().unwrap(). In production, a thread that panicked while holding a lock poisons the Mutex: any other thread trying to lock will also panic. Better to handle the error or use lock().expect("...") with a clear message, but even better to prevent threads from panicking while holding a lock.

Sponsored Protocol

Arc instead of Arc> for mutable data

Classic mistake: using Arc> for sharing across threads. RefCell is not Send because it's not thread-safe. The compiler rejects it. Only Mutex or RwLock for mutable shared state across threads.

Too many threads in Tokio pool

By default Tokio uses one thread per CPU core. If you spawn 1000 CPU-bound tasks on Tokio, you gain nothing, you lose from context switching. For parallel CPU work, use a separate thread pool with rayon or spawn_blocking. At Meteora Web, we optimized a data import system for a client: went from 30 manual threads to 4 threads with async + rayon, halving the time.

In a nutshell

  • To share data between threads: Arc> is bread and butter. Keep locks short, never across an await.
  • For massive I/O: use async/await with Tokio. Tasks are lightweight, the runtime handles scheduling.
  • For CPU-heavy work: don't run it on Tokio. Use spawn_blocking or standard threads with channels.
  • For communication: async channels (tokio::sync::mpsc) are the cleanest choice.
  • Never hold a standard lock across an .await. If you must, use tokio::sync::Mutex, but better redesign.

Ready to write Rust concurrency without pain? Start from the official Tokio repository: Tokio Tutorial. And if you need to dive deeper into Rust, go back to the full pillar on Rust.

> share
Ing. Calogero Bono

> AUTHOR_EXTRACTED

Ing. Calogero Bono

Ingegnere informatico, fondatore di Meteora Web e Zenith OS. System administrator e progettista di piattaforme, app e CMS proprietari, con esperienza in sviluppo full-stack, marketing digitale ed ecosistema Google.
[ Read Full Dossier ]

> METEORA_WEB // DIGITAL AGENCY

We build the digital presence your business deserves.

Websites, social media, online advertising, e-commerce and high-performance hosting, engineered with method by computer engineers in Sciacca, for all of Italy.

> MW_JOURNAL

> READ_ALL()