Skip to content

Commit 443fe23

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 IdleDirectorPingPong regression test reproducing the Director topology; it fails deterministically before the fix and passes after.
1 parent 30ee1cd commit 443fe23

2 files changed

Lines changed: 148 additions & 18 deletions

File tree

src/threading/scheduler/Pool.cpp

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -336,28 +336,30 @@ 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+
// 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 behaviour).
357+
//
358+
// The relaxed-ish load short-circuits the (more expensive) read-modify-write on the
353359
// common path where nothing has been latched, so a busy worker never pays for the
354360
// 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-
}
361+
if (pending_idle.load(std::memory_order_acquire)) {
362+
pending_idle.exchange(false, std::memory_order_acq_rel);
361363
}
362364

363365
bool got = false;
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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 <chrono>
26+
#include <memory>
27+
#include <string>
28+
#include <vector>
29+
30+
#include "nuclear"
31+
#include "test_util/diff_string.hpp"
32+
#include "util/precise_sleep.hpp"
33+
34+
// Reproduces the NUbots Director premature-idle failure.
35+
//
36+
// The Director dispatches provider work onto the default pool *while still holding its own
37+
// Sync<Director> token* (it runs on Pool<Director> at REALTIME). The dispatched provider then
38+
// re-enters the Sync<Director> group from the default pool. Because the token is still held, that
39+
// re-entrant task is PARKED as an external waiter on the director pool, which arms the pool's
40+
// "pending_idle" latch.
41+
//
42+
// When the director reaction finishes it releases the token and drains the parked (now-runnable)
43+
// task into its own queue. But if Pool::get_task honours the pending_idle latch *before* dequeuing
44+
// that drained task, it fires a (global) idle epoch even though a runnable reaction is queued. That
45+
// premature global idle lets the test's idle-driven step advance while real work is still pending,
46+
// producing an out-of-order event sequence.
47+
48+
namespace {
49+
50+
struct DirectorPool {
51+
static constexpr int concurrency = 1;
52+
};
53+
struct DirectorSync {};
54+
55+
struct Kick {};
56+
struct ReEnter {};
57+
struct Step1 {};
58+
struct Step2 {};
59+
60+
std::vector<std::string> events; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
61+
62+
class TestReactor : public NUClear::Reactor {
63+
public:
64+
explicit TestReactor(std::unique_ptr<NUClear::Environment> environment) : Reactor(std::move(environment)) {
65+
66+
// Each global idle advances the step machine by one. Step 1 kicks the director, then once
67+
// the director/provider ping-pong is done the next idle advances to step 2, and a final
68+
// idle shuts the powerplant down.
69+
on<Idle<>>().then([this] {
70+
switch (++step) {
71+
case 1: emit(std::make_unique<Step1>()); break;
72+
case 2: emit(std::make_unique<Step2>()); break;
73+
default: powerplant.shutdown(); break;
74+
}
75+
});
76+
77+
on<Trigger<Kick>, Sync<DirectorSync>, Pool<DirectorPool>, Priority::REALTIME>().then([this] {
78+
events.push_back("director");
79+
// Dispatch the re-entrant "provider" while STILL holding the Sync token so it parks.
80+
emit(std::make_unique<ReEnter>());
81+
// Hold the token briefly so the re-entrant task is definitely parked as an external waiter.
82+
NUClear::util::precise_sleep(std::chrono::milliseconds(1));
83+
});
84+
85+
on<Trigger<ReEnter>, Sync<DirectorSync>, Pool<DirectorPool>, Priority::REALTIME>().then([this] {
86+
// If global idle fired prematurely (while this drained task was queued), Step2 will have
87+
// been emitted already; this small delay lets that record first, exposing the ordering
88+
// violation deterministically.
89+
NUClear::util::precise_sleep(std::chrono::milliseconds(5));
90+
events.push_back("provider");
91+
});
92+
93+
on<Trigger<Step1>, Priority::LOW>().then([this] {
94+
events.push_back("step 1");
95+
emit(std::make_unique<Kick>());
96+
});
97+
98+
on<Trigger<Step2>, Priority::LOW>().then([this] { events.push_back("step 2"); });
99+
}
100+
101+
private:
102+
int step = 0;
103+
};
104+
105+
} // namespace
106+
107+
TEST_CASE("Test global idle does not fire while a parked Sync waiter is pending (Director premature idle)",
108+
"[api][dsl][Idle][Pool][Sync][Director]") {
109+
110+
events.clear();
111+
112+
NUClear::Configuration config;
113+
config.default_pool_concurrency = 1;
114+
NUClear::PowerPlant powerplant(config);
115+
powerplant.install<TestReactor>();
116+
powerplant.start();
117+
118+
const std::vector<std::string> expected = {
119+
"step 1",
120+
"director",
121+
"provider",
122+
"step 2",
123+
};
124+
125+
INFO(test_util::diff_string(expected, events));
126+
127+
REQUIRE(events == expected);
128+
}

0 commit comments

Comments
 (0)