Read-Copy Update (RCU)
Lock-free reader synchronization for read-heavy workloads — readers never block; writers retire old data after a guaranteed grace period.
RCU (Read-Copy Update)since C++26A synchronization mechanism where readers access shared data with a single atomic load and no locking, while writers publish new versions atomically and defer reclamation of old versions until every thread has passed through a quiescent state — a moment when it holds no live reference to RCU-protected data.
Overview
RCU solves the same problem as a reader-writer lock — coordinating readers and writers on shared mutable data — but with fundamentally different performance characteristics. A reader-writer lock serializes every reader behind any ongoing write; RCU eliminates all reader-side synchronization. The read path is a single memory_order_acquire atomic load followed by ordinary pointer dereference. No mutex, no CAS, no per-thread counter.
This makes RCU ideal for data that is read millions of times per second but updated infrequently: routing tables, DNS caches, security policy configurations, feature-flag snapshots, subscriber registries.
The cost is asymmetric write complexity. A writer cannot update in place. It must:
- Allocate a new version of the protected object.
- Atomically publish the new pointer (replacing the shared
std::atomic<T*>). - Retire the old pointer — scheduling its destruction after a grace period elapses.
A grace period is the minimum interval during which every thread has passed through at least one quiescent state. The RCU domain monitors these transitions and invokes each retired object's deleter only after a complete grace period has elapsed, guaranteeing no thread still holds a reference to the object being destroyed.
RCU vs hazard pointers: both enable lock-free reads. Hazard pointers require each reader to register the exact pointer it is about to dereference in a shared hazard record before use — per-access write traffic, but immediate per-object protection. RCU requires no per-access registration; instead the domain tracks per-thread quiescent states, making reads cheaper but requiring that read-side critical sections be bounded in duration and not persist across thread sleep or synchronization boundaries.
C++26 provides RCU via the <rcu> header. The implementation strategy (epoch counters, quiescent-state barriers, periodic scans) is left unspecified; only the observable guarantees are standardized.
Syntax
// <rcu> — C++26
namespace std {
// Manages retired objects and tracks grace periods across threads.
class rcu_domain { /* implementation-defined */ };
// Returns the process-wide default domain.
rcu_domain& rcu_default_domain() noexcept;
// CRTP mixin that adds a retire() member to derived types.
template<class T, class D = std::default_delete<T>>
class rcu_obj_base {
public:
// Enqueue *this for deletion after the next complete grace period.
void retire(D d = D{},
rcu_domain& domain = rcu_default_domain()) noexcept;
protected:
rcu_obj_base() = default;
rcu_obj_base(const rcu_obj_base&) = default;
rcu_obj_base& operator=(const rcu_obj_base&) = default;
};
// Retire p on domain: call d(p) after a complete grace period.
// Non-blocking — returns before deletion occurs.
template<class T, class D = std::default_delete<T>>
void rcu_retire(T* p, D d = D{},
rcu_domain& domain = rcu_default_domain());
// Block the caller until a complete grace period has elapsed on domain.
// Any object retired before this call is guaranteed deleted on return.
void rcu_synchronize(rcu_domain& domain = rcu_default_domain()) noexcept;
// Block until every retire callback enqueued on domain has been invoked.
void rcu_barrier(rcu_domain& domain = rcu_default_domain()) noexcept;
} // namespace stdrcu_retire is asynchronous — it enqueues and returns immediately. rcu_synchronize is its synchronous counterpart: after it returns, every object retired before the call is deleted. rcu_barrier is narrower: it waits only for the invocation of all queued callbacks, not necessarily for a full new grace period.
Examples
Read-heavy configuration snapshot
#include <rcu> // C++26
#include <atomic>
#include <cstdint>
#include <string>
struct ProxyConfig : std::rcu_obj_base<ProxyConfig> { // C++26
std::string upstream_host;
std::uint16_t upstream_port{443};
int timeout_ms{5000};
bool tls_enabled{true};
};
class ProxyRouter {
public:
// Read path: a single acquire-load. No blocking, no allocation.
// Do not persist this pointer beyond the calling scope.
const ProxyConfig* config() const noexcept {
return config_.load(std::memory_order_acquire);
}
// Write path: copy-update-swap-retire.
void reconfigure(ProxyConfig next) {
auto* incoming = new ProxyConfig{std::move(next)};
ProxyConfig* old = config_.exchange(incoming,
std::memory_order_acq_rel);
if (old) {
old->retire(); // C++26: deleted after the next grace period
}
}
~ProxyRouter() {
delete config_.exchange(nullptr, std::memory_order_acq_rel);
std::rcu_barrier(); // C++26: drain all pending retire callbacks
}
private:
std::atomic<ProxyConfig*> config_{nullptr};
};
// Hot path — called from many threads simultaneously.
void forward_request(const ProxyRouter& router,
std::string_view request_body) {
const ProxyConfig* cfg = router.config();
if (!cfg) return;
// Use cfg entirely within this scope.
connect_and_send(cfg->upstream_host, cfg->upstream_port,
cfg->tls_enabled, request_body);
}Synchronous reclaim with rcu_synchronize
Use rcu_synchronize when the write side needs a hard guarantee before proceeding — for example, when the old object owns resources that must be released in order:
void ordered_reconfigure(ProxyRouter& router, ProxyConfig next) {
auto* incoming = new ProxyConfig{std::move(next)};
// Swap atomically so new readers see `incoming` immediately.
ProxyConfig* old = router.swap_raw(incoming); // your atomic exchange
// Block until every thread that loaded `old` is provably done with it.
std::rcu_synchronize(); // C++26
// Safe to inspect and clean up `old` here before releasing memory.
audit_log("retired config", old->upstream_host);
delete old;
}Retiring types that cannot inherit rcu_obj_base
Third-party structs, C types, or final classes cannot inherit from rcu_obj_base. Use the free function rcu_retire with a custom deleter:
#include <rcu> // C++26
#include <atomic>
struct ssl_ctx_wrapper {
SSL_CTX* ctx{};
int generation{};
};
std::atomic<ssl_ctx_wrapper*> g_ssl_ctx{nullptr};
void rotate_ssl_context(SSL_CTX* new_ctx, int gen) {
auto* next = new ssl_ctx_wrapper{new_ctx, gen};
ssl_ctx_wrapper* old = g_ssl_ctx.exchange(next,
std::memory_order_acq_rel);
if (old) {
// C++26: free SSL_CTX, then delete the wrapper, after grace period
std::rcu_retire(old, [](ssl_ctx_wrapper* p) {
SSL_CTX_free(p->ctx);
delete p;
});
}
}Dedicated domain for isolated subsystems
#include <rcu> // C++26
// Separate domain prevents TLS-subsystem retirements from delaying
// grace periods in the routing subsystem and vice versa.
inline std::rcu_domain& tls_rcu_domain() {
static std::rcu_domain d;
return d;
}
void retire_ssl_config(SslConfig* old) {
std::rcu_retire(old, std::default_delete<SslConfig>{}, tls_rcu_domain());
}
void flush_tls_retirements() {
std::rcu_barrier(tls_rcu_domain()); // C++26
}Grace Period Internals (Epoch Counters)
The C++26 standard leaves RCU implementation strategy unspecified, but the dominant userspace technique uses an epoch counter — a single global integer that increments whenever a new grace period begins. Understanding this mental model clarifies why certain usage rules exist and what the performance bounds mean in practice.
How epoch tracking works
Each rcu_domain maintains a global epoch (e.g. std::atomic<uint64_t> global_epoch) and a per-thread "observed epoch" stored in thread-local storage. The protocol is:
- When a thread enters a quiescent state (its RCU read-side critical section ends), it writes
global_epoch.load(acquire)into its thread-local slot. - A grace period completes when the domain observes that every registered thread's local epoch is at least as large as the epoch that was current when the first retired object was enqueued.
- Deletion of a batch of retired objects is deferred until that condition holds. No lock is held during this scan; each thread-local slot is written only by its owning thread, so the domain reads them without contention.
// Simplified pseudo-implementation — NOT the standard API, for illustration only
namespace pseudo_rcu {
constinit std::atomic<std::uint64_t> global_epoch{0};
thread_local std::uint64_t local_epoch = 0;
// Called implicitly when a thread's read-side critical section ends.
inline void quiescent_state() noexcept {
local_epoch = global_epoch.load(std::memory_order_acquire);
}
struct RetiredItem {
void* ptr;
void (*deleter)(void*);
std::uint64_t epoch_at_retire; // grace period must pass this epoch
};
// Domain scans all threads: if every local_epoch >= item.epoch_at_retire,
// the item's grace period has elapsed and it is safe to delete.
void try_reclaim(std::vector<RetiredItem>& queue) {
std::uint64_t safe_epoch = min_across_all_threads(local_epoch);
std::erase_if(queue, [&](const RetiredItem& item) {
if (safe_epoch > item.epoch_at_retire) {
item.deleter(item.ptr);
return true;
}
return false;
});
}
} // namespace pseudo_rcuThe key insight is that readers never write to any shared structure — they only read global_epoch and update their own thread-local slot. The domain's reclamation thread does the scanning, completely out of the read path.
Quiescent state vs. epoch: kernel vs. userspace RCU
Linux kernel RCU uses an explicit quiescent-state model: every context switch, idle loop iteration, and user-space return is a guaranteed quiescent state, giving the kernel scheduler natural synchronization points. Userspace RCU cannot rely on these, so it models quiescent states as epoch observations instead. A thread that never yields — a busy-spinning loop — still advances its epoch at the end of each critical section, which is why std::rcu_synchronize may need to actively wait for every thread to call into the domain at least once.
This difference also explains the long-lived critical section prohibition: a thread blocked on a mutex or sleeping in I/O will not update its epoch, stalling the grace period for all other threads sharing that domain.
RCU vs Hazard Pointers Decision Table
Both RCU and std::hazard_pointer (C++26, <hazard_pointer>) solve the same core problem — allowing lock-free reads of shared data with safe reclamation — but they make opposing trade-offs. The table below covers the axes that matter most for choosing between them.
| Dimension | std::rcu | std::hazard_pointer |
|---|---|---|
| Reader overhead | Single acquire load; no writes | Two atomic ops: protect write + reload to confirm |
| Writer complexity | Retire pointer; domain handles reclamation timing | Must scan all hazard records before deleting |
| Reclamation latency | Unbounded — waits for all threads to quiesce | Near-immediate — delete as soon as no hazard record covers the pointer |
| Memory overhead | Per-thread epoch slot per domain (typically 8 bytes) | Per-registered-pointer hazard record slot (typically one per thread) |
| Object lifetime after retire | Lives until next grace period; bounded by slowest thread | Lives until writer scans hazard records; bounded by number of registered pointers |
| Fine-grained per-object protection | No — entire domain advances together | Yes — each hazard record protects a single pointer independently |
| Read-side in blocking code | Prohibited — critical sections must be short | Permitted — hazard record held as long as needed |
| Typical use case | Read-heavy routing tables, config, policy snapshots | Per-node concurrent linked lists, hash tables, lock-free queues |
When to choose RCU
Prefer RCU when: the read-to-write ratio is high (100:1 or more); the protected data is a single top-level pointer (a snapshot); readers are CPU-bound and latency-sensitive; and critical sections are provably short (microseconds, not milliseconds). Examples: hot-path config snapshot for a proxy, routing table for a packet forwarder, feature-flag registry queried on every HTTP request.
When to choose hazard pointers
Prefer hazard pointers when: readers need to protect individual nodes inside a data structure (not just the structure's root pointer); readers may block or sleep while holding a reference; the workload involves many simultaneous in-flight pointers per thread; or you need predictable per-object reclamation latency with no dependence on other threads' quiescent states.
Mixing the two within one program is valid — they are independent mechanisms with independent headers and no shared state.
Hot-Reload Pattern
The most common production use of RCU is a hot-reload loop: a background thread watches a config source (file, remote API, service mesh control plane) and calls reconfigure() without stopping the request-serving threads. The pattern below shows the complete lifecycle including the shutdown sequence.
#include <rcu> // C++26
#include <thread>
#include <chrono>
#include <fstream>
#include <atomic>
#include <string>
// ProxyConfig and ProxyRouter as defined in the Examples section above.
ProxyConfig load_config_from_disk(const std::string& path) {
// Parse JSON/TOML/YAML from path; return populated ProxyConfig.
ProxyConfig cfg;
// ... parsing omitted for brevity ...
return cfg;
}
class HotReloadService {
public:
explicit HotReloadService(ProxyRouter& router,
std::string config_path,
std::chrono::seconds interval = std::chrono::seconds{30})
: router_{router}
, config_path_{std::move(config_path)}
, interval_{interval}
{
// Load once synchronously so the router has valid config before
// any request thread calls router_.config().
router_.reconfigure(load_config_from_disk(config_path_));
// Background thread: re-reads file every `interval` seconds.
// C++26 — jthread injects stop_token automatically.
reload_thread_ = std::jthread{[this](std::stop_token st) {
while (!st.stop_requested()) {
std::this_thread::sleep_for(interval_);
if (st.stop_requested()) break;
try {
router_.reconfigure(load_config_from_disk(config_path_));
} catch (const std::exception& e) {
// Log and retain the previous configuration.
log_error("config reload failed", e.what());
}
}
}};
}
~HotReloadService() {
// 1. Stop the background thread (jthread destructor calls
// request_stop() then join()).
reload_thread_.request_stop();
reload_thread_.join();
// 2. Synchronize the RCU domain: block until every thread that
// holds a pointer to any retired ProxyConfig is done with it.
std::rcu_synchronize(); // C++26
// 3. Now safe to destroy the ProxyRouter (its destructor calls
// rcu_barrier() for belt-and-suspenders safety).
// router_ is a reference here; the owner destroys it after us.
}
private:
ProxyRouter& router_;
std::string config_path_;
std::chrono::seconds interval_;
std::jthread reload_thread_;
};
// Typical main():
//
// ProxyRouter router;
// HotReloadService reload{router, "/etc/proxy/config.toml"};
// run_request_loop(router); // calls router.config() millions of times
// // HotReloadService destructor runs: stops thread, synchronizes domain.
// // ProxyRouter destructor runs: rcu_barrier() drains callbacks.The shutdown order matters: the background thread must stop before rcu_synchronize is called, otherwise the thread could enqueue a new retirement during the synchronize call, violating the "all retirements before this call are deleted on return" guarantee. Joining the thread first provides that boundary.
The rcu_synchronize call in the destructor is the critical safety guarantee. Without it, if ProxyRouter is destroyed before every reader thread has finished using the last published config pointer, those threads will access freed memory. rcu_synchronize turns an implicit timing dependency into an explicit barrier.
Performance Characteristics
RCU's value proposition is its asymmetric cost structure: extremely cheap reads, moderately expensive writes. The numbers below are representative for modern x86-64 hardware (4+ GHz, L1 cache hot) and a userspace RCU implementation similar to liburcu.
| Operation | Typical latency | Notes |
|---|---|---|
config_.load(acquire) (read path) | 1–3 ns | Single atomic load; L1 cache hit on hot pointer |
old->retire() / rcu_retire() | 50–200 ns | Enqueue to domain's retired list; no cross-thread sync |
rcu_synchronize() (2 threads) | 1–10 μs | Waits for 2 quiescent states |
rcu_synchronize() (32 threads) | 10–100 μs | Linear in thread count × critical section length |
rcu_barrier() | ~same as synchronize | Drains callbacks; not a full grace period |
std::shared_mutex read lock (uncontested) | 15–50 ns | Atomic RMW on reader count |
std::shared_mutex read lock (contested) | 100 ns–10 μs | Depends on writer pressure |
The 10–15× read-path advantage over std::shared_mutex (uncontested) compounds quickly at scale. A proxy handling 500,000 requests/second with 16 worker threads calls config() roughly 8 million times per second. At 2 ns per call the cost is 16 ms/s of CPU time; at 30 ns (shared_mutex) it would be 240 ms/s — a 15× difference on a single data point.
Why the write path is slow
rcu_synchronize must wait for every registered thread to pass through a quiescent state. On a system with 32 threads where each thread holds a read-side critical section for up to 5 μs, the expected wait is roughly 32 × 5 μs = 160 μs in the worst case. This is not a bug; it is the fundamental trade-off. RCU transfers cost from the common (read) path to the rare (write) path, which is exactly correct for config-style workloads where writes happen every few minutes while reads happen millions of times per second.
Use retire() instead of rcu_synchronize() on the write path whenever possible. retire() is non-blocking and takes only 50–200 ns; the domain performs reclamation on a background thread outside the critical write path.
Interplay with std::jthread and std::stop_token
std::jthread (C++20) and RCU (C++26) compose naturally: jthread provides cooperative cancellation, and RCU provides lock-free config access inside the thread body. The shutdown contract requires that the thread drains its RCU obligations before exiting so the domain does not invoke callbacks into freed memory.
The pattern below uses std::stop_callback to register an RCU barrier as part of the stop sequence:
#include <rcu> // C++26
#include <thread> // std::jthread, std::stop_token, std::stop_callback
class PeriodicRefresher {
public:
explicit PeriodicRefresher(ProxyRouter& router,
std::string config_path)
: router_{router}
, config_path_{std::move(config_path)}
{
worker_ = std::jthread{[this](std::stop_token st) {
// Register stop_callback *inside* the thread so it runs
// on the stop-requesting thread, not on this worker thread.
// This ensures rcu_barrier() is called after the worker
// exits its last critical section but before the destructor
// releases any shared resources.
std::stop_callback on_stop{st, [&] {
// C++26: wait for all retire callbacks on the default domain
// to be invoked before the thread's stack unwinds.
std::rcu_barrier();
}};
while (!st.stop_requested()) {
// Short sleep; stop_requested() checked at top of loop.
std::this_thread::sleep_for(std::chrono::seconds{10});
if (st.stop_requested()) break;
// Reload and publish new config via RCU.
try {
router_.reconfigure(load_config_from_disk(config_path_));
} catch (...) { /* retain previous config */ }
}
// on_stop fires here, on the stopping thread, calling rcu_barrier().
}};
}
// jthread destructor: calls request_stop() then join().
// The stop_callback fires during request_stop(), guaranteeing
// rcu_barrier() completes before join() returns.
private:
ProxyRouter& router_;
std::string config_path_;
std::jthread worker_;
};There is a subtlety in stop_callback placement: registering it inside the thread body means it executes on the requesting thread (the one calling request_stop()), not on the worker thread. This is intentional — the worker may be sleeping in sleep_for, so it cannot call rcu_barrier() on its own stack before the requester needs the guarantee. The callback fires synchronously on the requesting thread, giving the requester the RCU drain before join() returns.
Do not register the stop_callback before the jthread starts. If stop_callback is constructed on the owning thread before the worker's thread body runs, it races with the worker's own initialization. Constructing it inside the lambda, as shown, serializes correctly: the callback is registered only after the worker's local state is fully initialized.
std::hazard_pointer Cross-Reference
std::hazard_pointer (C++26, <hazard_pointer>) solves the same safe-reclamation problem as RCU but with a different read-side protocol. Where RCU uses a domain-wide epoch, hazard pointers use per-thread per-pointer protection records written before each dereference.
Hazard pointer API (C++26)
// <hazard_pointer> — C++26
namespace std {
class hazard_pointer {
public:
// Atomically load src, store its value in this hazard record,
// then reload src to confirm the pointer is still live.
// Returns the protected pointer.
template<class T>
T* protect(const std::atomic<T*>& src) noexcept;
// Clear this hazard record — the pointed-to object may now
// be reclaimed once no other hazard record covers it.
void reset_protection() noexcept;
};
// Acquire a hazard pointer for the current thread.
hazard_pointer make_hazard_pointer();
} // namespace stdThe critical detail is the double-load inside protect: it writes the candidate pointer into the hazard record, then reloads the source atomic to confirm the pointer has not been swapped out during that window. This requires two atomic operations on every read, versus RCU's single acquire-load.
ProxyConfig rewritten with hazard pointers
#include <hazard_pointer> // C++26
#include <atomic>
// NOTE: ProxyConfig does NOT inherit rcu_obj_base here — hazard pointers
// have no base class requirement.
struct ProxyConfig {
std::string upstream_host;
std::uint16_t upstream_port{443};
int timeout_ms{5000};
bool tls_enabled{true};
};
class ProxyRouterHP {
public:
// Read path: two atomic ops — protect write + confirm reload.
// The hazard_pointer object must stay alive for the duration of use.
void forward_request(std::string_view request_body) {
std::hazard_pointer hp = std::make_hazard_pointer(); // C++26
// protect() = write hp record, reload config_, confirm same value
const ProxyConfig* cfg = hp.protect(config_); // C++26
if (!cfg) return;
// Safe to dereference cfg while hp is alive and protection holds.
connect_and_send(cfg->upstream_host, cfg->upstream_port,
cfg->tls_enabled, request_body);
hp.reset_protection(); // C++26: release hazard record
}
// Write path: publish new config, then try to reclaim old one.
void reconfigure(ProxyConfig next) {
auto* incoming = new ProxyConfig{std::move(next)};
ProxyConfig* old = config_.exchange(incoming,
std::memory_order_acq_rel);
if (old) {
// C++26: retire old — deleted once no hazard record covers it
std::hazard_pointer::retire(old);
}
}
private:
std::atomic<ProxyConfig*> config_{nullptr};
};RCU vs hazard pointer read path — side by side
// RCU read path — C++26
// One acquire-load. Cheapest possible non-blocking read.
const ProxyConfig* cfg = config_.load(std::memory_order_acquire);
// Hazard pointer read path — C++26
// Two atomic ops: protect() writes hp record, then reloads config_.
std::hazard_pointer hp = std::make_hazard_pointer();
const ProxyConfig* cfg = hp.protect(config_);
// ... use cfg ...
hp.reset_protection();The hazard pointer read path writes to shared memory on every access (the hazard record). Under high read concurrency, this write traffic can cause false sharing on the hazard record array, partially negating the lock-free advantage. RCU avoids this by deferring all bookkeeping to the write side.
Choose hazard pointers over RCU when readers may hold references across blocking operations. A reader that sleeps, waits on a condition variable, or blocks on I/O while holding a hazard pointer is safe — the hazard record remains valid as long as the hazard_pointer object is alive. The same pattern with RCU is a correctness bug: a sleeping thread does not advance its epoch, stalling reclamation for every other thread on that domain.
Best Practices
Keep read-side critical sections short. A grace period cannot complete until every thread exits its current critical section. Long-running work inside a reader — blocking I/O, sleeping, waiting on a condition variable — stalls reclamation and causes retired objects to accumulate without bound.
Never persist an RCU pointer across a quiescent state. Storing config() in a member variable and using it ten milliseconds later (after the thread has yielded, locked, or synchronized) breaks the safety contract. Obtain the pointer fresh per logical operation and confine its use to that scope.
Prefer retire() over rcu_synchronize() for throughput. rcu_synchronize blocks the writing thread until all threads quiesce — under high reader concurrency this can mean milliseconds of stall. Reserve it for cases requiring ordered cleanup. For routine pointer rotation, retire() is non-blocking and lets the domain batch reclamation efficiently.
Call rcu_barrier before destruction. Before destroying any data structure that owns RCU-protected objects, call std::rcu_barrier() on the relevant domain. Without it, domain-internal threads may invoke deleters that reference memory you have already freed.
Partition heavy workloads with dedicated domains. The default domain is a global singleton. A subsystem that retires thousands of objects per second can delay grace periods for unrelated subsystems sharing that domain. Construct a per-subsystem rcu_domain to provide isolation.
Always publish through std::atomic. Updating the shared pointer via a plain (non-atomic) store is undefined behaviour under the C++ memory model regardless of hardware guarantees. Use memory_order_release for the publish store and memory_order_acquire for the reader load, or memory_order_acq_rel for atomic exchange on the write side.
Stop the background thread before calling rcu_synchronize at shutdown. The background thread may enqueue new retirements concurrently with rcu_synchronize. Joining the thread first establishes a clear boundary: all retirements that will ever be enqueued are enqueued before the synchronize call begins, satisfying the "all retirements before this call are deleted on return" postcondition.
Common Pitfalls
Dereferencing a retired pointer. After calling retire() or rcu_retire(), the pointer is owned by the domain. Any subsequent access — including in the retiring thread — is undefined behaviour. Treat retirement as an irrevocable ownership transfer.
Mutating data through a reader's raw pointer. The reader receives (conceptually) a const view of the published object. Writing through it races with the writer's atomic swap and corrupts the new version that replaced it. Always go through a full copy-update-retire cycle.
Assuming rcu_synchronize is free. On systems with many threads or threads in lengthy critical sections, rcu_synchronize may block for substantial wall time. It is not a lightweight fence; budget for it to be the bottleneck of any write-path that calls it.
Forgetting rcu_barrier at shutdown. The domain may schedule deleter invocations on background threads. If the program exits or a shared library unloads while callbacks are still pending, those callbacks fire into unmapped memory. rcu_barrier drains the queue before you proceed.
Allocating retired objects at unbounded rates. RCU defers deletion; under a sustained write storm, retired-but-not-yet-reclaimed objects accumulate in memory. Rate-limit writers or intersperse rcu_synchronize calls to bound live memory.
Holding an RCU pointer across a stop_callback boundary. A stop_callback fires on an arbitrary thread at an arbitrary time relative to the worker. If the callback releases shared state that the worker's in-progress critical section still references, the worker may dereference freed memory. Structure shutdown so the worker exits its critical section and calls rcu_barrier before shared state is released.
See Also
std::hazard_pointer— per-pointer lock-free reclamation; higher read overhead but tighter reclamation latency and safe for blocking readersstd::atomic— required for all RCU pointer publish/subscribe operationsstd::jthread— cooperative cancellation model that pairs naturally with RCU-driven hot-reload patterns