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 OnceTypeMap;
// Somewhere in a downstream crate:
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:
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:
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:
- 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. - Through
&self, a slot can only be created and filled once. - Slots are only mutated, removed or dropped through
&mut selfor by dropping the map. The borrow checker allows that only when no&Thanded 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_initruns 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
removeorclear.
Deadlocks
Neither of these cases panics or returns an error. They hang:
- Calling
get_or_init::<T>orinsert::<T>from inside the initializer for the sameT. This is a reentrantOnceLockinitialization. The standard library leaves the outcome unspecified, and today it deadlocks.insertdoesn't returnErrhere. - Initializers that depend on each other in a cycle. If
A's initializer usesBandB's usesA, a single thread hits the reentrant case above. With two threads, one starting withAand the other withB, 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
Resultitself withget_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 .0 .get_or_try_initA 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 asyncget_or_init(orget_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 ownget_or_initfrom async code, because callers waiting on it block their thread.
examples/fallible_extensions.rs shows the
first two cases side by side:
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 test suite includes a test that keeps a
reference alive while the map grows. Run it under
Miri with both aliasing models:
MIRIFLAGS="-Zmiri-tree-borrows"
License
Apache-2.0