#pragma once // ============================================================================ // CoroutineRunner.hpp — the engine: a generational slot map of running // coroutines. // // - CoroutineHandle packs a 32-bit slot index + a 32-bit generation. // - startCoroutine / stopCoroutine / isCoroutineRunning are O(1) in the // common case (index straight into a vector, generation check to reject // stale/reused handles — no ABA bugs). // - Freed slots go on a free list and get reused before growing. // - update() reuses a persistent scratch buffer for finished slots instead // of allocating one every frame. // - getActiveCount() is tracked incrementally (O(1)) instead of // re-counting. // // Growing the backing store (i.e. adding a coroutine when there is no free // slot to reuse) has to be deferred to the start of the next update(), // because update() holds a reference into the slot array while it resumes // coroutines, and those coroutines are allowed to start new ones. A // std::vector resize while that reference is alive would dangle it. Reusing // an already-existing free slot has no such restriction (no reallocation), // so that path is handled immediately instead of staged. // // EXTENDING THIS: CoroutineRunner is not `final` specifically so it can be // derived from. A subclass can add its own overload(s) of startCoroutine // that accept extra data -- an EntityHandle in a game engine, or whatever // your program needs -- and track that data itself, keyed by CoroutineHandle // (which is hashable; see CoroutineHandle::id()/std::hash specialization in // CoroutineHandle.h). onCoroutineStarted(handle, parent)/onCoroutineFinished(handle) // are called at the right points to keep that side table in sync // automatically -- `parent` lets a subclass propagate its data from a // coroutine to any child it starts (via `co_yield childTask()` or a direct // startCoroutine() call from within its body). This base class has no // opinion on what "extra data" means -- that's entirely up to the derived // class. See coroutines/example.md for a worked example. // ============================================================================ #include #include #include #include #include #include #include namespace acrux::coroutines { class CoroutineRunner { private: struct Slot { Coroutine m_task; uint32_t m_generation = 0; bool m_alive = false; }; std::vector m_slots; std::vector m_freeIndices; struct PendingTask { uint32_t m_index; Coroutine m_task; CoroutineHandle m_parent; // whichever coroutine was resuming when this was started, or invalid }; // Tasks that need a brand-new slot (no free slot to reuse) get staged // here and only actually inserted into `m_slots` at the top of update(), // because that's the only point at which growing `m_slots` is safe. std::vector m_pendingTasks; uint32_t m_logicalSize = 0; // m_slots.size() + slots reserved for pending growth std::vector m_deadIndices; // scratch buffer, reused every frame size_t m_liveCount = 0; // Set for the duration of a single h.resume() call in update() (see // step 2 below), otherwise INVALID_COROUTINE_HANDLE. Lets // startCoroutine() record which coroutine, if any, was actually // running when it was called -- whether via `co_yield childTask()` // or a direct startCoroutine() call made from inside a coroutine's // body -- so onCoroutineStarted() can tell a subclass "this one was // started by that one" and propagate whatever it needs to. CoroutineHandle m_currentlyResuming = INVALID_COROUTINE_HANDLE; bool shouldResume(Coroutine::promise_type& promise) const { switch (promise.m_waitKind) { case Coroutine::promise_type::WaitKind::NextFrame: return true; case Coroutine::promise_type::WaitKind::Condition: return promise.m_condition(); case Coroutine::promise_type::WaitKind::NestedHandle: return !isCoroutineRunning(promise.m_nestedHandle); } return true; } protected: // Called once a coroutine becomes live in the slot map -- either // immediately (startCoroutine() reusing a free slot) or at the start // of the next update() (a newly-grown slot). Not called for a task // that never becomes live (e.g. stopped again before its first // update()). // // `parent` is whichever coroutine was actually resuming at the // moment `handle` was started -- set whether it was started via // `co_yield childTask()` or a plain `startCoroutine()` call made // from inside a coroutine's body -- or INVALID_COROUTINE_HANDLE if // it was started from outside any coroutine (e.g. from main()). // A subclass can use this to propagate its own per-coroutine data // from parent to child automatically; see coroutines/example.md. // // Override to hook "a coroutine started" in a subclass; the default // implementation does nothing. virtual void onCoroutineStarted(CoroutineHandle handle, CoroutineHandle parent) { (void)handle; (void)parent; } // Called when a coroutine's slot is reclaimed, whether it finished // naturally (in update()) or was stopped explicitly // (stopCoroutine()). Only called for a handle that previously // triggered onCoroutineStarted(). Override to clean up any // per-coroutine data a subclass is tracking; the default // implementation does nothing. virtual void onCoroutineFinished(CoroutineHandle handle) { (void)handle; } public: static CoroutineRunner* m_instance; CoroutineRunner() { m_instance = this; } virtual ~CoroutineRunner() { if (m_instance == this) m_instance = nullptr; } CoroutineRunner(const CoroutineRunner&) = delete; CoroutineRunner& operator=(const CoroutineRunner&) = delete; // O(1): reuses a free slot in place if one exists (no reallocation, safe // even while update() is mid-iteration). Only falls back to the staging // queue when the backing store actually needs to grow. CoroutineHandle startCoroutine(Coroutine task) { if (!task.m_handle) return INVALID_COROUTINE_HANDLE; if (!m_freeIndices.empty()) { uint32_t index = m_freeIndices.back(); m_freeIndices.pop_back(); CoroutineHandle newHandle(detail::acrux::coroutines::packHandle(index, m_slots[index].m_generation)); task.m_handle.promise().m_assignedId = newHandle; m_slots[index].m_task = std::move(task); m_slots[index].m_alive = true; ++m_liveCount; onCoroutineStarted(newHandle, m_currentlyResuming); return newHandle; } uint32_t index = m_logicalSize++; CoroutineHandle newHandle(detail::acrux::coroutines::packHandle(index, 0)); task.m_handle.promise().m_assignedId = newHandle; m_pendingTasks.push_back(PendingTask{ index, std::move(task), m_currentlyResuming }); return newHandle; } void startSingleCoroutine(CoroutineHandle& slotHandle, Coroutine task) { stopCoroutine(slotHandle); slotHandle = startCoroutine(std::move(task)); } // O(1): decode the slot index directly; generation check rejects stale // handles from a slot that has since been reused (no ABA bugs). Falls // back to a short linear scan only for handles started earlier this // same frame and not yet drained into `m_slots`. bool isCoroutineRunning(CoroutineHandle handle) const { if (handle == INVALID_COROUTINE_HANDLE) return false; uint32_t index = detail::acrux::coroutines::unpackIndex(handle.raw()); uint32_t generation = detail::acrux::coroutines::unpackGeneration(handle.raw()); if (index < m_slots.size() && m_slots[index].m_alive && m_slots[index].m_generation == generation) { return true; } for (const auto& pending : m_pendingTasks) { if (pending.m_index == index && pending.m_task.m_handle && pending.m_task.m_handle.promise().m_assignedId == handle) { return true; } } return false; } // O(1) in the common case; same fallback as isCoroutineRunning for // handles stopped in the same frame they were started. void stopCoroutine(CoroutineHandle& handle) { if (handle == INVALID_COROUTINE_HANDLE) return; uint32_t index = detail::acrux::coroutines::unpackIndex(handle.raw()); uint32_t generation = detail::acrux::coroutines::unpackGeneration(handle.raw()); if (index < m_slots.size() && m_slots[index].m_alive && m_slots[index].m_generation == generation) { m_slots[index].m_task = Coroutine{}; m_slots[index].m_alive = false; ++m_slots[index].m_generation; m_freeIndices.push_back(index); --m_liveCount; onCoroutineFinished(handle); handle = INVALID_COROUTINE_HANDLE; return; } for (auto it = m_pendingTasks.begin(); it != m_pendingTasks.end(); ++it) { if (it->m_index == index && it->m_task.m_handle && it->m_task.m_handle.promise().m_assignedId == handle) { m_pendingTasks.erase(it); break; } } handle = INVALID_COROUTINE_HANDLE; } void update() { // Step 1: grow the backing store exactly once (if needed) and drain // staged insertions into it. This is the only place `m_slots` // grows, so it's safe to do it here, before any references into it // are taken below. if (!m_pendingTasks.empty()) { if (m_logicalSize > m_slots.size()) { m_slots.resize(m_logicalSize); } for (auto& pending : m_pendingTasks) { CoroutineHandle handle = pending.m_task.m_handle.promise().m_assignedId; m_slots[pending.m_index].m_task = std::move(pending.m_task); m_slots[pending.m_index].m_alive = true; ++m_liveCount; onCoroutineStarted(handle, pending.m_parent); } m_pendingTasks.clear(); } // Step 2: tick every live slot. New coroutines started from within // a resumed coroutine go through startCoroutine, which either lands // in an existing free slot immediately or gets staged for next // frame — either way `m_slots` itself never reallocates during this // loop. m_currentlyResuming is set for the duration of each // h.resume() call so that a nested startCoroutine() (from // `co_yield childTask()` or a direct call in the coroutine's body) // can see who's resuming it right now. m_deadIndices.clear(); const size_t size = m_slots.size(); for (size_t i = 0; i < size; ++i) { Slot& slot = m_slots[i]; if (!slot.m_alive) continue; auto h = slot.m_task.m_handle; if (!h || h.done()) { m_deadIndices.push_back(static_cast(i)); continue; } if (shouldResume(h.promise())) { m_currentlyResuming = h.promise().m_assignedId; h.resume(); m_currentlyResuming = INVALID_COROUTINE_HANDLE; } if (!h || h.done()) { m_deadIndices.push_back(static_cast(i)); } } // Step 3: reclaim finished slots. for (uint32_t index : m_deadIndices) { Slot& slot = m_slots[index]; if (!slot.m_alive) continue; // may have been explicitly stopped already this frame // An alive slot always has a valid coroutine handle -- startCoroutine() // rejects an empty Coroutine before a slot is ever marked alive. CoroutineHandle handle = slot.m_task.m_handle.promise().m_assignedId; slot.m_task = Coroutine{}; slot.m_alive = false; ++slot.m_generation; m_freeIndices.push_back(index); --m_liveCount; onCoroutineFinished(handle); } } size_t getActiveCount() const { return m_liveCount + m_pendingTasks.size(); } }; inline CoroutineRunner* CoroutineRunner::m_instance = nullptr; // ---------------------------------------------------------------------------- // Out-of-line definition of promise_type::yield_value(Coroutine&&). Needs // CoroutineRunner to be a complete type, so it lives here rather than inline // inside Coroutine.hpp (see the comment at the top of that file). // ---------------------------------------------------------------------------- inline std::suspend_always Coroutine::promise_type::yield_value(Coroutine&& childTask) { assert(CoroutineRunner::m_instance && "co_yield of a child Coroutine requires an active CoroutineRunner " "(construct one before starting/resuming coroutines)."); CoroutineHandle childHandle = CoroutineRunner::m_instance->startCoroutine(std::move(childTask)); m_waitKind = WaitKind::NestedHandle; m_nestedHandle = childHandle; return {}; } // Free-function convenience wrapper around CoroutineRunner::startCoroutine, // using the active runner (CoroutineRunner::m_instance) — the same // singleton that co_yield of a child Coroutine relies on internally. // // Starts `task` immediately and returns its handle WITHOUT suspending the // caller, so it can be called from anywhere: main(), a plain function, or // from inside another running coroutine. This is the "fire it, keep the // handle, wait later (or never)" entry point: // // CoroutineHandle bg = startCoroutine(controller.fadeOut(delta)); // // ... keep going, do other work ... // co_yield waitFor(bg); // wait on it whenever you're ready (optional) // // (Contrast with `co_yield controller.fadeOut(delta);`, which starts AND // immediately blocks the caller until it finishes.) // // This always calls CoroutineRunner::startCoroutine specifically -- if the // active runner is actually a subclass with its own extended // startCoroutine() overload(s), call those directly on the runner instance // instead of through this free function. inline CoroutineHandle startCoroutine(Coroutine task) { assert(CoroutineRunner::m_instance && "startCoroutine() requires an active CoroutineRunner " "(construct one before starting/resuming coroutines)."); return CoroutineRunner::m_instance->startCoroutine(std::move(task)); } // Free-function convenience wrapper around CoroutineRunner::stopCoroutine, // using the active runner (CoroutineRunner::m_instance). // // Takes `handle` by REFERENCE, same as the member function — it stops the // coroutine AND resets `handle` to INVALID_COROUTINE_HANDLE, so a stale // handle can never accidentally be reused or re-stopped: // // CoroutineHandle bg = startCoroutine(controller.fadeOut(delta)); // ... // stopCoroutine(bg); // bg is now INVALID_COROUTINE_HANDLE // stopCoroutine(bg); // safe no-op, doesn't crash or double-free inline void stopCoroutine(CoroutineHandle& handle) { handle.stop(); } // ---------------------------------------------------------------------------- // Out-of-line definitions of CoroutineHandle::isRunning()/stop(). Needs // CoroutineRunner to be a complete type, so they live here rather than // inline inside CoroutineHandle.h (see the comment at the top of that // file). This is what lets you write `buster.stop();` / // `buster.isRunning()` directly on a handle instead of going through // CoroutineRunner or a free function. // ---------------------------------------------------------------------------- inline bool CoroutineHandle::isRunning() const { assert(CoroutineRunner::m_instance && "CoroutineHandle::isRunning() requires an active CoroutineRunner " "(construct one before starting/resuming coroutines)."); return CoroutineRunner::m_instance->isCoroutineRunning(*this); } inline void CoroutineHandle::stop() { assert(CoroutineRunner::m_instance && "CoroutineHandle::stop() requires an active CoroutineRunner " "(construct one before starting/resuming coroutines)."); CoroutineRunner::m_instance->stopCoroutine(*this); } }