#lockless

active_standby

A concurrency primitive for high concurrency reads

28 releases (2 stable)

2.0.0 May 31, 2022
1.0.0 Jan 3, 2022
0.9.2 Oct 15, 2021
0.8.4 Jun 27, 2021
0.4.3 Mar 13, 2021

#184 in Concurrency

Download history 14/week @ 2023-11-01 1/week @ 2023-11-08 3/week @ 2023-11-15 28/week @ 2023-11-22 113/week @ 2023-11-29 29/week @ 2023-12-13 28/week @ 2023-12-20 56/week @ 2023-12-27 27/week @ 2024-01-17 59/week @ 2024-01-24 85/week @ 2024-01-31 1/week @ 2024-02-07 314/week @ 2024-02-14

486 downloads per month

MIT/Apache

160KB
3.5K SLoC

A library for high concurrency reads.

This library is named after the 2 (identical) tables that are held internally:

  • Active - this is the table that all Readers view. This table will never be write locked, so readers never face contention.
  • Standby - this is the table that writers mutate. A writer should face minimal contention retrieving this table since Readers move to the Active table whenever calling .read().

There are 2 ways to use this crate:

  1. Direct interaction with AsLock/AsLockHandle. This is more flexible since users can pass in any struct they want and mutate it however they choose. All updates though, will need to be done by passing a function instead of via mutable methods (UpdateTables trait).
  2. Using collections which are built out of the primitives but which provide an API similar to RwLock<T>; writers can directly call to methods without having to provide a mutator function.

There are 2 flavors/modules:

  1. Lockless - this variant trades off increased performance against changing the API to be less like a RwLock. This centers around the AsLockHandle, which is conceptually similar to Arc<RwLock> (requires a separate AsLockHandle per thread/task).
  2. Sync - this centers around using an AsLock, which is meant to feel like a RwLock. The main difference is that you still cannot gain direct write access to the underlying table due to the need to keep them identical.

The cost of minimizing contention is:

  1. Memory - Internally there are 2 copies of the underlying type the user created. This is needed to allow there to always be a table that Readers can access out without contention.
  2. CPU - The writer must apply all updates twice, once to each table. Lock contention for the writer should be less than with a plain RwLock due to Readers using the active_table, so it's possible that write times themselves will drop.

Example

Example of the 3 usage patters: build your own wrapper, use prebuilt collections, and use the primitives. Each of these can be done with both sync and lockless.

use std::thread::sleep;
use std::time::Duration;
use std::sync::Arc;

// Create wrapper class so that users can interact with the active_standby
// struct via a RwLock-like interface. See the implementation of the
// collections for more examples.
mod wrapper {
    use active_standby::UpdateTables;

    active_standby::generate_lockless_aslockhandle!(i32);

    struct AddOne {}

    impl<'a> UpdateTables<'a, i32, ()> for AddOne {
        fn apply_first(&mut self, table: &'a mut i32) {
            *table = *table + 1;
        }
        fn apply_second(mut self, table: &mut i32) {
            self.apply_first(table);
        }
    }

    // Client's must implement the mutable interface that they want to
    // offer users. Non mutable functions are automatic via Deref.
    impl<'w> AsLockWriteGuard<'w> {
        pub fn add_one(&mut self) {
            self.guard.update_tables(AddOne {})
        }
    }
}

pub fn run_wrapper() {
    let table = wrapper::AsLockHandle::new(0);
    let table2 = table.clone();

    let handle = std::thread::spawn(move || {
        while *table2.read() != 1 {
            sleep(Duration::from_micros(100));
        }
    });

    table.write().add_one();
    handle.join();
}

// Use a premade collection which wraps `AsLock<Vec<T>>`, to provide an
// interface akin to `RwLock<Vec<T>>`.
pub fn run_collection() {
    use active_standby::sync::collections::AsVec;

    let table = Arc::new(AsVec::default());
    let table2 = Arc::clone(&table);

    let handle = std::thread::spawn(move || {
        while *table2.read() != vec![1] {
            sleep(Duration::from_micros(100));
        }
    });

    table.write().push(1);
    handle.join();
}

// Use the raw AsLock interface to update the underlying data.
pub fn run_primitive() {
    use active_standby::sync::AsLock;

    // If the entries in your table are large, you may want to hold only
    // 1 copy shared by both tables. This is safe so long as you never
    // mutate the shared data; only remove and replace it in the table.
    let table = Arc::new(AsLock::new(vec![Arc::new(1)]));
    let table2 = Arc::clone(&table);

    let handle = std::thread::spawn(move || {
        while *table2.read() != vec![Arc::new(2)] {
            sleep(Duration::from_micros(100));
        }
    });

    table.write().update_tables_closure(|table| {
        // Update the entry in the table, not the shared value behind the
        // Arc.
        table[0] = Arc::new(2);
    });
    handle.join();
}

fn main() {
    run_wrapper();
    run_collection();
    run_primitive();
}

Testing

There are a number of tests that come with active_standby (see tests/tests_script.sh for examples):

unittests

benchmarks

loom

LLVM Sanitizers

Miri

Rudra

Dependencies

~0.4–29MB
~370K SLoC