Skip to content

feat(concurrency): Implement C++26 Hazard Pointers (<hazard_pointer>) SMR pattern for zero-contention telemetry snapshot publication #22

Description

@OrDinaD

Problem Description / Motivation

In docs/decisions.md and docs/architecture.md, the rationale for telemetry snapshot publication states:

"Reader lifetime is the hard part of replacing a mutex-protected snapshot. Shared ownership supplies a clear reclamation rule. The actual atomic implementation can use a lock internally. Diagnostics expose this; benchmarks decide whether a more specialized scheme is worth its maintenance cost."
"A custom reclamation scheme would need a measured reason and a separate correctness argument."

With the formal inclusion of Hazard Pointers into the ISO C++26 Standard Library (<hazard_pointer>, P2530) and the battle-tested production evidence from Meta's Folly Hazard Pointers (folly::hazptr), both the measured reason and the formal correctness argument are now established for web_htop:

  1. The Measured Reason:
    • std::atomic<std::shared_ptr<T>> in libstdc++ relies on an internal table of 16 spinlocks (_Sp_locker), causing hidden lock contention, priority inversion vulnerability, and reporting is_lock_free() == false.
    • Releasing the last shared_ptr on the Reactor thread forces the epoll event loop to synchronously destroy large snapshot object graphs (vector<ProcessInfo>, multiple maps, three pre-encoded JSON strings), causing millisecond tail-latency spikes on active TCP connections.
    • Atomic reference counting incurs frequent lock xadd cache-line bouncing between reader and writer CPU cores.
  2. The Correctness Argument:
    • Hazard Pointers provide wait-free read-side protection (hp.protect()) without modifying the protected object's memory or cache lines.
    • Safe Memory Reclamation (SMR) ensures that retired snapshot objects are never freed while any reader holds a reference, yet the actual deallocation is deferred and executed exclusively on the writer thread (the sampling worker), completely shielding the Reactor event loop from memory destruction latency.

Technical Specification: C++26 <hazard_pointer> / Folly Hazptr Pattern

In modern C++26 concurrency (ISO/IEC 14882:2026 <hazard_pointer>), the Safe Memory Reclamation pattern is structured around three primitives:

  • std::hazard_pointer_obj_base<T>: Base class for protectable heap objects providing void retire().
  • std::hazard_pointer: RAII holder owned by a reader thread.
  • template<class T> T* hazard_pointer::protect(const std::atomic<T*>& src) noexcept: Atomically observes the pointer and advertises it in the thread's hazard slot, preventing reclamation during access.

Target Architecture for web_htop:

                 [ Metrics Collector Worker ]
                              │
                    1. Allocate & Encode
                              │
                    2. Publish (Atomic Exchange)
                              │
           old = active_.exchange(next, std::memory_order_acq_rel)
                              │
                    3. old->retire()
                 (Worker reclaims expired generations;
                  Reactor NEVER calls free!)
                              │
                              ▼
               ┌──────────────────────────────┐
               │ std::atomic<Snapshot const*> │
               └──────────────┬───────────────┘
                              │
                              │ 4. hp.protect(active_)
                              │    (Wait-free read, 0 atomic refcounts,
                              │     0 cache-line invalidations)
                              ▼
                  [ Epoll Reactor Thread ]

Snapshot Definition:

#if defined(__cpp_lib_hazard_pointer) && __cpp_lib_hazard_pointer >= 202306L
#include <hazard_pointer>
namespace smr = std;
#else
#include "common/concurrency/hazard_pointer.hpp"
namespace smr = web_htop::concurrency;
#endif

namespace web_htop::server
{
struct PublishedSnapshot : smr::hazard_pointer_obj_base<PublishedSnapshot>
{
    models::SystemSnapshot snapshot;
    std::shared_ptr<std::string const> json, frame, processes_json;
    std::chrono::steady_clock::time_point published_at;
    std::uint64_t encode_us{};
};
}

Writer Publication (Publish):

void SharedState::Publish(models::SystemSnapshot snapshot)
{
    auto next = std::make_unique<PublishedSnapshot>();
    // ... encode json, frame, processes_json ...
    
    auto* old = active_.exchange(next.release(), std::memory_order_acq_rel);
    if (old)
    {
        old->retire(); // Deallocation happens safely on the worker thread!
    }
}

Reader Observation (Load):

class SnapshotGuard
{
  public:
    SnapshotGuard(smr::hazard_pointer&& hp, PublishedSnapshot const* ptr) noexcept
        : hp_(std::move(hp)), ptr_(ptr) {}

    PublishedSnapshot const* operator->() const noexcept { return ptr_; }
    PublishedSnapshot const& operator*() const noexcept { return *ptr_; }
    explicit operator bool() const noexcept { return ptr_ != nullptr; }

  private:
    smr::hazard_pointer hp_;
    PublishedSnapshot const* ptr_;
};

SnapshotGuard SharedState::Load() const noexcept
{
    auto hp = smr::make_hazard_pointer();
    PublishedSnapshot const* ptr = hp.protect(active_);
    return SnapshotGuard(std::move(hp), ptr);
}

Step-by-Step Implementation Strategy

  1. Lightweight, Zero-Dependency C++20 Hazard Pointer Header:
    • Provide common/include/common/concurrency/hazard_pointer.hpp implementing the standard C++26 <hazard_pointer> interface (~120 lines of clean C++20):
      • Thread-local hazard pointer registry (fixed small array or lock-free linked list).
      • Worker-side retire list with batch reclamation.
      • Automatic forwarding to std::hazard_pointer when compiled with C++26 toolchains (__cpp_lib_hazard_pointer >= 202306L).
  2. Encapsulated SharedState Adaptation:
    • Inherit PublishedSnapshot from smr::hazard_pointer_obj_base<PublishedSnapshot>.
    • Update SharedState::Publish() to exchange() and retire().
    • Update SharedState::Load() to return SnapshotGuard (compatible with operator->).
    • Set IsLockFree() to return active_.is_lock_free() (guaranteed true on 64-bit architectures).
  3. Verification:
    • ThreadSanitizer verification under heavy concurrent read/write stress.
    • Verify zero allocations and zero deallocations on the Reactor thread during publication cycles.

Key Benefits

  • Zero Reactor Deallocation Latency: The epoll reactor thread is 100% freed from object destruction overhead; all garbage collection / retirement runs on the worker thread.
  • True Lock-Free Telemetry Publication: Eliminates the 16 global spinlocks of std::atomic<std::shared_ptr>; snapshot_store_lock_free becomes true.
  • Elimination of Atomic Refcount Bouncing: Reader acquires snapshot via relaxed store to thread-local slot + atomic acquire load, leaving the shared cache line in Shared (S) state instead of Modified (M).
  • C++26 Standards Compliance: Adopts the exact idioms of ISO C++26 P2530 and Meta's Folly Hazptr with 100% backward compatibility with C++20.

Acceptance Criteria

  • Implement zero-dependency smr::hazard_pointer compatible with C++26 <hazard_pointer> API.
  • SharedState::IsLockFree() returns true on x86_64 and arm64.
  • Profiling confirms 0 calls to free() or delete in the Reactor thread during snapshot publication under high load.
  • All unit, core, and integration tests pass cleanly under ThreadSanitizer (-fsanitize=thread).
  • PR touches only SharedState and the new concurrency header, preserving modular atomicity.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions