once_type_map 0.1.1

Thread-safe, insert-once type map that hands out plain references (no guards), like a OnceLock keyed by type
Documentation
  • Coverage
  • 100%
    12 out of 12 items documented8 out of 12 items with examples
  • Size
  • Source code size: 74.4 kB This is the summed size of all the files inside the crates.io package for this release.
  • Documentation size: 174.5 kB This is the summed size of all files generated by rustdoc for all configured targets
  • Ø build duration
  • this release: 2s Average build duration of successful builds.
  • all releases: 2s Average build duration of successful builds in releases after 2024-10-23.
  • Links
  • noclue/once_type_map
    0 0 0
  • crates.io
  • Dependencies
  • Versions
  • Owners
  • noclue

once_type_map

A thread-safe, insert-once type map that hands out plain &T references, not lock guards.

You can think of it as a OnceLock per type, for types you don't know when you write the owning struct. Library authors can embed one OnceTypeMap in a long-lived object (a client, a session, an app context). Downstream crates can then attach their own state to that object, such as caches, adapters, or implementers of extra traits, without the original struct knowing about them.

use once_type_map::OnceTypeMap;

struct Client {
    extensions: OnceTypeMap,
}

impl Client {
    pub fn extensions(&self) -> &OnceTypeMap {
        &self.extensions
    }
}

// Somewhere in a downstream crate:
struct MyCache { /* ... */ }

fn cache(client: &Client) -> &MyCache {
    client.extensions().get_or_init(|| MyCache { /* expensive setup */ })
}

examples/extensions.rs shows the full pattern. A library defines the extensible Client, and two independent extension modules add methods to it through extension traits. The application imports those traits and uses them, including from several threads:

cargo run --example extensions

examples/async_extensions.rs shows the same idea when the extension's state has to be created asynchronously, for example by a login call. It stores a tokio::sync::OnceCell in the map, so concurrent tasks wait for a single login without blocking the executor:

cargo run --example async_extensions

Why references instead of guards?

Typical type maps (http::Extensions, anymap, RwLock<HashMap<TypeId, Box<dyn Any>>>) either need &mut self to insert or return a guard that borrows the lock. Both make it hard for a wrapper type to expose fn foo(&self) -> &Foo.

OnceTypeMap returns a &T that lives as long as the borrow of the map. This is sound because of three rules:

  1. Each type's value lives in its own heap-allocated OnceLock<T> slot. When the internal hash map grows, it moves the pointer to the slot, not the slot. That pointer is an Arc that is never cloned, rather than a Box, because whether moving a Box invalidates references into it is still open in Rust's aliasing rules. An Arc makes no uniqueness claim about what it points to.
  2. Through &self, a slot can only be created and filled once.
  3. Slots are only mutated, removed or dropped through &mut self or by dropping the map. The borrow checker allows that only when no &T handed out by the map is still alive.

The same reasoning is why OnceLock itself can offer get_mut and take.

API

Method Receiver Behavior
get::<T>() &self Some(&T) if set
get_or_init(f) &self Returns the value, running f first if it is unset
insert(value) &self Ok(&T) if it was unset, otherwise gives the value back in Err
contains::<T>() &self Whether a value is set
len() / is_empty() &self Number of types whose value is set
get_mut::<T>() &mut self Some(&mut T) if set
remove::<T>() &mut self Takes the value out; the type can be set again
clear() &mut self Removes all values and frees all slots

Initialization semantics

  • get_or_init runs the initializer outside the map's lock. A slow initializer for one type doesn't block other types, and an initializer may use the map for other types, as long as initializers don't depend on each other in a cycle (see below).
  • When many threads race to initialize the same type, only one initializer runs. The other threads block until the value is ready. Once an initializer returns, no other initializer for that type runs until the value is removed.
  • If the initializer panics, the value stays unset and the next call runs its own initializer. So an initializer can run more than once over the life of the map: after a panic, and after remove or clear.

Deadlocks

Neither of these cases panics or returns an error. They hang:

  • Calling get_or_init::<T> or insert::<T> from inside the initializer for the same T. This is a reentrant OnceLock initialization. The standard library leaves the outcome unspecified, and today it deadlocks. insert doesn't return Err here.
  • Initializers that depend on each other in a cycle. If A's initializer uses B and B's uses A, a single thread hits the reentrant case above. With two threads, one starting with A and the other with B, each blocks forever waiting for the other's slot.

Empty slots

A type's slot is created the first time get_or_init or insert is called for it. If the initializer panics, the empty slot stays in the map. Nothing called through &self removes it, though a later successful initialization reuses it. This costs at most one small allocation per distinct type, but len() and is_empty() scan empty slots along with full ones. remove::<T>() and clear() free empty slots too.

Caveats

  • One value per type. Use newtypes to store several values of the same underlying type.
  • Values must be Send + Sync + 'static.
  • Initializers are synchronous and can't fail. See the next section.
  • A lookup takes a shared read lock. Because the returned reference isn't tied to a guard, hot paths can look a value up once and keep the &T.
  • The minimum supported Rust version is 1.86.

Fallible and async initialization

std's OnceLock::get_or_try_init isn't stable yet, so the map has no get_or_try_init. What to store instead depends on what a failure means:

  • The failure is final (a malformed setting, say). Store the Result itself with get_or_init. Every caller sees the same outcome, and setup runs once.

  • The failure is temporary (a server that isn't up yet). Store a cell that supports a fallible initializer, such as once_cell::sync::OnceCell:

    map.get_or_init(|| DbCell(OnceCell::new()))
        .0
        .get_or_try_init(|| connect())
    

    A failed attempt stores nothing, so the next call tries again, and only one attempt runs at a time.

  • Setup is async. Store an async cell such as tokio::sync::OnceCell. The map only creates the empty cell, and the cell's async get_or_init (or get_or_try_init, if setup can also fail) does the work without blocking the executor. Don't run slow or blocking setup in the map's own get_or_init from async code, because callers waiting on it block their thread.

examples/fallible_extensions.rs shows the first two cases side by side:

cargo run --example fallible_extensions

Once std stabilizes OnceLock::get_or_try_init, the map will gain a matching method.

Checking the unsafe code

All unsafe code goes through one private unsafe fn, detach. It extends a slot reference from the lock guard's lifetime to the borrow of the map. There are three unsafe blocks: the one in the body of detach, and one at each of its two call sites (slot and create_slot). Each call site has to meet detach's precondition: the slot must be owned by the map. Any new call to detach needs the same review.

The tests keep a reference alive while the map grows, and while every slot is moved through ordinary typed code. Run them under Miri with both aliasing models:

cargo +nightly miri test
MIRIFLAGS="-Zmiri-tree-borrows" cargo +nightly miri test

License

Apache-2.0