Skip to content

Commit 6b848e3

Browse files
Fix premature global idle when a parked Sync waiter becomes runnable
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.
1 parent 30ee1cd commit 6b848e3

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)