Fix premature global idle when a parked Sync waiter becomes runnable#195
Conversation
There was a problem hiding this comment.
Pull request overview
This PR fixes a scheduler edge case in NUClear’s thread pool where a latched pending_idle signal could trigger an idle epoch before attempting to dequeue newly-runnable work, allowing a premature global idle while runnable tasks are still queued.
Changes:
- Reorders
Pool::get_task()handling sopending_idleis consumed as a wakeup hint without firing idle up-front, deferring the idle decision to the dequeue/!gotpath. - Adds a regression test that reproduces the “Director” ping-pong topology and asserts correct ordering under idle-driven progression.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
src/threading/scheduler/Pool.cpp |
Stops firing idle epochs pre-dequeue when pending_idle is latched, preventing premature global idle. |
tests/tests/dsl/IdleDirectorPingPong.cpp |
Adds a regression test covering the parked-waiter-becomes-runnable idle ordering scenario. |
2a0c1da to
fa3cbf8
Compare
fa3cbf8 to
63bfb11
Compare
b5b625e to
7235e88
Compare
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 pending. It manifested in the NUbots Director, which dispatches provider reactions onto the default pool while still holding its Sync<Director> token; the re-entrant provider parks, and on token release the premature idle reordered the Director's idle-driven steps. 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), while a still-parked waiter leaves the queue empty so the !got branch fires idle exactly as before (preserving cross-pool idle-wake / deadlock-break behaviour). Add the IdleDirectorPingPong regression test reproducing the Director topology. It is fully deterministic and uses no sleeps: the provider and the global idle reaction share a single-worker pool, and priority ordering (REALTIME idle vs LOW provider) means that if the buggy scheduler fires idle while the drained provider is still queued, the idle reaction is dequeued first and observes the pending provider. It fails deterministically before the fix and passes after.
7235e88 to
6b848e3
Compare
|
🤖 Follow-up: found and fixed a real regression this change had introduced in the Root cause: the original fix deferred the entire The LOCAL and GLOBAL idle checks have opposite timing requirements: LOCAL must fire eagerly (edge-triggered), GLOBAL must stay deferred (to avoid the premature-global-idle bug this PR fixes). Fix: split Validation (Debug): |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
src/threading/scheduler/Pool.hpp:334
- The
pending_idlefield comment claims the fast path (when no idle reactions are present) "compiles down to a couple of relaxed atomic loads", but the surrounding code uses at least acquire loads (e.g.idle_relevant()usesmemory_order_acquire). This is slightly misleading documentation; consider wording it more generally as "atomic loads" (or mention acquire) to match the implementation.
/// This is only ever set when idle_relevant() is true (some idle reaction could fire
/// on this pool), so on the hot contended path with no idle reactions the latch stays
/// false and the whole mechanism compiles down to a couple of relaxed atomic loads.
Summary
Pool::get_taskhonoured thepending_idlelatch by firing an idle epoch before attempting to dequeue. When a parked external waiter (e.g. a task blocked on aSyncgroup's token) becomes runnable and is drained into the pool's queue, firing idle up-front dropped the pool's active count and released itsactive_poolsslot 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
Synctoken. The re-entrant provider parks as an external waiter (armingpending_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_idlelatch without firing idle — its only job is to wake the worker so it re-checks its queue. The existing dequeue-first /!gotpath then decides correctly:!gotbranch fires idle exactly as before (preserving the cross-pool idle-wake / deadlock-break behaviour).Regression test
tests/tests/dsl/IdleParkedSyncWaiter.cppreproduces the topology that triggered the bug (a REALTIME reaction on a concurrency-1 pool holding aSynctoken while re-entering it, driven by deterministicon<Trigger<Step<N>>>steps), generalised away from any Director-specific naming. It fails deterministically before the fix and passes after:PASS=0 FAIL=30PASS=30 FAIL=30Full local
ctestsuite passes.