[PATCH 0/3] libstdc++: implement std::hazard_pointer (P2530R3, C++26)
Paul Xi Cao
paulxicao7@gmail.com
Mon Aug 17 22:27:13 GMT 2026
Thanks for the review, and in particular for chasing R1 down with Maged
rather than just flagging it. You were right; it is a real
use-after-free.
All four findings are addressed, plus two I found while doing so. v2 is
posted as a separate thread, "[PATCH v2 0/3] libstdc++: Implement
std::hazard_pointer (P2530R3)". The per-patch rationale is in the
commit messages there; below is only what answers the review.
1. Reclaim-side ordering. Fixed: _M_synchronize() now executes a
seq_cst atomic_thread_fence between the collect step and the record
scan. Your diagnosis carried over exactly, including the part that
is the tempting one-line fix -- upgrading the scan's loads to
seq_cst does not work, because the removal store on the source is
user code that P2530R3 does not require to be seq_cst.
I reproduced your herd7 verdicts independently before changing
anything: Sometimes for the acquire scan, Sometimes for seq_cst
snapshot loads without the fence, Never with the fence. Those three
tests now gate CI in the standalone prototype, which is also where
every number below was measured:
https://github.com/PaulXiCao/hazard_pointer_prototype
They have no home in the libstdc++ tree, but I am happy to post them
separately.
I would still like the litmus tests you offered. One consequence
worth naming: R2 through R4 were done without your transcription as
a cross-check on our abstraction of the algorithm, so if your files
disagree with ours, what needs revisiting is the R1 argument itself,
not just the files.
One correction to a detail, because it changes how much a green
concurrent.cc is worth. x86 does not hide the litmus shape: over
10^6 runs with litmus7 on x86_64 we see 1 positive for the acquire
scan and 127 with seq_cst snapshot loads, 0 with the fence. TSO
permits exactly this StoreLoad reordering. Your "x86 as compiled
hides it" is right about the real code, where the reclaim path's
mutex puts locked RMWs between the removal store and the scan -- a
different program from the litmus test, and the distinction is now
written into the test file so nobody reads a green x86 run as
evidence of correctness.
2. retire() and noexcept. Fixed, and it forced a decision I would
rather the maintainers confirmed than take on my own authority.
retire() is now a pointer splice onto an intrusive list whose link
lives in the object. There is no way to make it non-allocating
otherwise -- a per-thread side table has to be grown by retire()
itself -- and moving the storage into the object changes the layout
of hazard_pointer_obj_base, a type users derive from, so its size
lands in their binaries and doc/xml/manual/abi.xml lists that as a
prohibited change after release. v2 therefore takes P2530R3
sec. 1.5 at its word and reserves both items it names, a cohort
pointer and a 64-bit counter. The reserved members are initialised
now, because the constructor is inlined into user code; the
corresponding check in retire() can wait, because nothing today can
set a non-null cohort. Precedent for the shape:
chrono::tzdb_list::const_iterator's void* _M_reserved = nullptr.
Three consequences I would rather flag than have found. The type is
no longer trivially copyable, both because reserving anything makes
the default constructor non-trivial and because the "not retired"
sentinel needs user-provided copy and move, or retiring a copy would
look like a double retire. The match key is now the private base
subobject address on both sides rather than the T*, which is what
makes struct T : Other, hazard_pointer_obj_base<T> correct. And we
are asking for more than the reference implementation gives: Folly's
retire() is not noexcept and its do_reclamation() builds an
F14FastSet, so matching Folly answers the intrusive push but not the
guarantee the clause demands.
The cost is memory: sizeof(hazard_pointer_obj_base<Node>) 1 -> 32,
sizeof(Node) 16 -> 48, sizeof(hazard_pointer) 8 -> 8. That is 32
bytes per protectable object whether or not it is ever retired,
against 16 bytes per retired object before. It buys no speed --
push-only retire() is 11.1ns against 11.2ns, the per-thread list
mutex dominating both -- so the case for it is the noexcept
guarantee alone. (Amortised over reclamation retire() is 50.3ns
against 66.7ns, but that is the scan improving, not the push.)
The no-allocation claim is checked by a test that
replaces the global allocation functions and was validated by
reinstating an allocation on the retire path; the first control I
tried, `delete new int`, is elided by the compiler and proved
nothing.
3. Slot acquire/release cost. Mostly settled by the layout work, and
now measured. Sec. 1.5 item 3 puts the reserved domain pointer in
the record rather than the handle, which ruled out the obvious cheap
fix of a slot index, since an index cannot name a domain. With the
handle holding a _Hazptr_rec* and one word wide, records became an
append-only list that is never unlinked: the std::find_if, the deque
and the free bitmap are gone, claiming a record is a flag CAS, and
_M_synchronize() walks the list with no lock and no snapshot array.
make_hazard_pointer() + ~hazard_pointer(), medians of 5, 6-core
x86_64 (the 8- and 16-thread rows oversubscribe that box; read them
as trends):
threads v1 v2
1 16.8ns 14.9ns 1.13x
2 218ns 68.7ns 3.2x
4 297ns 298ns equal
8 1558ns 678ns 2.3x
16 3511ns 930ns 3.8x
Three things I do not want to oversell. The win is contention
scaling, not latency: 14.9ns single-threaded is against sec. 3.1's
~4ns, so v2 does not reach the paper's figure. Claiming a handle
with 256 already held is 1.3x slower than v1's bitmap search (36.2us
against 27.3us), and _M_synchronize() likewise wins 1.4-2.0x up to
256 records but loses 1.16x at 1024 -- cache-line-padded records in
a linked list cannot prefetch like a contiguous pool, and records
are never unlinked, so a process that once peaked at 1024 live
handles pays that walk for the rest of its life. And the benchmark
caught a 6x regression before it
shipped: the natural CAS-per-record claim walk costs a locked RMW
per occupied record, so a 256-deep claim was 175us against v1's
27us, fixed with test-and-test-and-set (36us now). The
architecture change had made your finding worse while looking like a
fix, and only measuring found it -- which is also the honest answer
to why there were no numbers in v1.
protect()/reset_protection() is unchanged within noise, 4.27 ->
4.33ns, which is the number that most needed not to regress.
4. concurrent.cc. Correct, and worse than it looked: readers checked
`p->value < 0` while writers only ever published `++counter`, so
every "no data race" claim from that file was vacuous. Rewritten;
reclaimed nodes now go through a deleter that poisons them and parks
them on a lock-free quarantine, so a reader that dereferences a
reclaimed node observes the poison without reading freed memory, and
every converted test asserts it observed at least one node.
Validated by negative control rather than by a green run: breaking
the protected-set check makes it abort on the error count. 3/3's
commit message has the detail, including why writing the poison from
~Node() does not work. This is also what your POWER9 repro was
missing, and you said so yourself -- a clean run was inconclusive
rather than exculpatory, because nothing in the test could have
reported the failure. It can now.
Two more, self-found rather than from the review. v1 shipped a
bits/version.h whose condition for __cpp_lib_hazard_pointer was only
__cplusplus, although version.def declares the entry gthread and
hosted, so the macro was advertised on exactly the builds the gating
exists to exclude; fixed by regenerating with autogen, which produced
no other churn. The v1 ChangeLog blocks were also space-indented.
Tomasz: both of the things you suggested in the RFC round, the
std::deque<_Slot> and std::find over the vector<bool> free bitmap, are
gone in v2. That is not a rejection of the advice -- it was right for
the design it was given for, and Tom's review agreed those choices were
fine locally. What removed them was sec. 1.5 item 3 forcing the handle
to hold a _Hazptr_rec*, after which the deque, the bitmap and the mutex
over them had nothing left to do.
Two questions that are maintainer calls rather than review comments:
a. Is now the right moment to freeze the layout of
hazard_pointer_obj_base, and are those the right two
reservations? The alternative is a smaller object and accepting
that the paper's two extensions can never be added, so doing
nothing decides it too.
b. Is it acceptable to ship v2 with the known-slower deep-pool case
and follow up with a performance patch? The fix is per-thread
record caching (Folly's hazptr_tc), which addresses both the
14.9-vs-4ns gap and the 256-handle regression. I propose
deferring it because it is the one part of this work that is not a
one-way door: it lives in thread-local storage inside the domain,
hazard_pointer stays one word and hazard_pointer_obj_base is
untouched. I recognise this is the item most likely to draw an
objection, given that you raised R3 as a performance finding.
Still open, so that none of it reads as an oversight: retire() can
throw from std::mutex::lock, which needs the per-thread retire list to
become lock-free; the protected-set buffer is the one allocation left
in reclamation, now reused across scans and falling back to a linear
membership test rather than deferring if it cannot grow; and contracts
integration remains deferred, waiting on a macro that says contracts
are evaluated rather than merely supported.
Disclosure: the prose of this message was rephrased with LLM
assistance.
Paul
More information about the Libstdc++
mailing list