papaya

repository·master·Indexed 21 days ago

https://github.com/ibraheemdev/papaya

A high-performance, concurrent hash-table implementation in Rust optimized for read-heavy workloads. It features a lock-free API to prevent deadlocks, incremental resizing to maintain consistent latency, and integration with the seize crate for memory management. The library provides atomic operations via methods like update and compute, and supports async contexts through OwnedGuards.

Tokens
11.2K
Snippets
50
Records
55
Agent score
75%

What's inside papaya

  1. Overview of papaya concurrent hash-table

    master
    papaya is a fast and ergonomic concurrent hash-table designed specifically for read-heavy workloads. It provides a lock-free API to prevent deadlocks, supports powerful atomic operations, and integrates seamlessly into async contexts. It is optimized for high-throughput, low-latency reads that scale with concurrency and uses the seize crate for efficient memory management and garbage collection.
  2. Understand papaya's performance characteristics and workload suitability

    master

    When choosing papaya for your project, consider the following performance characteristics:

    • Read-Heavy Workloads: papaya is highly optimized for read-heavy workloads and outperforms many competitors in this category. A core guarantee is that reads never block under any circumstances, ensuring consistent read latency regardless of write concurrency.
    • Write-Heavy Workloads: papaya may fall short in update-heavy workloads due to allocator pressure and the overhead of memory reclamation required to maintain lock-free reads. If your workload is primarily write-heavy and does not require lock-free reads, consider an alternative hash-table implementation.
    • Latency Distribution: papaya provides more consistent latency distribution compared to implementations like dashmap. This is achieved through incremental resizing and the absence of bucket locks, which helps keep tail latency significantly lower (often by several orders of magnitude).

    Note: Benchmarks are workload-specific. Always measure performance using your specific workload before making architectural decisions.

  3. Performance characteristics of papaya

    master

    papaya is optimized for workloads where read operations are more frequent than writes.

    Key performance features include:

    • Scalable Reads: Extremely high throughput and low latency that scales with concurrency.
    • Predictable Latency: Most operations are lock-free; non-lock-free operations only block under rare, constrained conditions.
    • Incremental Resizing: Uses incremental resizing to maintain consistent performance during table growth.
    • Competitive Writes: While optimized for reads, it maintains competitive performance in write-heavy workloads.
  4. How pinning works in papaya HashMap

    master

    To interact with a papaya::HashMap, you must use a pin. Because the map is concurrent and entries can be removed, papaya uses pinning to ensure that references returned by the map remain valid for the duration of the pin's lifetime.

    • A pin acts like a lock guard: it ties the lifetime of any returned references to the guard itself.
    • Unlike a standard Mutex lock, pinning is cheap and cannot cause deadlocks.
    • Important: Holding a guard prevents the map from performing garbage collection. While pinning is relatively inexpensive, you should avoid holding guards indefinitely. Reuse guards where reasonable to minimize the cost of pinning/unpinning.
    use papaya::HashMap;
    
    // Create a map.
    let map = HashMap::new();
    
    // Pin the map to get a guard.
    let map = map.pin();
    
    // Use the map as normal. References are tied to the lifetime of `map`.
    map.insert('A', 1);
    assert_eq!(map.get(&'A'), Some(&1));
  5. Perform atomic updates with `HashMapRef::update` and `compute`

    master

    To modify values in a concurrent environment, use the atomic update methods provided by HashMapRef:

    1. update: Takes a closure Fn(&V) -> V to transform the existing value. Returns Option<&V> (the new value if it existed, or None if nothing was updated).
    2. update_or_insert_with: Updates an existing entry or inserts a new one using a closure if the key is missing.
    3. compute: The most powerful atomic operation. It uses a closure FnMut(Option<(&K, &V)>) -> Operation<V, T> to decide the next state of the entry (Insert, Update, Remove, or Abort) based on its current state.
    // Example of using update on a HashMapRef
    let new_val = map_ref.update(key, |old_val| {
        // logic to transform old_val
        new_value
    });
  6. How to access `HashSet` elements using Guards and Pinning

    master

    Because HashSet is concurrent, most operations require a Guard to ensure memory safety and prevent garbage collection of active nodes. There are three primary ways to interact with the set:

    1. Manual Guarding: Call .guard() to get a LocalGuard. This is useful for short-lived, synchronous operations. You must pass this guard to methods like .get(), .insert(), or .contains().
    2. Pinning (pin): Call .pin() to get a HashSetRef with a LocalGuard. This provides a convenient interface where you don't have to pass the guard to every method call manually.
    3. Owned Pinning (pin_owned): Call .pin_owned() to get a HashSetRef with an OwnedGuard. Unlike pin(), this reference implements Send and Sync, making it safe to hold across .await points in async/work-stealing schedulers (e.g., when using iterators in an async task).
    // 1. Manual Guarding
    let guard = set.guard();
    set.insert(1, &guard);
    let exists = set.contains(&1, &guard);
    
    // 2. Pinning (Local)
    let pinned = set.pin();
    pinned.insert(1);
    assert!(pinned.contains(&1));
    
    // 3. Owned Pinning (Send/Sync for async)
    let owned_pinned = set.pin_owned();
    // This can be moved into an async block or held across .await
  7. How Guards and Pinning work in HashMap

    master

    Most HashMap operations require a Guard to ensure memory safety and prevent garbage collection of entries currently in use. You can acquire access to the map in two ways:

    1. Manual Guards: Use map.guard() to get a LocalGuard or map.owned_guard() to get an OwnedGuard. OwnedGuard implements Send and Sync, making it suitable for use across .await points in async code.
    2. Pinning: Use map.pin() for a local reference or map.pin_owned() for a reference that can be held across await points. Pinning manages the guard internally.

    Warning: Holding a guard prevents garbage collection from reclaiming memory.

    // Using pin() for local synchronous access
    map.pin().insert(1, "a");
    
    // Using pin_owned() for async/work-stealing schedulers
    let pinned = map.pin_owned();
    // pinned can now be held across .await points
  8. Configure a HashMap using HashMapBuilder

    master

    For complex configurations, use HashMap::builder() to create a HashMapBuilder. This allows you to customize the initial capacity, the hasher, the garbage collection strategy, and the resizing behavior.

    Note that manually setting a hasher can expose the map to DoS attacks if the hasher is not cryptographically secure or randomly generated.

    use papaya::{HashMap, ResizeMode};
    use seize::Collector;
    use std::collections::hash_map::RandomState;
    
    let map: HashMap<i32, i32> = HashMap::builder()
        // Set the initial capacity.
        .capacity(2048)
        // Set the hasher.
        .hasher(RandomState::new())
        // Set the resize mode.
        .resize_mode(ResizeMode::Blocking)
        // Set a custom garbage collector.
        .collector(Collector::new().batch_size(128))
        // Construct the hash map.
        .build();
  9. Use papaya in async contexts with owned guards

    master

    By default, a pinned map guard (LocalGuard) is not Send because it is tied to the current thread. This prevents you from holding a reference across an .await point in work-stealing schedulers (like Tokio).

    To use the map across .await points, use pin_owned() to create an OwnedGuard.

    Note: OwnedGuard is more expensive to create than a regular guard. If you only need a value briefly, it is more efficient to drop the guard, perform the async operation, and then re-pin the map to fetch the value again.

    use std::sync::Arc;
    use papaya::HashMap;
    
    async fn run(map: Arc<HashMap<i32, String>>) {
        tokio::spawn(async move {
            // Use pin_owned() to allow the guard to be Send
            let map = map.pin_owned();
    
            // The reference can now be held across this .await
            let value = map.get(&37);
            tokio::fs::write("db.txt", format!("{value:?}")).await;
            println!("{value:?}");
        });
    }
  10. Handle advanced lifetimes with the Guard trait

    master

    When returning references to data contained within a HashMap that is part of a larger struct, you cannot return a reference tied to a temporary pin. Instead, you should use the Guard trait to allow the caller to provide the lifetime.

    To implement this:

    1. Provide a method that returns an object implementing Guard (using map.guard()).
    2. Use a generic lifetime 'guard in your methods that accepts &'guard impl Guard to tie the returned reference to the caller's guard.
    use papaya::Guard;
    
    pub struct Metrics {
        map: papaya::HashMap<String, Vec<u64>>
    }
    
    impl Metrics {
        // Returns a guard that the caller can use to manage lifetimes
        pub fn guard(&self) -> impl Guard + '_ {
            self.map.guard()
        }
    
        // The returned reference is tied to the lifetime of the provided guard
        pub fn get<'guard>(&self, name: &str, guard: &'guard impl Guard) -> Option<&'guard [u64]> {
            self.map.get(name, guard)?.as_slice()
        }
    }
  11. Configure HashMap ResizeMode

    master

    The ResizeMode determines how the HashMap handles table expansion when it becomes full. This is configured via HashMapBuilder::resize_mode.

    • Incremental(usize): (Default, chunk size 64) Writers copy a constant number of pairs to the new table during inserts. This avoids latency spikes but can reduce overall throughput and requires searching both tables during a resize.
    • Blocking: All writes must wait until the resize completes. This typically offers higher throughput (especially with multiple writers) but causes latency spikes during the resize operation.
  12. Use papaya HashMap across multiple threads

    master

    Since papaya::HashMap operations take a shared reference (&self), you can freely pin and access the map from multiple threads simultaneously. Each thread creates its own pin to interact with the map.

    use papaya::HashMap;
    
    let map = HashMap::new();
    std::thread::scope(|s| {
        // Thread 1: Insert values
        s.spawn(|| {
            let map = map.pin();
            for i in 'A'..='Z' {
                map.insert(i, 1);
            }
        });
    
        // Thread 2: Remove values
        s.spawn(|| {
            let map = map.pin();
            for i in 'A'..='Z' {
                map.remove(&i);
            }
        });
    
        // Thread 3: Read values via iterator
        s.spawn(|| {
            for (key, value) in map.pin().iter() {
                println!("{key}: {value}");
            }
        });
    });