]> git.hungrycats.org Git - bees/commitdiff
task: park Exclusion waiters on the lock, wake on final release
authorZygo Blaxell <bees@furryterror.org>
Thu, 3 Sep 2026 01:42:34 +0000 (21:42 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:04:15 +0000 (00:04 -0400)
A Task that lost try_lock was appended to the owning Task's post-exec
queue and rescheduled whenever that Task finished an execution.  That
wakes every waiter at once, and it fires at the wrong times: a planner
task that restarts wakes its whole waiter herd on every iteration, and
a move_lock handoff chain wakes all waiters at every intermediate task
completion while the lock is still held.  At endgame, when the
remaining work is a few highly-shared ref clusters, these spurious
wake-all events feed a retry storm in which the woken tasks mostly
fail, requeue, and wake each other again.

Park waiters on the Exclusion itself instead.  The owner token
(ExclusionOwner) is shared by all ExclusionLocks of one acquisition;
when the last lock is released, its destructor wakes the parked
waiters via Task::run().  A move_lock retag installs a replacement
owner, so the old owner's destructor sees a live successor and wakes
nobody — waiters sleep through handoffs and wake exactly when the
lock is free.  Waiters are deduped by task id, so a task that dies
repeatedly on the same lock parks once.

The Exclusion's mutex, owner and waiter list move into a shared
ExclusionState so outstanding locks and parked waiters survive
destruction of the Exclusion object itself.  Owner references are
always dropped after the state mutex is released — a shared_ptr
returned by weak_ptr::lock() can be the last reference, and
~ExclusionOwner takes the same mutex (the initial version of this
change deadlocked test_exclusion exactly that way).

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

index 853b651677b8bb3372b900610b1d60718192ce2e..b35022a165ef1c51c92f4aafb8f64293d3bba6b3 100644 (file)
@@ -151,9 +151,12 @@ namespace crucible {
                void release();
        };
 
+       class ExclusionOwner;
+       class ExclusionState;
+
        class ExclusionLock {
-               shared_ptr<Task> m_owner;
-               ExclusionLock(shared_ptr<Task> owner);
+               shared_ptr<ExclusionOwner> m_owner;
+               ExclusionLock(shared_ptr<ExclusionOwner> owner);
        friend class Exclusion;
        public:
                /// Explicit default constructor because we have other kinds
@@ -167,10 +170,11 @@ namespace crucible {
        };
 
        class Exclusion {
-               mutex m_mutex;
-               weak_ptr<Task> m_owner;
+               shared_ptr<ExclusionState> m_state;
 
        public:
+               Exclusion();
+
                /// Attempt to obtain a Lock.  If successful, current Task
                /// owns the Lock until the ExclusionLock is released
                /// (it is the ExclusionLock that owns the lock, so it can
@@ -182,23 +186,26 @@ namespace crucible {
                /// sharing the existing owner.  The Exclusion stays
                /// locked until all ExclusionLocks are released.
                ///
-               /// If not successful, the argument Task is appended to the
-               /// Task that currently holds the lock (rendezvous).  When
-               /// the winning Task finishes execution, the Task
-               /// infrastructure automatically reschedules the losing
-               /// Task from the post-exec queue.  The caller is expected
-               /// to immediately release any other ExclusionLock objects
-               /// it holds and exit its Task function so the restart
-               /// begins from a clean state.
+               /// If not successful, the argument Task is parked on the
+               /// Exclusion's waiter list (rendezvous).  When the last
+               /// ExclusionLock is released, every parked Task is
+               /// rescheduled via Task::run().  Waiters therefore wake
+               /// exactly when the lock actually becomes free — not when
+               /// the owning Task happens to finish an execution, which
+               /// re-woke them spuriously on every restart of the owner
+               /// and after every move_lock handoff.  The caller is
+               /// expected to immediately release any other
+               /// ExclusionLock objects it holds and exit its Task
+               /// function so the restart begins from a clean state.
                ///
-               /// Exclusion stores a weak_ptr<Task> as m_owner, NOT a
-               /// reference to itself.  The caller must keep the
-               /// Exclusion alive (e.g. via shared_ptr) for the
-               /// duration of the lock.  If the Exclusion is managed
-               /// by NamedPtr, the shared_ptr must be stored alongside
-               /// the ExclusionLock — otherwise the NamedPtr entry
-               /// expires and a new Exclusion is created for the same
-               /// key, breaking mutual exclusion.
+               /// The ExclusionLock and parked waiters share the
+               /// Exclusion's internal state, so they remain valid even
+               /// if the Exclusion itself is destroyed while the lock is
+               /// held.  If the Exclusion is managed by NamedPtr, the
+               /// shared_ptr should still be stored alongside the
+               /// ExclusionLock — otherwise the NamedPtr entry expires
+               /// and a new Exclusion is created for the same key,
+               /// breaking mutual exclusion.
                ExclusionLock try_lock(const Task &task);
 
                /// Return true if the Exclusion is currently owned by a
@@ -220,10 +227,11 @@ namespace crucible {
                /// are outstanding, but only the new one gates further
                /// try_lock calls.
                ///
-               /// Tasks that contended on this Exclusion before move_lock was
-               /// called were appended to the current Task's post-exec queue.
-               /// They will be rescheduled when the current Task finishes,
-               /// then retry try_lock and queue on @p new_task's TaskState.
+               /// Tasks that contended on this Exclusion before move_lock
+               /// was called stay parked on the Exclusion's waiter list.
+               /// They are rescheduled when the last ExclusionLock —
+               /// which after this call means @p new_task's — is
+               /// released; the handoff itself wakes nobody.
                ExclusionLock move_lock(const Task &new_task);
 
        };
index a29dfae7ba0474ed1bc494c4333b79b093ca65e6..495ec845dfa5144a5d0c6020222c2bf864059353 100644 (file)
@@ -1226,7 +1226,67 @@ namespace crucible {
                m_barrier_state.reset();
        }
 
-       ExclusionLock::ExclusionLock(shared_ptr<Task> owner) :
+       /// Shared state behind one Exclusion: the current owner and the
+       /// Tasks parked waiting for it.  Held by shared_ptr from the
+       /// Exclusion, every ExclusionOwner, and nothing else, so it
+       /// outlives the Exclusion object if locks are still outstanding.
+       class ExclusionState {
+       public:
+               mutex                   m_mutex;
+               weak_ptr<ExclusionOwner> m_owner;
+               list<Task>              m_waiters;
+       };
+
+       /// The owner token shared by all ExclusionLocks of one
+       /// acquisition.  Destroyed when the last ExclusionLock is
+       /// released; the destructor wakes the parked waiters — unless
+       /// ownership was retagged to a different live owner (move_lock),
+       /// in which case the lock is still held and nobody wakes.
+       class ExclusionOwner {
+               Task                            m_task;
+               shared_ptr<ExclusionState>      m_state;
+       public:
+               ExclusionOwner(const Task &task, const shared_ptr<ExclusionState> &state) :
+                       m_task(task),
+                       m_state(state)
+               {
+               }
+               TaskId id() const { return m_task.id(); }
+               ~ExclusionOwner()
+               {
+                       // Declared before the lock so it is destroyed after
+                       // the mutex is released: if it is the last reference
+                       // to a replacement owner, that owner's destructor
+                       // must not run while we hold m_mutex.
+                       shared_ptr<ExclusionOwner> replacement;
+                       list<Task> waiters;
+                       {
+                               unique_lock<mutex> lock(m_state->m_mutex);
+                               replacement = m_state->m_owner.lock();
+                               if (replacement) {
+                                       // A different owner is registered and
+                                       // alive (move_lock retagged the
+                                       // Exclusion while our locks drained).
+                                       // The lock is still held; its release
+                                       // will wake the waiters.
+                                       return;
+                               }
+                               waiters.swap(m_state->m_waiters);
+                       }
+                       // Wake outside the state mutex: Task::run() takes
+                       // task-infrastructure locks of its own.
+                       for (const auto &t : waiters) {
+                               t.run();
+                       }
+               }
+       };
+
+       Exclusion::Exclusion() :
+               m_state(make_shared<ExclusionState>())
+       {
+       }
+
+       ExclusionLock::ExclusionLock(shared_ptr<ExclusionOwner> owner) :
                m_owner(owner)
        {
        }
@@ -1240,8 +1300,13 @@ namespace crucible {
        ExclusionLock
        Exclusion::try_lock(const Task &task)
        {
-               unique_lock<mutex> lock(m_mutex);
-               const auto sp = m_owner.lock();
+               // sp is declared before the lock so it is destroyed after
+               // the mutex is released: on the contention path it can be
+               // the last reference to the owner, and ~ExclusionOwner
+               // takes the same mutex.
+               shared_ptr<ExclusionOwner> sp;
+               unique_lock<mutex> lock(m_state->m_mutex);
+               sp = m_state->m_owner.lock();
                if (sp) {
                        if (task && sp->id() == task.id()) {
                                // Reentrant: current task already owns this
@@ -1251,12 +1316,26 @@ namespace crucible {
                                return ExclusionLock(sp);
                        }
                        if (task) {
-                               sp->insert(task);
+                               // Park the loser on the Exclusion.  It is
+                               // rescheduled when the last ExclusionLock is
+                               // released.  Dedupe by id: a task that dies
+                               // repeatedly on the same lock parks once.
+                               const auto task_id = task.id();
+                               bool found = false;
+                               for (const auto &t : m_state->m_waiters) {
+                                       if (t.id() == task_id) {
+                                               found = true;
+                                               break;
+                                       }
+                               }
+                               if (!found) {
+                                       m_state->m_waiters.push_back(task);
+                               }
                        }
                        return ExclusionLock();
                } else {
-                       const auto rv = make_shared<Task>(task);
-                       m_owner = rv;
+                       const auto rv = make_shared<ExclusionOwner>(task, m_state);
+                       m_state->m_owner = rv;
                        return ExclusionLock(rv);
                }
        }
@@ -1264,28 +1343,33 @@ namespace crucible {
        bool
        Exclusion::locked_by_another(const Task &task)
        {
-               unique_lock<mutex> lock(m_mutex);
-               const auto sp = m_owner.lock();
+               // Same destruction-order requirement as try_lock.
+               shared_ptr<ExclusionOwner> sp;
+               unique_lock<mutex> lock(m_state->m_mutex);
+               sp = m_state->m_owner.lock();
                return sp && !(task && sp->id() == task.id());
        }
 
        ExclusionLock
        Exclusion::move_lock(const Task &new_task)
        {
-               unique_lock<mutex> lock(m_mutex);
-               const auto sp = m_owner.lock();
+               // Same destruction-order requirement as try_lock.
+               shared_ptr<ExclusionOwner> sp;
+               unique_lock<mutex> lock(m_state->m_mutex);
+               sp = m_state->m_owner.lock();
                THROW_CHECK0(runtime_error, sp);
                const auto current = Task::current_task();
                THROW_CHECK2(runtime_error, sp->id(), current.id(),
                        sp->id() == current.id());
-               // Retag the Exclusion: a copy of new_task sharing its TaskState
-               // becomes the new owner.  Contenders that arrive after this point
-               // will queue on new_task's post-exec list.  Callers must store the
-               // returned ExclusionLock outside the new_task lambda (never capture
-               // it in the closure) to avoid a TaskState → closure → lock → TaskState
-               // cycle.
-               const auto rv = make_shared<Task>(new_task);
-               m_owner = rv;
+               // Retag the Exclusion: a new owner token bound to new_task
+               // takes over.  The old owner's destructor sees the live
+               // replacement and wakes nobody; parked contenders stay on
+               // the waiter list until new_task's lock is released.
+               // Callers must store the returned ExclusionLock outside the
+               // new_task lambda (never capture it in the closure) to avoid
+               // a TaskState → closure → lock → TaskState cycle.
+               const auto rv = make_shared<ExclusionOwner>(new_task, m_state);
+               m_state->m_owner = rv;
                return ExclusionLock(rv);
        }