Fix lost-wakeup deadlock in asynchronous_sink flush (event primitive) (#255) - #256
Open
Burgch wants to merge 1 commit into
Open
Fix lost-wakeup deadlock in asynchronous_sink flush (event primitive) (#255)#256Burgch wants to merge 1 commit into
Burgch wants to merge 1 commit into
Conversation
atomic_based_event::set_signalled() and winapi_based_event::set_signalled()
had a fast path that, when m_state was already non-zero, issued only a release
fence and skipped the wake (notify_one() / SetEvent()). The event is paired
with external predicates (unbounded_fifo_queue's m_interruption_requested flag
and the queue contents) that the caller publishes immediately before calling
set_signalled(), and the fast path is unsound for that usage:
* On architectures that permit StoreLoad reordering (including x86), the plain
load of m_state can observe a stale non-zero value before the caller's
predicate store becomes visible.
* When a concurrent wait() is consuming the previous signal (m_state 1 -> 0),
skipping the wake races that consumption, so the waiter re-checks the
predicate, does not yet see it, and parks with no wakeup pending.
Either way the wakeup is lost: the feeding thread of an asynchronous_sink sleeps
in the event forever and a concurrent core::flush() blocks indefinitely. This
regressed in 1.78 when the futex-based event was ported to Boost.Atomic; the
previous implementation used an unconditional exchange.
Restore an unconditional read-modify-write. exchange with acq_rel is a full
barrier and is totally ordered with wait()'s exchange in m_state's modification
order, so a wake is guaranteed whenever the prior signal was just consumed.
acq_rel (matching wait()) is sufficient because all synchronization funnels
through the single variable m_state; seq_cst is not required. The auto-reset
kernel event in the winapi SetEvent sub-path does not rescue that path either,
because the fast path skipped SetEvent entirely rather than leaving a pending
signal, so the same fix applies there.
The bug is not platform-specific: atomic_based_event is selected on both Linux
(futex) and modern Windows 8+ (WaitOnAddress), and winapi_based_event has the
same defect on pre-WaitOnAddress Windows. Reproduced live on x86-64 Linux
(atomic_based_event) and Windows 11 x64 (winapi_based_event, forced); the buggy
builds stalled reliably under stress while the fixed builds ran 10000 stress
iterations with zero stalls.
Adds test/run/sink_async_frontend_flush.cpp: one thread repeatedly flushes the
core while another logs to an asynchronous_sink. A single run rarely trips the
race, so it is meant to be looped; if the wakeup is lost, flush() never returns
and the flush thread never joins, so the hang is itself the failure signal.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix lost-wakeup deadlock in asynchronous_sink flush (event primitive)
What this fixes
boost::log::core::flush()on anasynchronous_sinkcan hang forever: the sink'sfeeding thread parks in the event's
wait()after a requested wakeup is lost, andthe flushing thread waits indefinitely for it. Full analysis in the linked issue
(#255).
Closes #255.
This is not platform-specific: the buggy
atomic_based_eventis the eventimplementation selected on both Linux (futex) and modern Windows 8+
(
WaitOnAddress), since both provide a native atomic int32 wait/notify. Thedistinct
winapi_based_event(used only on pre-WaitOnAddressWindows) has thesame defect in its own
set_signalled(). This PR fixes both.The change
libs/log/src/event.cpp—set_signalled()had a fast path that, whenm_statewas already non-zero, issued only a release fence and skipped the wake. Both
atomic_based_eventandwinapi_based_eventcarried it.atomic_based_event::set_signalled():winapi_based_event::set_signalled()gets the same treatment, keeping itsexisting
notify_one()(nativeWaitOnAddress) /SetEvent()(kernel handle)split:
The event is paired with external predicates
(
unbounded_fifo_queue::m_interruption_requestedand the queue contents) thatthe caller publishes immediately before calling
set_signalled(). The fast pathis unsound for that usage:
load of
m_statecan observe a stale non-zero value before the caller'spredicate store is visible.
wait()is consuming the previous signal (m_state1 → 0),skipping the notify races that consumption; the waiter re-checks the predicate,
does not yet see it, and parks with no wakeup pending.
Either way the wakeup is lost. This regressed in 1.78 (commit
28822ba,porting the futex-based event to Boost.Atomic); the previous implementation used
an unconditional
exchange.Why the fix is correct on every architecture (and why
acq_rel, notseq_cst)All synchronization funnels through the single variable
m_state, sorelease/acquire on that one variable is sufficient — a
seq_csttotal orderacross multiple variables is not needed.
exchange(1)andthe waiter's
exchange(0)inwait()are totally ordered inm_state'smodification order. Either the waiter's exchange precedes (this one reads 0 → we
notify) or follows (waiter reads 1 →
wait()returns). No lost wakeup, on anymemory model.
before the call. If this
exchangereads non-zero (so we skip notify), thewaiter that consumes the pending signal reads-from this exchange (or a later RMW
in its release sequence); its acquire
exchange(0)then synchronizes-with thisrelease exchange, so the caller's predicate store happens-before the waiter's
post-wait predicate check.
acq_relmatcheswait()'s exchange and is strictly stronger than the plainreleasethe pre-1.78 code used.Performance
set_signalled()runs on the enqueue path (once per record), so this removes thefast path the 1.78 change added: the already-signalled case now performs a
lock-prefixed exchange instead of a relaxed load plus release fence. The extracost is one atomic RMW per enqueue in the common bursty-logging case where the
event is already signalled. There is no cheaper correct alternative — a load-only
fast path is exactly what reintroduces the lost wakeup, and a
seq_cstfencebefore the load compiles to an equivalent locked operation on x86 — and the
notify/
SetEventitself is still gated on the 0→1 transition, so no extra wakeupsyscall is issued.
Instruction-level confirmation
Release build (gcc 14),
atomic_based_event::set_signalled:mov (%rdi),%eax; test; jne <ret>— a plain load; thealready-signalled branch returns with no barrier and no notify.
mov $0x1,%eax; xchg %eax,(%rdi); test; je <notify>; ret— thefirst instruction is the
lock-impliedxchg(a full barrier), and the onlyskipped notify is the correct one (this call did not transition 0 → 1).
The same was verified in the compiled Windows DLL (MSVC 14.3) for
winapi_based_event::set_signalled: the function now leads withmov eax,1; xchg eax,[rcx](alock-prefixed exchange) with no plain-load fastpath, then dispatches
WakeByAddressSingle(native) /SetEvent(kernel handle).The difference between buggy and correct is exactly a plain load vs. a locked
exchange.
Test
Adds
libs/log/test/run/sink_async_frontend_flush.cpp: one thread repeatedlyflushes the core while another logs to an
asynchronous_sink, started togethervia a barrier, for a few seconds. It mirrors the field configuration. A single
run rarely trips the race, so it is meant to be looped under stress; if the wakeup
is lost,
flush()never returns and the flush thread never joins — the hang isitself the failure signal (no watchdog needed). The Jamfile's
run/*.cppglobwires it in automatically.
Validation
Aggressive stress loop (96 producers, 12 flushers, sub-second iterations):
atomic_based_event(Boost 1.89.0, gcc 14): the unfixed buildreliably stalled within a couple thousand iterations (observed at 1121 and
1537), confirmed via gdb — feeder parked in
wait()withm_state == 0whilem_interruption_requested == 1and a flusher blocked onm_BlockCond. With thefix, the identical loop ran 10000 times with zero stalls.
winapi_based_event(Boost 1.89.0, MSVC 14.3): the samepath forced on; the unfixed build stalled at iteration 117, cdb showing the
identical signature (feeder in
winapi_based_event::wait/WaitOnAddress,m_state == 0, kernel handleNULL,m_interruption_requested == 1). With thefix, 10000 iterations with zero stalls.
held across the flush;
consume()back-pressure), confirming a genuinelost-wakeup rather than a queue that never drains.
libs/log/testsuite continues to build and pass.Why
winapi_based_eventneeded the same fixwinapi_based_event::set_signalled()carried the identical "already signalled →release fence only, skip the wake" fast path, and both of its sub-paths share
the same lost-wakeup defect for the same reason (the fast path only loads
m_state, so the caller never joinsm_state's release sequence and the waiter'sacquiring
exchange(0)need not synchronize-with it, leaving the paired predicatestore invisible on the post-wait recheck):
m_event == NULL, whenboost::atomicreports anative wait/notify): the direct analogue of
atomic_based_event. This is thesub-path reproduced live on Windows.
m_event != NULL): the auto-reset event's stickySetEventdoes not save it, because the fast path skipsSetEvententirely— there is no persisted signal to fall back on. An interrupt's wake can be
collapsed into a prior enqueue's
SetEventthat the feeder has already consumedand auto-reset, stranding the feeder with the predicate set and the kernel event
unsignalled.
The unconditional
exchange(1u, acq_rel)(keeping the existing!m_event ? notify_one() : SetEvent()split) repairs both sub-paths: even whenthe exchange reads a stale non-zero and skips the wake, it still writes
m_state,inserting the caller into the release sequence so the waiter synchronizes-with it,
and the
locked RMW closes the x86 StoreLoad window.Note on platform selection: on Windows 8+ the whole
winapi_based_eventclass isnot selected —
boost::atomic<uint32>has a nativeWaitOnAddress, soevent.hppselectsatomic_based_event(the same class as Linux), which thefirst part of this PR fixes.
winapi_based_eventis reached only onpre-
WaitOnAddressWindows. LoweringBOOST_USE_WINAPI_VERSIONdoes not flip theselection — the capability is detected from
WaitOnAddressavailability at buildtime, not gated on the target-version macro — so the Windows repro forced the
winapi_based_eventpath explicitly.Tooling and review
Claude Code (Anthropic) was used during this investigation — to help reproduce
the stall, reason about the memory model, and draft the change and this
description. I reviewed all of it thoroughly: I am a software engineer with 15+
years of C++ experience, I have independently verified the root-cause analysis,
the memory-ordering argument, the disassembly, and the reproduction on both
Linux and Windows, and I stand behind the change as my own.
License
This contribution is licensed under the Boost Software License version 1.0.