Fix lost-wakeup deadlock in asynchronous_sink flush (event primitive) (#255) - #256
Fix lost-wakeup deadlock in asynchronous_sink flush (event primitive) (#255)#256Burgch 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.
| { | ||
| typedef sinks::asynchronous_sink< sinks::text_ostream_backend > sink_t; | ||
|
|
||
| boost::shared_ptr< std::ostream > strm(new std::ostringstream()); |
There was a problem hiding this comment.
I think, an ofstream to /dev/null (or NUL on Windows) would be better here. Or a special sink backend that discards the log record. We don't want to hog arbitrary amount of memory during the test.
| // either way the waiter re-parks with the wakeup lost. The exchange is a | ||
| // full barrier, ordered with wait()'s exchange in m_state's modification | ||
| // order, so a notify is guaranteed whenever the prior signal was consumed. | ||
| if (m_state.exchange(1u, boost::memory_order_acq_rel) == 0u) |
There was a problem hiding this comment.
acq_rel is not needed here.
Also, the comment above is misleading. Event is a generic component, it has no relation to sinks, or the context in which it is used.
Same applies below, to winapi_based_event::set_signalled.
There was a problem hiding this comment.
Ah yes, I agree - release is sufficient because we only need to guarantee that writes which occurred before signalling are published and visible to the waiter after it is woken up - we don't need to read anything else after this, so no need for acquire. I also confirmed that, on x86 at least, both acq_rel and release compile to exactly the same assembly - though I believe the same would not be true on a weak memory model architecture.
|
I've also left some comments in the issue regarding the problem. It isn't clear to me what the problem is. |
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.