xtc_chash(3)
---xtc_chash(3)
RCU-protected concurrent hash table
| XTC_CHASH(3) | Library Functions Manual | XTC_CHASH(3) |
NAME
xtc_chash_create,
xtc_chash_destroy,
xtc_chash_get,
xtc_chash_insert,
xtc_chash_remove,
xtc_chash_size,
xtc_chash_set_auto_shrink,
xtc_chash_get_auto_shrink —
RCU-protected concurrent hash table
SYNOPSIS
#include <xtc.h>
#include <xtc_chash.h>
#include <xtc_rcu.h>
int
xtc_chash_create(xtc_chash_cmp_fn
cmp, xtc_chash_hash_fn
hash, size_t
initial_capacity,
xtc_chash_t **out);
void
xtc_chash_destroy(xtc_chash_t
*h);
int
xtc_chash_get(xtc_chash_t
*h, const void
*key, void
**out_value);
int
xtc_chash_insert(xtc_chash_t
*h, void *key,
void *value,
void
**out_old_value);
int
xtc_chash_remove(xtc_chash_t
*h, const void
*key, void
**out_value);
size_t
xtc_chash_size(const
xtc_chash_t *h);
void
xtc_chash_set_auto_shrink(xtc_chash_t
*h, int on);
int
xtc_chash_get_auto_shrink(const
xtc_chash_t *h);
DESCRIPTION
xtc_chash is a chained hash table built on
xtc_rcu(3) so that lookups are wait-free -- no lock, no
atomic compare-exchange, just acquire-ordered loads down a bucket chain --
while inserts and removes still support many concurrent writers. It is the
primary consumer motivating xtc_rcu's existence in
this tree.
Keys and values are caller-owned void *
pointers stored verbatim, the same convention as
xtc_chan(3) and xtc_pdict(3): the table
never copies a key or value and never frees one. A caller-supplied
comparator cmp (the qsort(3)
convention -- only the == 0 vs. !=
0 distinction matters here) and hash function
hash make the table key-type-agnostic.
xtc_chash_create()
allocates a table with initial_capacity buckets,
rounded up to the next power of two (0 defaults to 16).
xtc_chash_destroy()
frees the table's own bookkeeping; it does not free caller-owned keys/values
and is not safe to call concurrently with any other call on the same table
-- quiesce first, exactly as xtc_rcu(3)'s
xtc_rcu_fini()
requires.
xtc_chash_get()
must be called inside the caller's own
xtc_rcu_read_lock()/xtc_rcu_read_unlock()
bracket (nesting is fine, see xtc_rcu(3)). On a hit it
writes the value pointer to *out_value and returns
XTC_OK; the pointer is valid only until the caller's
read-side ends. On a miss it returns
XTC_E_NOTFOUND.
xtc_chash_insert()
and
xtc_chash_remove()
take the read-side internally for their own traversal; the caller does not
need an outer read-side to call them, though nesting inside one is harmless.
xtc_chash_insert() replaces any existing entry for
an equal key, writing the old value pointer to
*out_old_value (NULL for a fresh insert; the argument
itself may be NULL). It may trigger a resize (a grow).
xtc_chash_remove() writes the removed value pointer
to *out_value (may be NULL) and returns
XTC_E_NOTFOUND on a miss.
xtc_chash_size()
returns the approximate live entry count -- exact with no concurrent
writers, a fresh-enough estimate otherwise.
xtc_chash_set_auto_shrink()
enables (on non-zero) or
disables (on zero) automatic
shrink on the table h; it is OFF by default.
xtc_chash_get_auto_shrink()
returns the current setting as 0 or 1. Both take effect immediately and are
safe to call from any thread (the flag is a single atomic field). See
RESIZE.
CONCURRENCY AND RECLAMATION
Writers serialize through a fixed set of 64 stripe mutexes; a key's stripe is derived from its bucket index in the CURRENT array (so two keys in the same bucket always take the same stripe lock -- a correctness requirement, not just an optimisation), and two writers touching different buckets that happen to share a stripe only pay a false-sharing serialization cost, never a correctness one.
A removed node is unlinked by overwriting its
predecessor's link with a single release-ordered store of the removed node's
own (unchanged) successor -- the node's own next field
is never touched, so a reader already parked inside it can finish walking
the rest of the chain exactly as it stood when it arrived. The node itself
is handed to
xtc_rcu_retire(),
never freed synchronously: a concurrent reader that already loaded a pointer
to it keeps a fully valid node until it leaves its read-side.
A resize claims every stripe lock (blocking
writers, never readers), duplicates every live node into a freshly allocated
array of the new size, publishes the new array with a release store, and
retires the old array and its original nodes via
xtc_rcu_retire().
A reader that loaded the old array before the swap keeps working against a
fully valid table -- larger or smaller than the new one -- until it leaves
its read-side.
This module never calls
xtc_rcu_synchronize()
itself; as with every other xtc_rcu(3) consumer, actual
reclamation of retired nodes happens on the caller's, a test's, or an
application-installed reaper's schedule.
RESIZE
xtc_chash always GROWS: it doubles its
bucket array past a 75% load factor. It SHRINKS only when auto-shrink is
enabled via xtc_chash_set_auto_shrink() (OFF by
default), in which case a xtc_chash_remove() that
drops the load factor below 18.75% halves the bucket array -- never below
the initial capacity passed to xtc_chash_create()
(minimum 16). Shrink runs the identical RCU-swap machinery as grow, only in
reverse (two old buckets merge into one, deterministic because sizes are)
powers of two , so every invariant above holds in both directions.
The wide gap between the grow trigger
(0.75) and the shrink trigger (0.1875) is deliberate hysteresis. A shrink
halves the array, which doubles the load factor to below 0.375; climbing
back to a grow then needs a roughly 2x increase in live entries and dropping
back to another shrink a further roughly 2x decrease, so no single
xtc_chash_insert()
or
xtc_chash_remove()
near a boundary can make the table thrash grow<->shrink.
Grow is always-on because memory then tracks the true working-set peak monotonically. Shrink is opt-in because it adds a rehash to the remove path and reclaims memory a caller may have wanted stable: enable it if the workload has large delete phases and you want the memory reclaimed.
RETURN VALUES
XTC_OK on success;
XTC_E_INVAL for a NULL table/key or missing
comparator/hash at create; XTC_E_NOTFOUND on a
get/remove miss; XTC_E_NOMEM on allocation
failure.
EXAMPLES
static int
str_cmp(const void *a, const void *b)
{
return strcmp((const char *)a, (const char *)b);
}
static uint64_t
str_hash(const void *key)
{
const char *s = key;
uint64_t h = 1469598103934665603ULL;
for (; *s != '\0'; s++) { h ^= (uint8_t)*s; h *= 1099511628211ULL; }
return h;
}
xtc_chash_t *h;
xtc_chash_create(str_cmp, str_hash, 64, &h);
xtc_chash_insert(h, "answer", (void *)(intptr_t)42, NULL);
xtc_rcu_read_lock();
void *v;
if (xtc_chash_get(h, "answer", &v) == XTC_OK)
printf("%ld\n", (intptr_t)v);
xtc_rcu_read_unlock();
xtc_chash_destroy(h);
SEE ALSO
HISTORY
Appeared in xtc 0.1, M13a.
| July 13, 2026 | Debian |