How to Use Shared State in Async Rust (DashMap, etc.)

Use the DashMap crate to safely share mutable state across threads in async Rust with minimal boilerplate.

The async cache that doesn't block your runtime

You are building an async service. A request arrives. You need to check a cache to see if you've processed this user before. Another request arrives a millisecond later. It needs to update the cache. You try to share a standard HashMap across your tasks. The compiler rejects you with E0277 (trait bound not satisfied) because HashMap is not Send or Sync. You wrap it in a Mutex. Now your tasks can share it, but every access locks the entire map. If one task holds the lock across an .await, you block every other task from reading or writing. Your throughput collapses.

You need a data structure that allows concurrent access without locking the whole world. You need sharding.

That is what DashMap provides. It is a concurrent hash map that splits the data into shards. Each shard has its own lock. When you access a key, the map hashes the key to find the shard, locks only that shard, and performs the operation. Other threads can access different shards simultaneously. It gives you the safety of a locked map with the concurrency of a distributed system, all in user space.

How sharding works

A standard HashMap is a single bucket of data. To mutate it safely across threads, you lock the whole bucket. DashMap divides the data into multiple buckets, called shards. The default number of shards matches the number of CPU cores.

When you insert or get a key, DashMap hashes the key. The hash determines which shard owns that key. The map locks only that specific shard. If two tasks access keys that hash to different shards, they run in parallel. If they hash to the same shard, one waits for the other.

Think of a library with a single librarian versus a library with a librarian at every desk. The single librarian must handle every request sequentially. The desk librarians can handle requests for books at their desks simultaneously. DashMap is the library with desk librarians.

This design means DashMap excels at random access with high concurrency. It struggles with iteration, because iterating requires visiting every shard and locking them in sequence. If you need to scan the whole map, sharding adds overhead. If you need random lookups, sharding removes contention.

Minimal example

Add dashmap to your Cargo.toml. The crate is widely used and stable.

[dependencies]
dashmap = "6"

Create a map, insert values, and read them. The API mirrors std::collections::HashMap closely.

use dashmap::DashMap;

fn main() {
    // WHY: DashMap is not Clone. You cannot share it by cloning the map itself.
    // You must wrap it in Arc to share ownership across threads or tasks.
    let map = DashMap::new();

    // WHY: insert takes ownership of the key and value.
    // The map stores them on the heap inside the shard.
    map.insert(1, "one".to_string());
    map.insert(2, "two".to_string());

    // WHY: get returns a Ref guard, not a direct reference.
    // The guard holds the shard lock. When the guard drops, the lock releases.
    if let Some(entry) = map.get(&1) {
        println!("Value: {}", entry.value());
    }

    // WHY: The guard 'entry' drops here. The lock on shard 1 is released.
}

The get method returns a Ref<K, V>. This is a RAII guard. It holds a shared lock on the shard. You can read the value through the guard. When the guard goes out of scope, the lock releases automatically. You never hold a raw reference to the value, because the value lives inside a locked shard. The guard ensures you cannot access the value after the lock is released.

Realistic async usage

In async Rust, you share state via Arc. You clone the Arc and move it into tasks. DashMap plays well with this pattern, but you must respect the guard lifetimes.

use dashmap::DashMap;
use std::sync::Arc;
use tokio::task;

/// WHY: This function simulates a cache lookup and update.
/// It demonstrates safe sharing of DashMap across async tasks.
async fn process_request(cache: Arc<DashMap<String, i32>>, key: String) {
    // WHY: Clone the Arc to move ownership into the closure.
    // The DashMap itself is shared; the Arc counter increments.
    let cache_clone = cache.clone();

    task::spawn(async move {
        // WHY: Check if the key exists.
        // get() blocks the current thread if the shard is locked.
        // In async, blocking is acceptable for short durations,
        // but avoid holding the guard across an await.
        if let Some(entry) = cache_clone.get(&key) {
            println!("Cache hit for {}: {}", key, entry.value());
        } else {
            // WHY: Insert a new value.
            // insert() blocks the shard until the lock is acquired.
            cache_clone.insert(key.clone(), 42);
            println!("Cache miss for {}. Inserted.", key);
        }
    });
}

#[tokio::main]
async fn main() {
    // WHY: Wrap DashMap in Arc to allow multiple owners.
    let cache = Arc::new(DashMap::new());

    // WHY: Spawn multiple tasks that share the same cache.
    for i in 0..5 {
        let key = format!("user_{}", i);
        process_request(cache.clone(), key).await;
    }

    // WHY: Wait for tasks to finish.
    tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}

Convention aside: Always wrap DashMap in Arc. You will see Arc<DashMap<K, V>> in almost every real-world usage. The community treats this as the standard pattern. You rarely see a bare DashMap in async code.

The Entry API

DashMap provides an entry API similar to HashMap. This is useful for atomic check-and-insert operations. You avoid double-locking the shard.

use dashmap::DashMap;

fn main() {
    let map = DashMap::new();

    // WHY: entry() locks the shard and returns an Entry guard.
    // You can inspect or modify the entry without releasing the lock.
    // This prevents race conditions between get and insert.
    let count = map.entry("requests".to_string()).or_insert(0);

    // WHY: Modify the value through the guard.
    // The lock is held during mutation.
    *count += 1;

    // WHY: The guard drops here. The lock releases.
    // The value is now persisted in the shard.
    println!("Count: {}", map.get("requests").unwrap());
}

The entry API is powerful for counters, caches, and lazy initialization. It reduces lock contention by combining lookup and insertion into a single critical section.

Pitfalls and compiler errors

DashMap is safe, but it has behavioral traps. The compiler will not catch all of them.

Blocking on get. DashMap::get blocks the current OS thread if the shard is locked. In a multi-threaded async runtime like Tokio, blocking a worker thread is dangerous. If contention is high, you can starve the runtime. Use try_get if you need non-blocking access. try_get returns None if the shard is locked, allowing you to yield or retry.

Holding guards across await. If you hold a Ref or RefMut guard across an .await, you hold the shard lock across the await. This blocks other tasks from accessing that shard. In a single-threaded runtime, this deadlocks. In a multi-threaded runtime, it reduces concurrency. Always drop the guard before awaiting.

// BAD: Holding guard across await blocks the shard.
let guard = map.get(&key);
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
println!("{}", guard.value()); // Guard still held during sleep.

// GOOD: Clone the value and drop the guard.
let value = map.get(&key).map(|entry| entry.value().clone());
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
if let Some(v) = value {
    println!("{}", v);
}

Non-Send values. DashMap requires keys and values to be Send + Sync. If you try to store Rc<T> or !Send types, the compiler rejects you with E0277. Use Arc<T> instead of Rc<T> for shared values.

Iteration performance. Iterating over a DashMap locks shards sequentially. If other tasks are accessing the map, iteration can be slow or inconsistent. Use iteration sparingly. If you need a snapshot, collect the data into a Vec or HashMap first.

Error codes. You will see E0277 when trait bounds fail. You will see E0382 (use of moved value) if you try to use a key after moving it into the map. You will see E0596 if you try to mutate a value without a RefMut. The compiler messages are precise. Read them.

Decision matrix

Choose the right tool for your concurrency pattern.

Use DashMap when you need high-concurrency random access with low contention. Use DashMap when you have many tasks performing independent lookups and updates on different keys. Use DashMap when you want to avoid the overhead of Arc<Mutex<HashMap>> for simple key-value operations.

Reach for Mutex<HashMap> when you need atomic operations across multiple keys. Use Mutex<HashMap> when you perform bulk updates or transactions that must be consistent. Use Mutex<HashMap> when iteration performance matters more than concurrent access.

Pick RwLock<HashMap> when reads vastly outnumber writes and contention is low. Use RwLock<HashMap> when you can tolerate blocking the entire map for writes but want concurrent reads. Use RwLock<HashMap> when you need async-aware locking via tokio::sync::RwLock.

Select channels when you have a producer-consumer pattern. Use channels when tasks send discrete messages rather than sharing mutable state. Use channels when you want to decouple producers from consumers.

Where to go next

Drop the guard before the await. Sharding wins for random access, loses for iteration. Trust the lock granularity.