]> git.hungrycats.org Git - bees/commitdiff
task: wake one Exclusion waiter per release, not all of them
authorZygo Blaxell <bees@furryterror.org>
Thu, 3 Sep 2026 04:27:28 +0000 (00:27 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:15 +0000 (00:04 -0400)
Waking every parked waiter on each final release turned out to be
self-amplifying: a woken task's failed batch reacquire transiently
takes and releases locks (including the peek's lost-race case, which
takes a lock only to drop it), every such release re-broadcast a
wake to all waiters of that lock, and each of those wakeups caused
more transient takes.  Four contending planner tasks sustained this
cycle indefinitely — a livelock retrying the batch reacquire at
~55k/s (borrower_dead_reacquire reached 332M) with zero dedupe
progress, where the pre-parking code made progress because wakes
were bounded by task-completion frequency and scheduled in sorted
task-id order.

Wake exactly one waiter per release instead.  Liveness is preserved
because a woken task retries its entire memoized lock set: any lock
it acquires and then releases on the way to failure or completion
hands a wake onward to that lock's next waiter, so parked tasks are
served one release at a time instead of racing in synchronized
rounds.

Assisted-by: Claude-Code:claude-fable-5
lib/task.cc

index 495ec845dfa5144a5d0c6020222c2bf864059353..586b46589502dbbddb622a2994057e8f49ae6ea3 100644 (file)
@@ -1259,7 +1259,7 @@ namespace crucible {
                        // to a replacement owner, that owner's destructor
                        // must not run while we hold m_mutex.
                        shared_ptr<ExclusionOwner> replacement;
-                       list<Task> waiters;
+                       Task waiter;
                        {
                                unique_lock<mutex> lock(m_state->m_mutex);
                                replacement = m_state->m_owner.lock();
@@ -1271,13 +1271,28 @@ namespace crucible {
                                        // will wake the waiters.
                                        return;
                                }
-                               waiters.swap(m_state->m_waiters);
+                               if (m_state->m_waiters.empty()) {
+                                       return;
+                               }
+                               // Wake exactly one waiter.  Waking all of them
+                               // makes every release a broadcast, and the
+                               // broadcasts feed each other: each woken task's
+                               // failed batch reacquire transiently takes and
+                               // releases locks, each such release re-wakes
+                               // every waiter of that lock, and a handful of
+                               // contending tasks can spin this way forever
+                               // (observed as a livelock retrying ~55k/s).
+                               // The woken task retries its whole memoized
+                               // lock set, so locks it acquires and releases
+                               // on the way to failure hand the wake onward;
+                               // the remaining waiters are served one release
+                               // at a time.
+                               waiter = m_state->m_waiters.front();
+                               m_state->m_waiters.pop_front();
                        }
                        // Wake outside the state mutex: Task::run() takes
                        // task-infrastructure locks of its own.
-                       for (const auto &t : waiters) {
-                               t.run();
-                       }
+                       waiter.run();
                }
        };