28

I would like to have a shared struct between threads. The struct has many fields that are never modified and a HashMap, which is. I don't want to lock the whole HashMap for a single update/remove, so my HashMap looks something like HashMap<u8, Mutex<u8>>. This works, but it makes no sense since the thread will lock the whole map anyways.

Here's this working version, without threads; I don't think that's necessary for the example.

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

fn main() {
    let s = Arc::new(Mutex::new(S::new()));
    let z = s.clone();
    let _ = z.lock().unwrap();
}

struct S {
    x: HashMap<u8, Mutex<u8>>, // other non-mutable fields
}

impl S {
    pub fn new() -> S {
        S {
            x: HashMap::default(),
        }
    }
}

Playground

Is this possible in any way? Is there something obvious I missed in the documentation?

I've been trying to get this working, but I'm not sure how. Basically every example I see there's always a Mutex (or RwLock, or something like that) guarding the inner value.

Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
Andrew
  • 2,063
  • 3
  • 24
  • 40
  • 2
    In order to achieve thread safety, there is no way around it. You must have a guard around the entire type and not just it's underlying fields. – squiguy May 10 '18 at 23:32
  • @Shepmaster Thanks! That's too bad, sadly. Inserting/removing happens very rarely to the map, it's like 99.9% updates only. That's why I thought I would just lock the single element in the map, and use some kind of other method to ensure that inserting/removing happens while there aren't any workers using the map. Back to thinking I guess. – Andrew May 10 '18 at 23:41
  • @Shepmaster Forgive me if I'm stupid, but updating a single element in the map wouldn't still need a write lock? Or do you mean, that I should use a read lock up until I actually modify the map, and then upgrade it to a write lock? Considering the actual work's ~95% would be under write lock in this way anyway, should I just go ahead and use a `Mutex` instead to avoid the `RwLock`'s overhead? – Andrew May 11 '18 at 00:16
  • Do your threads really spend a lot of time accessing the HashMap? Is it read-mostly? – David Schwartz Apr 17 '20 at 20:18

3 Answers3

29

I don't see how your request is possible, at least not without some exceedingly clever lock-free data structures; what should happen if multiple threads need to insert new values that hash to the same location?

In previous work, I've used a RwLock<HashMap<K, Mutex<V>>>. When inserting a value into the hash, you get an exclusive lock for a short period. The rest of the time, you can have multiple threads with reader locks to the HashMap and thus to a given element. If they need to mutate the data, they can get exclusive access to the Mutex.

Here's an example:

use std::{
    collections::HashMap,
    sync::{Arc, Mutex, RwLock},
    thread,
    time::Duration,
};

fn main() {
    let data = Arc::new(RwLock::new(HashMap::new()));

    let threads: Vec<_> = (0..10)
        .map(|i| {
            let data = Arc::clone(&data);
            thread::spawn(move || worker_thread(i, data))
        })
        .collect();

    for t in threads {
        t.join().expect("Thread panicked");
    }

    println!("{:?}", data);
}

fn worker_thread(id: u8, data: Arc<RwLock<HashMap<u8, Mutex<i32>>>>) {
    loop {
        // Assume that the element already exists
        let map = data.read().expect("RwLock poisoned");

        if let Some(element) = map.get(&id) {
            let mut element = element.lock().expect("Mutex poisoned");

            // Perform our normal work updating a specific element.
            // The entire HashMap only has a read lock, which
            // means that other threads can access it.
            *element += 1;
            thread::sleep(Duration::from_secs(1));

            return;
        }

        // If we got this far, the element doesn't exist

        // Get rid of our read lock and switch to a write lock
        // You want to minimize the time we hold the writer lock
        drop(map);
        let mut map = data.write().expect("RwLock poisoned");

        // We use HashMap::entry to handle the case where another thread 
        // inserted the same key while where were unlocked.
        thread::sleep(Duration::from_millis(50));
        map.entry(id).or_insert_with(|| Mutex::new(0));
        // Let the loop start us over to try again
    }
}

This takes about 2.7 seconds to run on my machine, even though it starts 10 threads that each wait for 1 second while holding the exclusive lock to the element's data.

This solution isn't without issues, however. When there's a huge amount of contention for that one master lock, getting a write lock can take a while and completely kills parallelism.

In that case, you can switch to a RwLock<HashMap<K, Arc<Mutex<V>>>>. Once you have a read or write lock, you can then clone the Arc of the value, returning it and unlocking the hashmap.

The next step up would be to use a crate like arc-swap, which says:

Then one would lock, clone the [RwLock<Arc<T>>] and unlock. This suffers from CPU-level contention (on the lock and on the reference count of the Arc) which makes it relatively slow. Depending on the implementation, an update may be blocked for arbitrary long time by a steady inflow of readers.

The ArcSwap can be used instead, which solves the above problems and has better performance characteristics than the RwLock, both in contended and non-contended scenarios.

I often advocate for performing some kind of smarter algorithm. For example, you could spin up N threads each with their own HashMap. You then shard work among them. For the simple example above, you could use id % N_THREADS, for example. There are also complicated sharding schemes that depend on your data.

As Go has done a good job of evangelizing: do not communicate by sharing memory; instead, share memory by communicating.

Community
  • 1
  • 1
Shepmaster
  • 388,571
  • 95
  • 1,107
  • 1,366
  • Thanks for the good working example. Would you elaborate "you can switch to a RwLock>>>. Once you have a read or write lock, you can then clone the Arc of the value, returning it and unlocking the hashmap." ? Why "clone the Arc" can help locking shorter and safe ? Or would give an example for this improvement ? - thanks a lot, – Charlie 木匠 Oct 18 '22 at 17:38
  • 1
    @Charlie木匠 cloning an `Arc` only involves copying a pointer and atomically incrementing a reference count — a very fast operation. The newly cloned value can be used in isolation and no longer requires that the `RwLock` be read-/write-locked. – Shepmaster Oct 21 '22 at 01:16
  • @Shepmaster Can I ask why a `RwLock>>>` in your 2nd example? If you have the same amount of read and write, `Mutex>>>` would have the same effect while being a bit lighter in terms of memory. Also, in your sharding example, instead of spinning the threads, you could have a `Vec>>` and like you said, use something like `hash(key) % shards.len()` to retrieve the shard – Ervadac Dec 01 '22 at 15:44
  • (to clarify `Arc>>>` could be shared across threads) – Ervadac Dec 01 '22 at 17:56
  • @Ervadac I'm sure a `Mutex` would be fine in certain workloads. As you suggested: *if you have the same amount of read and write*. That's not always true — some systems have 99.9% reads and 0.01% writes. – Shepmaster Dec 21 '22 at 21:02
  • @Shepmaster If we assume that the hashmap keys never get updated and that only the values get updated, could we remove the `RWLock` to the hashmap and only keep the locks on the values? Would something like this be possible? – m0ur Apr 14 '23 at 18:32
  • 1
    @jimouris sure, if you mean something [like this](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=673df199dd4e1a4ff88968d1a4b836ce). – Shepmaster Apr 14 '23 at 20:32
  • @Shepmaster whoa, that's neat! thank you – m0ur Apr 15 '23 at 00:37
  • @Shepmaster I have another question since I'm more familiar with rayon parallelization. Is there a benefit of using the `thread::scope` instead of rayon? – m0ur Apr 15 '23 at 01:13
  • @jimouris IIRC, Rayon is a thread _pool_ while `thread::scope` is not. Also there's all the cool features for Rayon's parallel iterators. – Shepmaster Apr 16 '23 at 12:33
9

Suppose the key of the data is map-able to a u8

You can have Arc<HashMap<u8,Mutex<HashMap<Key,Value>>>

When you initialize the data structure you populate all the first level map before putting it in Arc (it will be immutable after initialization)

When you want a value from the map you will need to do a double get, something like:

data.get(&map_to_u8(&key)).unwrap().lock().expect("poison").get(&key)

where the unwrap is safe because we initialized the first map with all the value.

to write in the map something like:

data.get(&map_to_u8(id)).unwrap().lock().expect("poison").entry(id).or_insert_with(|| value);

It's easy to see contention is reduced because we now have 256 Mutex and the probability of multiple threads asking the same Mutex is low.

@Shepmaster example with 100 threads takes about 10s on my machine, the following example takes a little more than 1 second.

use std::{
    collections::HashMap,
    sync::{Arc, Mutex, RwLock},
    thread,
    time::Duration,
};

fn main() {
    let mut inner = HashMap::new( );
    for i in 0..=u8::max_value() {
        inner.insert(i, Mutex::new(HashMap::new()));
    }
    let data = Arc::new(inner);

    let threads: Vec<_> = (0..100)
        .map(|i| {
            let data = Arc::clone(&data);
            thread::spawn(move || worker_thread(i, data))
        })
        .collect();

    for t in threads {
        t.join().expect("Thread panicked");
    }

    println!("{:?}", data);
}

fn worker_thread(id: u8, data: Arc<HashMap<u8,Mutex<HashMap<u8,Mutex<i32>>>>> ) {
    loop {

        // first unwrap is safe to unwrap because we populated for every `u8`
        if let Some(element) = data.get(&id).unwrap().lock().expect("poison").get(&id) {
            let mut element = element.lock().expect("Mutex poisoned");

            // Perform our normal work updating a specific element.
            // The entire HashMap only has a read lock, which
            // means that other threads can access it.
            *element += 1;
            thread::sleep(Duration::from_secs(1));

            return;
        }

        // If we got this far, the element doesn't exist

        // Get rid of our read lock and switch to a write lock
        // You want to minimize the time we hold the writer lock

        // We use HashMap::entry to handle the case where another thread
        // inserted the same key while where were unlocked.
        thread::sleep(Duration::from_millis(50));
        data.get(&id).unwrap().lock().expect("poison").entry(id).or_insert_with(|| Mutex::new(0));
        // Let the loop start us over to try again
    }
}

Riccardo Casatta
  • 1,180
  • 12
  • 18
5

Maybe you want to consider evmap:

A lock-free, eventually consistent, concurrent multi-value map.

The trade-off is eventual-consistency: Readers do not see changes until the writer refreshes the map. A refresh is atomic and the writer decides when to do it and expose new data to the readers.

Ibraheem Ahmed
  • 11,652
  • 2
  • 48
  • 54
jonas
  • 1,074
  • 11
  • 11
  • `evmap` can be used in a fully consistent mode by refreshing after every write. – Ibraheem Ahmed Mar 31 '21 at 13:39
  • The write and flush are not atomic operations though so I do not believe this qualifies as "fully consistent" as a read request could still race against the flush itself right? – jeremyong Apr 19 '21 at 11:08
  • @jeremyong What makes you think it is not atomic? I couldn't find any information right now. In my understanding, it works by keeping two copies of the map: one for reading and one for writing. The writes are not atomic, but the swapping of the two references could be atomic. – jonas Apr 21 '21 at 06:35
  • @jonas When writing, the read map is not locked, so someone could issue a read before the flush is visible. – jeremyong Apr 21 '21 at 13:36
  • @jeremyong Yes indeed. It is lock-free and always consistent (because the writer decides when to sync), but maybe not up to date. This is what "eventual consistency" means here: Eventual consistent between writer and reader, but the data can always be consistent in itself if the writer does something sensible. – jonas Apr 22 '21 at 10:21
  • 1
    @jonas I wasn't responding to your answer but to ibraheem's comment that evmap can be used in "fully consistent mode" (which I believe to be erroneous) – jeremyong Apr 22 '21 at 14:14