fix: a push must wake its consumer, even when the ring looked non-empty
push(), try_push() and push_blocking() fired push_callback_ only on the
empty->non-empty edge, and computed that edge from a head_ sampled before
the item was published. A PoolNode consumer decides whether to run again
from the level (count_ready -> approx_size), so a pop landing in that
window left both sides standing down:
producer (push) consumer (PoolNode firing)
------------------------ ----------------------------
samples t=782, h=781
-> was_empty = false, no wake
pops idx 781, head_ = 782
count_ready(): head_==tail_==782
-> not ready, gate released to Idle
tail_.store(783)
The item is in the ring, the node is idle, and no wake is outstanding.
The failure is absorbing: every later push then sees a non-empty ring, so
the edge never fires again and the node sleeps while its backlog grows.
Observed as a hang in bench_pipeline at (chain, depth=4, work_us=10,
shared pool): all pool workers asleep in worker_loop, the reader blocked
in pop(), and 218 items stranded in one channel with head_ stopped at
exactly the index where the edge was dropped.
Re-reading head_ after the tail_ store does not fix this. That is the
store-buffer pattern, and under acquire/release both sides may legally
read stale; forbidding it needs seq_cst on the producer's tail_ store and
head_ load *and* on the consumer's head_ store and tail_ load, a fence on
both hot paths. Firing unconditionally is correct by construction: the
callback runs after the publishing store, so a consumer that observes the
level at all observes the item. The redundant wakes are cheap --
on_input_ready re-checks the level and SubmitGate::claim() collapses a
wake arriving mid-firing into the firing already in flight.
The stress test named this exact hazard and could not detect it: it
asserted only 1 <= callbacks <= N, which a *missed* callback satisfies.
It now requires one callback per successful push, and fails at 1325/10000
against the old code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+41
-7
@@ -136,7 +136,6 @@ public:
|
||||
throw ChannelOverflowError(capacity_);
|
||||
}
|
||||
|
||||
const bool was_empty = (t == h);
|
||||
buf_[t & ring_mask_] = make_storage(std::move(value));
|
||||
tail_.store(t + 1, std::memory_order_release);
|
||||
stats_.record_push(t - h + 1, data_bytes);
|
||||
@@ -144,7 +143,8 @@ public:
|
||||
wake_.fetch_add(1, std::memory_order_release);
|
||||
wake_.notify_one();
|
||||
|
||||
if (was_empty && push_callback_)
|
||||
// Level-triggered, not edge-triggered — see set_push_callback.
|
||||
if (push_callback_)
|
||||
push_callback_();
|
||||
}
|
||||
|
||||
@@ -187,13 +187,13 @@ public:
|
||||
if (t - h >= capacity_) return PushResult::Full;
|
||||
|
||||
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
|
||||
const bool was_empty = (t == h);
|
||||
buf_[t & ring_mask_] = make_storage(std::move(value));
|
||||
tail_.store(t + 1, std::memory_order_release);
|
||||
stats_.record_push(t - h + 1, data_bytes);
|
||||
wake_.fetch_add(1, std::memory_order_release);
|
||||
wake_.notify_one();
|
||||
if (was_empty && push_callback_) push_callback_();
|
||||
// Level-triggered, not edge-triggered — see set_push_callback.
|
||||
if (push_callback_) push_callback_();
|
||||
return PushResult::Taken;
|
||||
}
|
||||
|
||||
@@ -212,13 +212,13 @@ public:
|
||||
const std::size_t h = head_.load(std::memory_order_acquire);
|
||||
if (t - h < capacity_) { // space available → normal push
|
||||
const std::size_t data_bytes = ChannelDataSize<T>::bytes(value);
|
||||
const bool was_empty = (t == h);
|
||||
buf_[t & ring_mask_] = make_storage(std::move(value));
|
||||
tail_.store(t + 1, std::memory_order_release);
|
||||
stats_.record_push(t - h + 1, data_bytes);
|
||||
wake_.fetch_add(1, std::memory_order_release);
|
||||
wake_.notify_one();
|
||||
if (was_empty && push_callback_) push_callback_();
|
||||
// Level-triggered, not edge-triggered — see set_push_callback.
|
||||
if (push_callback_) push_callback_();
|
||||
return true;
|
||||
}
|
||||
// full: yield briefly and retry (consumer will drain)
|
||||
@@ -388,7 +388,41 @@ public:
|
||||
wake_.notify_all();
|
||||
}
|
||||
|
||||
// Register a callback fired when the queue transitions empty→non-empty.
|
||||
// Register a callback fired after every successful push.
|
||||
//
|
||||
// It fires on every push, not on the empty→non-empty transition, and that
|
||||
// is a correctness requirement rather than a simplification.
|
||||
//
|
||||
// The edge version tested `was_empty = (t == h)` using an `h` sampled
|
||||
// *before* the item was published. A PoolNode consumer decides whether to
|
||||
// run again from the level (count_ready → approx_size), so the two sides
|
||||
// could each read the other as stale and both stand down:
|
||||
//
|
||||
// producer (push) consumer (PoolNode firing)
|
||||
// ------------------------ ----------------------------
|
||||
// samples t=782, h=781
|
||||
// -> was_empty = false, no wake
|
||||
// pops idx 781, head_ = 782
|
||||
// count_ready(): head_==tail_==782
|
||||
// -> not ready, gate released to Idle
|
||||
// tail_.store(783)
|
||||
//
|
||||
// The item is in the ring, the node is idle, and no wake is outstanding.
|
||||
// Worse, the failure is absorbing: every later push now sees a non-empty
|
||||
// ring, so `was_empty` is false forever and the callback never fires again.
|
||||
// The node sleeps while its backlog grows and its consumer waits on it.
|
||||
//
|
||||
// Re-reading head_ after the tail_ store does not fix it. That is the
|
||||
// store-buffer pattern, and under acquire/release both sides may legally
|
||||
// read stale; forbidding it needs seq_cst on the producer's tail_ store and
|
||||
// head_ load *and* on the consumer's head_ store and tail_ load — a fence
|
||||
// on both hot paths. Firing unconditionally is correct by construction:
|
||||
// the callback runs after the publishing store, so a consumer that observes
|
||||
// the level at all observes the item.
|
||||
//
|
||||
// The redundant wakes are cheap. on_input_ready re-checks the level, and
|
||||
// SubmitGate::claim() collapses a wake arriving during a firing into the
|
||||
// firing already in flight, so the cost is one CAS, not one extra run.
|
||||
void set_push_callback(std::function<void()> cb) {
|
||||
push_callback_ = std::move(cb);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user