@@ -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
0 commit comments