Skip to content

Commit 7235e88

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 7235e88

3 files changed

Lines changed: 215 additions & 26 deletions

File tree

src/threading/scheduler/Pool.cpp

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -336,28 +336,50 @@ 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 an idle
341+
// epoch up-front: the latch's only job is to WAKE this worker so it re-checks its
342+
// queue. Whether this is actually an idle situation must be decided by the normal
343+
// 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 get_idle_task() here, before try_dequeue_task(), we would drop this pool's
348+
// "active" count to zero and release its active_pools slot while a runnable task is
349+
// still sitting in the queue. That can let a GLOBAL idle epoch fire even though real
350+
// work is pending (premature idle) - which reorders idle-driven reactions and, with
351+
// 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;
360-
}
353+
// By only consuming the latch here and letting the dequeue-first / !got path below
354+
// decide, a drained-runnable waiter is dequeued and run (no idle), while a still-
355+
// parked waiter leaves the queue empty so the !got branch fires idle exactly as
356+
// before (preserving the cross-pool idle-wake / deadlock-break behavior).
357+
//
358+
// The relaxed-ish load short-circuits the (more expensive) store on the common path
359+
// where nothing has been latched, so a busy worker never pays for the exclusive
360+
// cacheline acquire that any unconditional atomic RMW - store, exchange, or even a
361+
// failed compare_exchange - would force every iteration. On real hardware only a
362+
// plain load can be satisfied from a cache line held Shared; a store/exchange/CAS
363+
// always requires exclusive ownership of the line, even when the value doesn't
364+
// change (a "failed" CAS still takes the lock on x86, still faults the exclusive
365+
// monitor on ARM). So gating the write behind a load is the only way to keep this
366+
// hot per-dispatch check free of cross-core cache-line ping-pong on a busy pool.
367+
//
368+
// A plain store (rather than exchange) is safe here even though we don't hold the
369+
// mutex: we never branch on the old value, so there is nothing for exchange to give
370+
// us that store doesn't. The apparent "lost wakeup" if a new waiter's
371+
// register_external_waiter() sees the latch already true (skips its notify) and we
372+
// then clear it here is not actually a correctness issue, because neither
373+
// notify_one() call is what makes a parked waiter's task eventually run: submit()
374+
// (when the drained task is enqueued) and unregister_external_waiter() (when
375+
// external_waiters returns to 0) both notify unconditionally, under the pool's
376+
// mutex, on every transition that the wait predicate below actually depends on.
377+
// pending_idle's own notify is purely a latency optimization to promptly wake a
378+
// worker that is sleeping for no other reason than "nothing has happened yet"; if
379+
// it is occasionally skipped, the worker is woken anyway by one of those other
380+
// unconditional notifies once there is something to actually act on.
381+
if (pending_idle.load(std::memory_order_acquire)) {
382+
pending_idle.store(false, std::memory_order_release);
361383
}
362384

363385
bool got = false;

src/threading/scheduler/Pool.hpp

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -283,12 +283,14 @@ namespace threading {
283283
std::atomic<std::size_t> external_waiters{0};
284284
/// Latched "an external waiter was parked for this pool since you last polled".
285285
///
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.
286+
/// Consumed (cleared to false) at the top of every get_task iteration purely to WAKE a
287+
/// sleeping worker so it re-checks its queue; it does NOT by itself force an idle fire.
288+
/// Whether this is actually an idle situation is decided by the normal dequeue-first
289+
/// path: if the parked waiter has since become runnable and been drained into this
290+
/// pool's queue (e.g. a Sync group released its token), the worker dequeues and runs it
291+
/// with no idle epoch. Only if the queue is genuinely empty after dequeuing does the
292+
/// !got path fire idle, preserving the cross-pool idle-wake / deadlock-break behavior
293+
/// without prematurely firing idle while real work is still pending.
292294
///
293295
/// This is only ever set when idle_relevant() is true (some idle reaction could fire
294296
/// on this pool), so on the hot contended path with no idle reactions the latch stays
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
/*
2+
* MIT License
3+
*
4+
* Copyright (c) 2024 NUClear Contributors
5+
*
6+
* This file is part of the NUClear codebase.
7+
* See https://github.com/Fastcode/NUClear for further info.
8+
*
9+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
10+
* documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
11+
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
12+
* permit persons to whom the Software is furnished to do so, subject to the following conditions:
13+
*
14+
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
15+
* Software.
16+
*
17+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
18+
* WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
19+
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
20+
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21+
*/
22+
23+
#include <catch2/catch_message.hpp>
24+
#include <catch2/catch_test_macros.hpp>
25+
#include <memory>
26+
#include <mutex>
27+
#include <string>
28+
#include <utility>
29+
#include <vector>
30+
31+
#include "nuclear"
32+
#include "test_util/diff_string.hpp"
33+
34+
// Deterministic regression test for a premature global-idle epoch, WITHOUT relying on any sleeps or
35+
// timing windows.
36+
//
37+
// Topology:
38+
// * outer - runs on a single-worker WorkerPool while holding the Sync group token, and
39+
// dispatches the re-entrant inner task onto that same pool while STILL holding the
40+
// token (so it cannot run and instead parks as an external waiter).
41+
// * inner - the re-entrant task: same Sync group, same WorkerPool, LOW priority.
42+
// * on<Idle<>> - the global idle reaction, also pinned to the single-worker WorkerPool, at
43+
// REALTIME priority.
44+
//
45+
// The bug: when the WorkerPool worker is woken by the parked inner task's pending_idle latch, the
46+
// buggy scheduler fires a global idle epoch BEFORE dequeuing the (now drained and runnable) inner
47+
// task, dropping the pool's active count and releasing its active_pools slot while real work is
48+
// queued.
49+
//
50+
// Why this is deterministic (no sleeps):
51+
// * Submission of the inner task is synchronous inside the outer reaction, so while the outer
52+
// reaction still holds the Sync token the inner task is parked (arming pending_idle) before the
53+
// outer reaction returns. Returning releases the token and drains the inner task into the
54+
// WorkerPool queue as a runnable task.
55+
// * The inner task and the idle reaction share the SAME single-worker WorkerPool, so they can never
56+
// run concurrently - the one worker serializes them.
57+
// * The idle reaction is REALTIME and the inner task is LOW. In the buggy case the premature idle
58+
// submits the idle reaction into the same queue that already holds the drained inner task; the
59+
// single worker then dequeues the higher-priority idle reaction FIRST, so it observes the still
60+
// pending inner task (awaiting_inner == true) and records the violation. In the fixed case the
61+
// worker dequeues the inner task first (no idle epoch), so the idle reaction never runs while the
62+
// inner task is pending.
63+
//
64+
// Result: without the fix the event sequence deterministically contains an extra
65+
// "idle-while-inner-pending" entry; with the fix it is exactly {"outer", "inner"}.
66+
67+
namespace {
68+
69+
struct WorkerPool {
70+
static constexpr int concurrency = 1;
71+
};
72+
struct SyncGroup {};
73+
74+
struct Outer {};
75+
struct Inner {};
76+
77+
// NOLINTBEGIN(cppcoreguidelines-avoid-non-const-global-variables)
78+
std::mutex mtx;
79+
std::vector<std::string> events;
80+
bool awaiting_inner = false;
81+
bool inner_done = false;
82+
bool shutting_down = false;
83+
// NOLINTEND(cppcoreguidelines-avoid-non-const-global-variables)
84+
85+
class TestReactor : public NUClear::Reactor {
86+
public:
87+
explicit TestReactor(std::unique_ptr<NUClear::Environment> environment) : Reactor(std::move(environment)) {
88+
89+
// Global idle, pinned to the (single-worker) WorkerPool at REALTIME so that if the buggy
90+
// scheduler submits it while the drained inner task is still queued on the same pool, it is
91+
// dequeued before the lower-priority inner task - making the observation deterministic.
92+
on<Idle<>, Pool<WorkerPool>, Priority::REALTIME>().then([this] {
93+
bool do_shutdown = false;
94+
{
95+
const std::lock_guard<std::mutex> lock(mtx);
96+
if (awaiting_inner) {
97+
// Real work (the parked-then-drained inner task) is still pending on this pool, yet
98+
// a global idle epoch has fired: this is the premature-idle bug.
99+
events.push_back("idle-while-inner-pending");
100+
}
101+
else if (inner_done && !shutting_down) {
102+
shutting_down = true;
103+
do_shutdown = true;
104+
}
105+
}
106+
if (do_shutdown) {
107+
powerplant.shutdown();
108+
}
109+
});
110+
111+
// Kick off exactly one cycle.
112+
on<Startup>().then([this] { emit(std::make_unique<Outer>()); });
113+
114+
// The outer reaction: holds the Sync token on the WorkerPool and dispatches the re-entrant
115+
// inner task onto that same pool while still holding that token.
116+
on<Trigger<Outer>, Sync<SyncGroup>, Pool<WorkerPool>, Priority::REALTIME>().then([this] {
117+
{
118+
const std::lock_guard<std::mutex> lock(mtx);
119+
awaiting_inner = true;
120+
events.push_back("outer");
121+
}
122+
// Submitted synchronously: because we still hold the Sync<SyncGroup> token, the inner task
123+
// fails the group lock and PARKS as an external waiter (arming the WorkerPool's
124+
// pending_idle latch) before this reaction returns. Returning then releases the token,
125+
// draining the parked inner task into the WorkerPool queue as a runnable task.
126+
emit(std::make_unique<Inner>());
127+
});
128+
129+
// The re-entrant inner task: same Sync group, same WorkerPool, lower priority than idle.
130+
on<Trigger<Inner>, Sync<SyncGroup>, Pool<WorkerPool>, Priority::LOW>().then([this] {
131+
const std::lock_guard<std::mutex> lock(mtx);
132+
events.push_back("inner");
133+
awaiting_inner = false;
134+
inner_done = true;
135+
});
136+
}
137+
};
138+
139+
} // namespace
140+
141+
TEST_CASE("Test global idle does not fire while a parked Sync waiter is pending", "[api][dsl][Idle][Pool][Sync]") {
142+
143+
{
144+
const std::lock_guard<std::mutex> lock(mtx);
145+
events.clear();
146+
awaiting_inner = false;
147+
inner_done = false;
148+
shutting_down = false;
149+
}
150+
151+
NUClear::Configuration config;
152+
config.default_pool_concurrency = 1;
153+
NUClear::PowerPlant powerplant(config);
154+
powerplant.install<TestReactor>();
155+
powerplant.start();
156+
157+
const std::vector<std::string> expected = {
158+
"outer",
159+
"inner",
160+
};
161+
162+
INFO(test_util::diff_string(expected, events));
163+
164+
REQUIRE(events == expected);
165+
}

0 commit comments

Comments
 (0)