Skip to content

Commit 822f036

Browse files
Fix premature global idle when a parked Sync waiter becomes runnable (#195)
## Summary `Pool::get_task` honoured the `pending_idle` latch by firing an idle epoch **before** attempting to dequeue. When a parked external waiter (e.g. a task blocked on a `Sync` group's token) becomes runnable and is drained into the pool's queue, firing idle up-front dropped the pool's active count and released its `active_pools` slot while a runnable task was still queued — letting a **global idle epoch fire prematurely**. This reorders idle-driven reactions and, with shutdown-on-idle, can quiesce the powerplant while real work is still pending. ## How it manifested It was found via the NUbots **Director**, which dispatches provider reactions onto the default pool while still holding its `Sync` token. The re-entrant provider parks as an external waiter (arming `pending_idle`); on token release the parked task is drained into the pool's queue (now runnable), but the stale latch fires idle first, reordering the Director's idle-driven steps and (with shutdown-on-idle) causing early quiescence/hangs. ## The fix Consume the `pending_idle` latch **without** firing idle — its only job is to *wake* the worker so it re-checks its queue. The existing dequeue-first / `!got` path then decides correctly: - a **drained-runnable** waiter is dequeued and run (no idle), - a **still-parked** waiter leaves the queue empty so the `!got` branch fires idle exactly as before (preserving the cross-pool idle-wake / deadlock-break behaviour). ## Regression test `tests/tests/dsl/IdleParkedSyncWaiter.cpp` reproduces the topology that triggered the bug (a REALTIME reaction on a concurrency-1 pool holding a `Sync` token while re-entering it, driven by deterministic `on<Trigger<Step<N>>>` steps), generalised away from any Director-specific naming. It **fails deterministically before the fix and passes after**: - Without fix: `PASS=0 FAIL=30` - With fix: `PASS=30 FAIL=30` Full local `ctest` suite passes.
1 parent 30ee1cd commit 822f036

3 files changed

Lines changed: 300 additions & 41 deletions

File tree

‎src/threading/scheduler/Pool.cpp‎

Lines changed: 90 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -336,27 +336,65 @@ namespace threading {
336336
continue;
337337
}
338338

339-
// If a waiter was parked for this pool since the last time this worker looked,
340-
// ensure we fire one idle epoch before dispatching the next task. This is the
341-
// counterpart of the OLD scheduler behaviour where a parked task with a failing
342-
// group lock sat in the pool queue and forced the worker to poll-fail-and-fall-
343-
// through to get_idle_task; in the fast path the task is parked in the Group's
344-
// wait_buckets instead, so without this latch the worker can be preempted long
345-
// enough for the drained (lock-OK) task to arrive in the queue before the worker
346-
// polls and end up running it directly, swallowing the idle fire.
339+
// A waiter was parked for this pool since the last time this worker looked, so it
340+
// set pending_idle to wake us. Consume the latch here, but do NOT fire the GLOBAL
341+
// idle epoch up-front: that decision's only job is to WAKE this worker so it
342+
// re-checks its queue. Whether a GLOBAL idle epoch is actually appropriate must be
343+
// decided by the normal dequeue-first path below.
347344
//
348-
// get_idle_task() is a no-op when this thread is already idle (local_lock set),
349-
// so a wasted consume here is harmless: the worker just falls through to the
350-
// normal dequeue path below.
345+
// This matters for the case where the parked waiter has since become runnable and
346+
// been drained into this pool's queue (e.g. a Sync group released its token). If we
347+
// fired the global idle check here, before try_dequeue_task(), we would drop this
348+
// pool's "active" count to zero and release its active_pools slot while a runnable
349+
// task is still sitting in the queue. That can let a GLOBAL idle epoch fire even
350+
// though real work is pending (premature idle) - which reorders idle-driven
351+
// reactions and, with shutdown-on-idle, can quiesce the powerplant early.
351352
//
352-
// The relaxed load short-circuits the (more expensive) read-modify-write on the
353-
// common path where nothing has been latched, so a busy worker never pays for the
354-
// exclusive cacheline acquire that exchange() would force every iteration.
355-
if (pending_idle.load(std::memory_order_acquire)
356-
&& pending_idle.exchange(false, std::memory_order_acq_rel)) {
357-
auto idle_task = get_idle_task();
358-
if (idle_task.task != nullptr) {
359-
return idle_task;
353+
// By only consuming the latch here and letting the dequeue-first / !got path below
354+
// decide the GLOBAL case, a drained-runnable waiter is dequeued and run (no idle),
355+
// while a still-parked waiter leaves the queue empty so the !got branch fires idle
356+
// exactly as before (preserving the cross-pool idle-wake / deadlock-break behavior).
357+
//
358+
// The LOCAL (per-pool, on<Idle<ThisPool>>) check is different: it is still fired
359+
// eagerly here, right away. `active` is edge-triggered (CountingLock only succeeds on
360+
// the exact transition to zero), so if we deferred it behind try_dequeue_task() too,
361+
// a fleeting active-count-reaches-zero window could be missed forever whenever this
362+
// pool happens to have unrelated work land in its queue in the same instant (the
363+
// dequeue would then succeed and skip the idle check entirely for this iteration,
364+
// with no guarantee `active` will ever read exactly zero again for this waiter's
365+
// epoch). Firing the local check early is safe with respect to the premature-idle
366+
// bug above because it only ever fires THIS pool's own Idle<ThisPool> reactions and
367+
// never touches `scheduler.active_pools` - it cannot release a global idle slot.
368+
//
369+
// The relaxed-ish load short-circuits the (more expensive) store on the common path
370+
// where nothing has been latched, so a busy worker never pays for the exclusive
371+
// cacheline acquire that any unconditional atomic RMW - store, exchange, or even a
372+
// failed compare_exchange - would force every iteration. On real hardware only a
373+
// plain load can be satisfied from a cache line held Shared; a store/exchange/CAS
374+
// always requires exclusive ownership of the line, even when the value doesn't
375+
// change (a "failed" CAS still takes the lock on x86, still faults the exclusive
376+
// monitor on ARM). So gating the write behind a load is the only way to keep this
377+
// hot per-dispatch check free of cross-core cache-line ping-pong on a busy pool.
378+
//
379+
// A plain store (rather than exchange) is safe here even though we don't hold the
380+
// mutex: we never branch on the old value, so there is nothing for exchange to give
381+
// us that store doesn't. The apparent "lost wakeup" if a new waiter's
382+
// register_external_waiter() sees the latch already true (skips its notify) and we
383+
// then clear it here is not actually a correctness issue, because neither
384+
// notify_one() call is what makes a parked waiter's task eventually run: submit()
385+
// (when the drained task is enqueued) and unregister_external_waiter() (when
386+
// external_waiters returns to 0) both notify unconditionally, under the pool's
387+
// mutex, on every transition that the wait predicate below actually depends on.
388+
// pending_idle's own notify is purely a latency optimization to promptly wake a
389+
// worker that is sleeping for no other reason than "nothing has happened yet"; if
390+
// it is occasionally skipped, the worker is woken anyway by one of those other
391+
// unconditional notifies once there is something to actually act on.
392+
if (pending_idle.load(std::memory_order_acquire)) {
393+
pending_idle.store(false, std::memory_order_release);
394+
395+
auto local_idle_task = get_local_idle_task();
396+
if (local_idle_task.task != nullptr) {
397+
return local_idle_task;
360398
}
361399
}
362400

@@ -400,13 +438,7 @@ namespace threading {
400438
throw ShutdownThreadException();
401439
}
402440

403-
Pool::Task Pool::get_idle_task() {
404-
if (!running || !descriptor->counts_for_idle) {
405-
return Task{};
406-
}
407-
408-
std::vector<std::shared_ptr<Reaction>> tasks;
409-
441+
void Pool::collect_local_idle_reactions(std::vector<std::shared_ptr<Reaction>>& tasks) {
410442
auto& local_lock = thread_idle[std::this_thread::get_id()];
411443

412444
if (local_lock == nullptr) {
@@ -415,16 +447,9 @@ namespace threading {
415447
tasks.insert(tasks.end(), idle_tasks.begin(), idle_tasks.end());
416448
}
417449
}
450+
}
418451

419-
if (pool_idle == nullptr && active.load(std::memory_order_relaxed) == 0) {
420-
pool_idle = std::make_unique<CountingLock>(scheduler.active_pools);
421-
422-
if (pool_idle->lock()) {
423-
const std::lock_guard<std::mutex> lock(scheduler.idle_mutex);
424-
tasks.insert(tasks.end(), scheduler.idle_tasks.begin(), scheduler.idle_tasks.end());
425-
}
426-
}
427-
452+
Pool::Task Pool::make_idle_dispatch_task(std::vector<std::shared_ptr<Reaction>>&& tasks) {
428453
if (tasks.empty()) {
429454
return Task{};
430455
}
@@ -445,6 +470,36 @@ namespace threading {
445470
return Task{std::move(task)};
446471
}
447472

473+
Pool::Task Pool::get_local_idle_task() {
474+
if (!running || !descriptor->counts_for_idle) {
475+
return Task{};
476+
}
477+
478+
std::vector<std::shared_ptr<Reaction>> tasks;
479+
collect_local_idle_reactions(tasks);
480+
return make_idle_dispatch_task(std::move(tasks));
481+
}
482+
483+
Pool::Task Pool::get_idle_task() {
484+
if (!running || !descriptor->counts_for_idle) {
485+
return Task{};
486+
}
487+
488+
std::vector<std::shared_ptr<Reaction>> tasks;
489+
collect_local_idle_reactions(tasks);
490+
491+
if (pool_idle == nullptr && active.load(std::memory_order_relaxed) == 0) {
492+
pool_idle = std::make_unique<CountingLock>(scheduler.active_pools);
493+
494+
if (pool_idle->lock()) {
495+
const std::lock_guard<std::mutex> lock(scheduler.idle_mutex);
496+
tasks.insert(tasks.end(), scheduler.idle_tasks.begin(), scheduler.idle_tasks.end());
497+
}
498+
}
499+
500+
return make_idle_dispatch_task(std::move(tasks));
501+
}
502+
448503
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
449504
thread_local Pool* Pool::current_pool = nullptr;
450505

‎src/threading/scheduler/Pool.hpp‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,43 @@ namespace threading {
257257
*/
258258
Task get_idle_task();
259259

260+
/**
261+
* Get only this pool's own local idle task (on<Idle<ThisPool>> reactions), without considering the
262+
* global (all-pools) idle epoch.
263+
*
264+
* This exists so the local, per-pool `active` transition can be checked eagerly - as soon as a woken
265+
* worker notices `pending_idle` - without risking the premature-global-idle bug that firing the
266+
* global check early can cause (see the comment in get_task() for details). The local `active`
267+
* counter only ever gates this pool's OWN Idle<ThisPool> reactions, never `scheduler.active_pools`,
268+
* so firing it eagerly cannot release the global active_pools slot early - it is safe to check as
269+
* soon as possible, and doing so avoids missing a fleeting active-count-reaches-zero edge that a
270+
* concurrent task submission could otherwise paper over before the deferred dequeue-first path gets
271+
* around to checking it.
272+
*
273+
* @return the local idle task to execute if it is lockable, or hold if it is not
274+
*/
275+
Task get_local_idle_task();
276+
277+
/**
278+
* Collect this pool's own local idle reactions (on<Idle<ThisPool>>) if this worker is the one that
279+
* takes the pool's `active` count to zero.
280+
*
281+
* Appends the reactions to fire to @p tasks; leaves it untouched if this worker did not win the
282+
* local idle lock. Shared by both get_local_idle_task() and get_idle_task().
283+
*
284+
* @param tasks the accumulator to append any local idle reactions to
285+
*/
286+
void collect_local_idle_reactions(std::vector<std::shared_ptr<Reaction>>& tasks);
287+
288+
/**
289+
* Wrap a collected set of idle reactions in a dispatch task that submits them when run.
290+
*
291+
* @param tasks the idle reactions to dispatch (moved from)
292+
*
293+
* @return the dispatch task, or an empty Task if @p tasks is empty
294+
*/
295+
Task make_idle_dispatch_task(std::vector<std::shared_ptr<Reaction>>&& tasks);
296+
260297
friend class ExternalWaiterRegistration;
261298
void unregister_external_waiter();
262299

@@ -283,12 +320,14 @@ namespace threading {
283320
std::atomic<std::size_t> external_waiters{0};
284321
/// Latched "an external waiter was parked for this pool since you last polled".
285322
///
286-
/// Consumed (exchanged to false) at the top of every get_task iteration. If set and
287-
/// this thread is not already idle, a single idle fire is dispatched before any task
288-
/// from the queue is returned. This preserves the OLD scheduler's invariant that a
289-
/// waiting-but-not-runnable task on the destination pool would always force one idle
290-
/// fire per parking, even when the worker is preempted long enough for the drained
291-
/// (RunningLock-OK) task to be sitting in the queue by the time the worker resumes.
323+
/// Consumed (cleared to false) at the top of every get_task iteration purely to WAKE a
324+
/// sleeping worker so it re-checks its queue; it does NOT by itself force an idle fire.
325+
/// Whether this is actually an idle situation is decided by the normal dequeue-first
326+
/// path: if the parked waiter has since become runnable and been drained into this
327+
/// pool's queue (e.g. a Sync group released its token), the worker dequeues and runs it
328+
/// with no idle epoch. Only if the queue is genuinely empty after dequeuing does the
329+
/// !got path fire idle, preserving the cross-pool idle-wake / deadlock-break behavior
330+
/// without prematurely firing idle while real work is still pending.
292331
///
293332
/// This is only ever set when idle_relevant() is true (some idle reaction could fire
294333
/// on this pool), so on the hot contended path with no idle reactions the latch stays

0 commit comments

Comments
 (0)