๐Ÿฆ€ Functional Rust

465: Message Passing vs Shared Memory

Difficulty: 3 Level: Intermediate Compare two correct approaches to concurrent state: channels (message passing) vs `Arc<Mutex<T>>` (shared memory) โ€” and know when to choose each.

The Problem This Solves

You need multiple threads to update a shared data structure โ€” say, a word-frequency counter. Both approaches are correct, but they have very different characteristics. Picking the wrong one either kills performance or makes reasoning about your code unnecessarily hard. With shared memory (`Arc<Mutex<HashMap>>`), every thread grabs the lock for every word. This is fast when updates are tiny and frequent, but the mutex becomes a bottleneck if threads spend significant time holding it. Under high contention, threads spend more time waiting than working. With message passing, each thread accumulates a partial result and sends it all at once. There's no contention during computation โ€” threads work independently. The merge step at the end is the only coordination point. The downside: you're allocating and sending intermediate `HashMap`s, which has overhead for very fine-grained updates.

The Intuition

Message passing says "share data by communicating a result once"; shared memory says "communicate by accessing shared data many times" โ€” message passing is easier to reason about and scales better under high contention; shared memory is faster for high-frequency tiny updates. The Go proverb applies: do not communicate by sharing memory; share memory by communicating.

How It Works in Rust

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

let words = vec!["foo", "bar", "foo", "baz", "bar", "foo"];

// --- Message Passing Approach ---
let (tx, rx) = mpsc::channel::<HashMap<&str, usize>>();

let words_mp = words.clone();
thread::spawn(move || {
 let mut local: HashMap<&str, usize> = HashMap::new();
 for word in words_mp {
     *local.entry(word).or_insert(0) += 1;  // accumulate locally
 }
 tx.send(local).unwrap();                    // send result once
});

let result_mp = rx.recv().unwrap();             // no contention during processing

// --- Shared Memory Approach ---
let shared: Arc<Mutex<HashMap<&str, usize>>> = Arc::new(Mutex::new(HashMap::new()));
let shared2 = Arc::clone(&shared);

thread::spawn(move || {
 for word in &words {
     let mut map = shared2.lock().unwrap();  // lock per update โ€” contention here
     *map.entry(word).or_insert(0) += 1;
 }
});

What This Unlocks

Key Differences

ConceptOCamlRust
Message passingManual queue + result accumulation`mpsc::channel` + partial `HashMap`
Shared memory`ref` + `Mutex``Arc<Mutex<HashMap>>`
Lock granularityManualPer `lock()` call
Ownership transferCopied or GC-managedMoved into channel โ€” zero-copy
Best forBatch, independent workersFine-grained, high-frequency updates
Reasoning difficultyEasier (no shared state)Harder (must reason about lock ordering)
// 465. Message passing vs shared memory
use std::sync::{Arc, Mutex, mpsc};
use std::collections::HashMap;
use std::thread;

fn count_words(s: &str) -> HashMap<String,usize> {
    let mut m = HashMap::new();
    for w in s.split_whitespace() { *m.entry(w.to_lowercase()).or_insert(0)+=1; }
    m
}

fn merge(mut a: HashMap<String,usize>, b: HashMap<String,usize>) -> HashMap<String,usize> {
    for (k,v) in b { *a.entry(k).or_insert(0)+=v; } a
}

fn msg_passing(texts: Vec<String>) -> HashMap<String,usize> {
    let (tx,rx) = mpsc::channel::<HashMap<String,usize>>();
    for t in texts { let tx=tx.clone(); thread::spawn(move || { tx.send(count_words(&t)).unwrap(); }); }
    drop(tx);
    rx.iter().fold(HashMap::new(), merge)
}

fn shared_mem(texts: Vec<String>) -> HashMap<String,usize> {
    let shared = Arc::new(Mutex::new(HashMap::<String,usize>::new()));
    let hs: Vec<_> = texts.into_iter().map(|t|{let s=Arc::clone(&shared); thread::spawn(move || {
        let local=count_words(&t); let mut g=s.lock().unwrap();
        for (k,v) in local { *g.entry(k).or_insert(0)+=v; }
    })}).collect();
    for h in hs { h.join().unwrap(); }
    Arc::try_unwrap(shared).unwrap().into_inner().unwrap()
}

fn main() {
    let texts = vec!["the fox".to_string(),"the cat".to_string(),"fox cat fox".to_string()];
    let r1 = msg_passing(texts.clone());
    let r2 = shared_mem(texts);
    println!("results match: {}", r1==r2);
    println!("fox count: {}", r1.get("fox").unwrap());
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test] fn test_same() {
        let t=vec!["a b c".to_string(),"a d".to_string()];
        assert_eq!(msg_passing(t.clone()), shared_mem(t));
    }
}
(* 465. Message passing vs shared memory โ€“ OCaml *)
let count_words text =
  List.length (List.filter ((<>) "") (String.split_on_char ' ' text))

(* Message passing: collect results *)
let (send,_,recv) =
  let q=Queue.create () let m=Mutex.create () let c=Condition.create () in
  (fun v -> Mutex.lock m; Queue.push v q; Condition.signal c; Mutex.unlock m),
  (),
  (fun () -> Mutex.lock m; while Queue.is_empty q do Condition.wait c m done;
    let v=Queue.pop q in Mutex.unlock m; v)

(* Shared memory: update global counter *)
let total=ref 0 let mutex=Mutex.create ()

let () =
  let texts=["hello world";"foo bar baz";"one two three four"] in
  let ts=List.map (fun t -> Thread.create (fun () -> send (count_words t)) ()) texts in
  List.iter Thread.join ts;
  let mp_total = List.fold_left (fun a _ -> a + recv ()) 0 texts in
  Printf.printf "message passing: %d\n" mp_total;

  let ts=List.map (fun t -> Thread.create (fun () ->
    let n=count_words t in Mutex.lock mutex; total:= !total+n; Mutex.unlock mutex) ()
  ) texts in
  List.iter Thread.join ts;
  Printf.printf "shared memory: %d\n" !total