Skip to main content

concurrent/
concurrent.rs

1//! Many threads, one database.
2//!
3//! Shows the two properties that motivate MVCC in the first place — readers are
4//! never blocked by writers, and a long reader sees a stable world — plus the
5//! retry loop that contended writers need.
6//!
7//!     cargo run --release --example concurrent
8
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
11use std::thread;
12use std::time::{Duration, Instant};
13
14use mvcc::{Config, Database, Mvcc, Result, Serializable};
15
16#[derive(Mvcc, Clone, Debug)]
17#[mvcc(table = "accounts")]
18struct Account {
19    #[mvcc(primary_key)]
20    id: u64,
21    balance: i64,
22}
23
24const ACCOUNTS: u64 = 64;
25const TRANSFER_THREADS: usize = 8;
26const TRANSFERS_PER_THREAD: usize = 500;
27const STARTING_BALANCE: i64 = 1_000;
28
29fn main() -> Result<()> {
30    let db = Arc::new(Database::open(Config::in_memory())?);
31    db.register::<Account>()?;
32
33    db.transaction(|tx| {
34        for id in 0..ACCOUNTS {
35            tx.insert(Account {
36                id,
37                balance: STARTING_BALANCE,
38            })?;
39        }
40        Ok(())
41    })?;
42
43    let total_before = total(&db)?;
44    println!("starting total: {total_before}");
45
46    let stop = Arc::new(AtomicBool::new(false));
47    let reads = Arc::new(AtomicU64::new(0));
48    let retries = Arc::new(AtomicU64::new(0));
49
50    // ---- a reader that must never observe a torn transfer -----------------
51    // Money moves between accounts constantly. Under snapshot isolation this
52    // thread's scans must always sum to the same total: it either sees both
53    // halves of a transfer or neither.
54    let auditor = {
55        let db = Arc::clone(&db);
56        let stop = Arc::clone(&stop);
57        let reads = Arc::clone(&reads);
58        thread::spawn(move || -> Result<()> {
59            while !stop.load(Ordering::Relaxed) {
60                let mut tx = db.begin();
61                let sum: i64 = tx.scan::<Account>()?.iter().map(|a| a.balance).sum();
62                assert_eq!(
63                    sum,
64                    ACCOUNTS as i64 * STARTING_BALANCE,
65                    "a reader observed a partially applied transfer"
66                );
67                reads.fetch_add(1, Ordering::Relaxed);
68            }
69            Ok(())
70        })
71    };
72
73    // ---- writers ----------------------------------------------------------
74    let start = Instant::now();
75    let mut writers = Vec::new();
76    for t in 0..TRANSFER_THREADS {
77        let db = Arc::clone(&db);
78        let retries = Arc::clone(&retries);
79        writers.push(thread::spawn(move || -> Result<()> {
80            // A cheap per-thread PRNG; no dependency needed for this.
81            let mut seed = 0x9e37_79b9_7f4a_7c15u64 ^ (t as u64 + 1);
82            let mut next = move || {
83                seed ^= seed << 13;
84                seed ^= seed >> 7;
85                seed ^= seed << 17;
86                seed
87            };
88
89            for _ in 0..TRANSFERS_PER_THREAD {
90                let from = next() % ACCOUNTS;
91                let to = next() % ACCOUNTS;
92                if from == to {
93                    continue;
94                }
95
96                let mut attempts = 0u64;
97                // Serializable, because the transfer's *write* depends on a
98                // balance it *read* — the write-skew shape. Snapshot isolation
99                // would let two concurrent transfers both pass the check.
100                db.transaction_with::<Serializable, _, _>(|tx| {
101                    attempts += 1;
102                    let balance = tx.get::<Account>(&from)?.map(|a| a.balance).unwrap_or(0);
103                    if balance < 10 {
104                        return Ok(());
105                    }
106                    tx.update::<Account>(&from, |a| a.balance -= 10)?;
107                    tx.update::<Account>(&to, |a| a.balance += 10)?;
108                    Ok(())
109                })?;
110                retries.fetch_add(attempts - 1, Ordering::Relaxed);
111            }
112            Ok(())
113        }));
114    }
115
116    for w in writers {
117        w.join().expect("writer panicked")?;
118    }
119    let elapsed = start.elapsed();
120
121    stop.store(true, Ordering::Relaxed);
122    auditor.join().expect("auditor panicked")?;
123
124    // ---- results ----------------------------------------------------------
125    let total_after = total(&db)?;
126    let committed = (TRANSFER_THREADS * TRANSFERS_PER_THREAD) as u64;
127
128    println!("final total:    {total_after}");
129    assert_eq!(total_before, total_after, "money was created or destroyed");
130
131    println!("\n{committed} transfers in {elapsed:.2?}");
132    println!(
133        "  {:.0} transfers/sec",
134        committed as f64 / elapsed.as_secs_f64()
135    );
136    println!(
137        "  {} concurrent audit scans completed, none of them blocked",
138        reads.load(Ordering::Relaxed)
139    );
140    println!(
141        "  {} retries ({:.1} per transfer)",
142        retries.load(Ordering::Relaxed),
143        retries.load(Ordering::Relaxed) as f64 / committed as f64
144    );
145
146    // The GC watermark should be advancing. If `active_transactions` climbs and
147    // the watermark stalls, some transaction is being leaked — see engine::gc.
148    let stats = db.stats();
149    println!(
150        "\ngc watermark {:?}, {} transactions still live",
151        stats.watermark, stats.active_transactions
152    );
153
154    println!("\n✓ no torn reads, no lost updates, conservation held");
155
156    // Slow readers are what pin the GC watermark. Left here as the shape of the
157    // thing to watch for, not as something this example demonstrates going wrong.
158    thread::sleep(Duration::from_millis(1));
159    Ok(())
160}
161
162fn total(db: &Database) -> Result<i64> {
163    let mut tx = db.begin();
164    Ok(tx.scan::<Account>()?.iter().map(|a| a.balance).sum())
165}