← Back to list

Stop Using Arc<Mutex<T>> — There’s a Better Architectural Pattern

Why Shared Mutable State Is Slowing Your Rust Backend (And What to Use Instead)

TheOpinionatedDev · 2026-02-24 17:19 · 176 claps · 3.6 min read paywalled
#mutex #rust-programming-language #architectural-patterns #programming #software-engineering
Open on Medium ↗
Wiki topics: 💻 · Programming 🌐 · Web Development 🏛️ · Architecture

Stop Using Arc<Mutex<T>> — There’s a Better Architectural Pattern

Why Shared Mutable State Is Slowing Your Rust Backend (And What to Use Instead)

generated by ai

generated by ai

I’m going to say something that might hurt a little:

If your Rust service is full of Arc<Mutex<T>>

You probably ported your architecture from another language.

And Rust is silently tolerating it.

But it’s not happy.

And neither is your performance.

The Day I Realized I Was Writing “Java in Rust”

Early in one of our async services, we had this everywhere:

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

struct AppState {
    counter: usize,
}
let state = Arc::new(Mutex::new(AppState { counter: 0 }));
for _ in 0..10 {
    let state = state.clone();
    tokio::spawn(async move {
        let mut guard = state.lock().unwrap();
        guard.counter += 1;
    });
}

It worked.

It compiled.

It was “safe”.

But under load?

Latency spikes. Lock contention. Throughput flattening. Weird async stalls.

And the architecture started feeling… wrong.

That’s when it clicked:

Arc<Mutex<T>> is usually a symptom, not a solution.

Why Arc<Mutex<T>> Feels Natural

Because in most languages:

  • Threads share memory
  • We protect it with locks
  • We move on

So when Rust forces you to wrap shared state:

Arc<Mutex<T>>

It feels like:

“Okay fine, that’s the cost of safety.”

But here’s the deeper truth:

Rust’s ownership model isn’t nudging you toward locks.

It’s nudging you away from shared mutable state entirely.

The Real Problem With Arc<Mutex<T>>

Let’s break it down.

Arc = atomic reference counting Mutex = blocking mutual exclusion

When you combine them:

Arc<Mutex<T>>

You create:

  • Shared ownership
  • Shared mutation
  • Blocking access
  • Contention under concurrency

In async Rust (Tokio especially), this gets worse.

Because:

std::sync::Mutex blocks threads.

And if you use:

tokio::sync::Mutex

It doesn’t block threads — but it still serializes access.

Under load, that becomes a bottleneck.

Visualizing the Contention

Imagine this architecture:

      ┌──────────────┐
      │   Task 1     │
      └──────┬───────┘
             │ lock()
             ▼
        ┌─────────┐
        │ Mutex   │
        └─────────┘
             ▲
             │ lock()
      ┌──────┴───────┐
      │   Task 2     │
      └──────────────┘

Only one task can proceed.

Even if you have 8 CPU cores.

You just serialized your system.

That’s not scalable architecture.

That’s a traffic jam.

The Better Pattern: Message Passing + Ownership

Instead of sharing mutable state:

Give ownership to one task.

Other tasks send it messages.

This is the Actor-style pattern.

And it aligns perfectly with Rust.

Rewriting the Same Example (The Right Way)

Instead of this:

Arc<Mutex<AppState>>

We do this:

use tokio::sync::mpsc;

struct AppState {
    counter: usize,
}
enum Command {
    Increment,
    Get(tokio::sync::oneshot::Sender<usize>),
}
async fn state_manager(mut rx: mpsc::Receiver<Command>) {
    let mut state = AppState { counter: 0 };
    while let Some(cmd) = rx.recv().await {
        match cmd {
            Command::Increment => {
                state.counter += 1;
            }
            Command::Get(reply) => {
                let _ = reply.send(state.counter);
            }
        }
    }
}

Spawning it:

let (tx, rx) = mpsc::channel(32);
tokio::spawn(state_manager(rx));

Using it:

tx.send(Command::Increment).await.unwrap();

let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
tx.send(Command::Get(resp_tx)).await.unwrap();
let value = resp_rx.await.unwrap();

No locks. No contention. No shared mutation.

Only one owner of state.

Architecture Diagram (Lock-Free Version)

          ┌───────────────┐
          │   Task A      │
          └──────┬────────┘
                 │ send()
                 ▼
          ┌────────────────┐
          │  mpsc Channel  │
          └──────┬─────────┘
                 ▼
          ┌────────────────┐
          │  State Manager │
          │  (owns state)  │
          └────────────────┘

That’s it.

Single ownership. Serialized mutation by design. No mutex.

Why This Is Architecturally Superior

Because:

  1. Ownership is clear.
  2. Mutation is localized.
  3. Concurrency is controlled.
  4. Contention disappears.
  5. Deadlocks become impossible.

You move from:

Shared-memory concurrency

To:

Message-driven concurrency

And that’s a fundamental shift.

But What About Performance?

Here’s the surprising part:

Under load, the channel-based model often performs better.

Why?

Because:

  • No atomic refcount bump on every clone
  • No mutex locking
  • No wake contention
  • No thread parking

Tokio’s mpsc is optimized with:

  • Lock-free fast paths
  • Batched wakeups
  • Efficient task scheduling

In real services, this reduces tail latency dramatically.

When Arc<Mutex<T>> Is Actually Fine

Let’s be honest.

It’s okay when:

  • Contention is extremely low
  • Data is rarely mutated
  • You are not inside hot async paths
  • It’s short-lived

Example:

Arc<Mutex<HashMap<String, String>>>

Used during startup config load?

Fine.

Used inside every request handler under 10k RPS?

Probably not fine.

Real Production Pattern (Scaling It Up)

In larger systems, this evolves into:

  • Actor systems
  • Command buses
  • Event-driven services
  • State sharding

Example: Sharded State

Hash(key) % 4
              ↓
 ┌───────┬───────┬───────┬───────┐
 │Shard 0│Shard 1│Shard 2│Shard 3│
 └───────┴───────┴───────┴───────┘

Each shard:

  • Owns its own state
  • Runs independently
  • Has its own channel

Now you have parallelism without locks.

Why Rust Pushes You Toward This

Because Rust’s core philosophy is:

Shared mutable state is dangerous.

The borrow checker enforces:

  • Single mutable owner
  • Or multiple immutable references

Arc<Mutex<T>> is basically saying:

“Fine, I’ll bypass that rule carefully.”

But the better design is:

Don’t bypass it.

Embrace it.

Code Flow Comparison

Lock-Based

Task → Clone Arc → Lock → Mutate → Unlock

Message-Based

Task → Send Message → State Owner Handles → Done

One requires blocking. One requires coordination. One scales better.

The Emotional Shift

The first time we removed Arc<Mutex<T>> from a core service, something strange happened.

The code became:

  • Simpler
  • Easier to reason about
  • Deadlock-proof
  • More predictable under load

And debugging got easier.

Because state lived in one place.

Not everywhere.

Final Truth

Arc<Mutex<T>> is not evil.

But it’s often a sign that your architecture still assumes:

Shared mutable memory across threads is normal.

In Rust, it’s not.

Rust wants you to design around:

Ownership. Isolation. Message passing. Explicit concurrency.

And once you lean into that…

You stop fighting the language.

And your systems start scaling naturally.


메타데이터
post_id
cc4d26b89a3e
slug
stop-using-arc-mutex-t-theres-a-better-architectural-pattern-cc4d26b89a3e
url
https://medium.com/@theopinionatedev/stop-using-arc-mutex-t-theres-a-better-architectural-pattern-cc4d26b89a3e
canonical_url
https://medium.com/@theopinionatedev/stop-using-arc-mutex-t-theres-a-better-architectural-pattern-cc4d26b89a3e
author_url
https://medium.com/@theopinionatedev
status
ok
fetched_at
2026-06-26 12:24:55