morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
strand.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <atomic>
5#include <core/async/ExecutorContext.hpp>
6#include <core/async/IExecutor.hpp>
7#include <core/async/KeyedStrands.hpp>
8#include <core/async/ParkedWork.hpp>
9#include <core/async/Strand.hpp>
10#include <coroutine>
11#include <cstddef>
12#include <cstdint>
13#include <exception>
14#include <functional>
15#include <memory>
16#include <mutex>
17#include <shared_mutex>
18#include <string>
19#include <type_traits>
20#include <unordered_map>
21#include <utility>
22
23#include "../attributes.hpp"
24#include "../session/session.hpp"
25#include "executor.hpp"
26#include "logger.hpp"
27#include "profiler.hpp"
28
29/// @file
30/// @brief morph's strands: one per model instance, from core-cpp's
31/// `core::async::KeyedStrands`, over a morph executor.
32///
33/// Specified in `docs/spec/core/executor.md`, "Strands".
34
35namespace morph::exec::detail {
36
37/// @brief Opaque identifier for a model instance inside a backend.
38///
39/// The value 0 is reserved and means "not bound". All non-zero values are
40/// assigned by the backend and are stable for the lifetime of the model.
41struct ModelId {
42 /// @brief Raw numeric id. Zero means unbound.
43 uint64_t v{0};
44
45 /// @brief Three-way comparison — enables `==`, `!=`, `<`, `<=`, `>`, `>=`.
46 auto operator<=>(const ModelId&) const = default;
47};
48
49/// @brief Hash functor so `ModelId` can be used as an `unordered_map` key.
50struct ModelIdHash {
51 /// @brief Returns the hash of @p mid.
52 std::size_t operator()(ModelId mid) const noexcept { return std::hash<uint64_t>{}(mid.v); }
53};
54
55/// @brief A callable posted to a strand, with its throw logged rather than
56/// propagated.
57///
58/// A core-cpp strand lets a task's throw propagate to whoever resumed its
59/// pump, and under MSVC's `cl` ends the process instead. morph's strands have
60/// always logged a throwing task and gone on with the next; this keeps that
61/// policy in morph, inside the one allocation the post costs.
62/// @tparam F The callable's type.
63template <typename F>
64struct LoggedTask {
65 F fn;
66
67 void operator()() {
68 try {
69 fn();
70 } catch (const std::exception& exc) {
71 ::morph::log::logError("[strand] task threw: " + std::string{exc.what()});
72 } catch (...) {
73 ::morph::log::logError("[strand] task threw unknown exception");
74 }
75 }
76};
77
78class TaskResumer;
79
80/// @brief In which order `ModelStrands::teardown` stops the Task handlers and
81/// seals the strands.
82enum class TeardownOrder : std::uint8_t {
83 /// Stop, drain, then seal: a stopped handler unwinds on its strand, which
84 /// still admits its resumption, and the drain before the seal lets
85 /// everything the stop set moving finish there. For a build with threads,
86 /// where the strands keep running on the pool while the owner waits: an
87 /// arrival refused by the seal runs inline on its own thread, and the drain
88 /// first is what keeps a task of its key from running beside it.
89 StopThenSeal,
90 /// Seal, then stop: a stopped handler's resumption is refused and runs
91 /// inline, in the stop. For the single-threaded build, where nothing runs
92 /// the strands while the owner tears them down.
93 SealThenStop,
94};
95
96/// @brief The teardown order for this build: `StopThenSeal` where threads
97/// exist, `SealThenStop` where they do not.
98inline constexpr TeardownOrder buildTeardownOrder =
99 CORE_CPP_ASYNC_HAS_THREADS ? TeardownOrder::StopThenSeal : TeardownOrder::SealThenStop;
100
101/// @brief One strand per model instance over a morph executor: work for one
102/// `ModelId` runs serially and in order, and work for different ids runs
103/// concurrently where the executor has the threads.
104///
105/// `core::async::KeyedStrands<ModelId>`, with what morph adds to it:
106/// - the base executor's core-cpp identity (`IExecutor::coreExecutor`), which
107/// the strands run their pumps on;
108/// - posted callables that log a throw (`LoggedTask`);
109/// - the action's session, and the Task handler's resumer as the current
110/// executor, around every coroutine resumed on a model instance whose Task
111/// handler has started and not finished (`enroll`). That is the keyed
112/// around-task hook's job: a handler that comes back to its strand through
113/// any awaitable finds both installed, and a callable posted to the same
114/// strand does not.
115///
116/// Held by `std::shared_ptr`: a `TaskResumer` shares it, so a suspended
117/// handler can still ask whether it is closed after its backend is gone.
118class ModelStrands final {
119public:
120 /// @param base Where every strand's pump runs. Borrowed: it must outlive
121 /// this object and keep running tasks until `drain` has
122 /// returned.
123 /// @param options How each strand shares @p base; its `aroundTask` must be
124 /// unset, since these strands install their own.
125 explicit ModelStrands(::morph::exec::IExecutor& base MORPH_LIFETIMEBOUND,
126 ::core::async::StrandOptions options = {})
127 : _strands{base.coreExecutor(), options, ::core::async::KeyedAroundTask<ModelId>::of(_hook)} {}
128
129 ModelStrands(const ModelStrands&) = delete;
130 ModelStrands& operator=(const ModelStrands&) = delete;
131 ModelStrands(ModelStrands&&) = delete;
132 ModelStrands& operator=(ModelStrands&&) = delete;
133 ~ModelStrands() = default;
134
135 /// @brief Queues @p task on @p key's strand, after everything queued there
136 /// before it. Dropped once the strands are closed.
137 /// @param key The model instance.
138 /// @param task The callable; held by value in one allocation, and its throw
139 /// is logged.
140 template <typename F>
141 void post(ModelId key, F&& task) {
142 _strands.post(key, LoggedTask<std::decay_t<F>>{std::forward<F>(task)});
143 }
144
145 /// @brief Runs @p task here if the calling thread is on @p key's strand,
146 /// posts it there otherwise, and runs it here once the strands are
147 /// closed.
148 ///
149 /// How a Task handler's end reaches its strand when the handler finished
150 /// off it. Once the strands are closed their backend has drained them, so
151 /// no strand task is left to race @p task.
152 /// @param key The model instance.
153 /// @param task The callable.
154 template <typename F>
155 void runOnStrand(ModelId key, F task) {
156 if (runningHere(key)) {
157 task();
158 return;
159 }
160 LoggedTask<F> logged{std::move(task)};
161 if (!_strands.tryPost(key, logged)) {
162 logged();
163 }
164 }
165
166 /// @brief Queues @p handle on @p key's strand, unless the strands are closed.
167 /// @param key The model instance.
168 /// @param handle The coroutine to resume there; borrowed.
169 /// @return Whether it was queued.
170 [[nodiscard]] bool trySubmit(ModelId key, std::coroutine_handle<> handle) {
171 return _strands.trySubmit(key, handle);
172 }
173
174 /// @brief Queues @p work on @p key's strand, holding its claim until it
175 /// runs, unless the strands are closed.
176 /// @param key The model instance.
177 /// @param work The coroutine and its claim; moved from only where this
178 /// returns true.
179 /// @return Whether it was queued.
180 [[nodiscard]] bool trySubmit(ModelId key, ::core::async::ParkedWork& work) {
181 return _strands.trySubmit(key, work);
182 }
183
184 /// @brief Whether the calling thread is inside a task of @p key's strand.
185 /// @param key The model instance.
186 /// @return True inside that strand's task, at any depth.
187 [[nodiscard]] bool runningHere(ModelId key) const noexcept { return _strands.runningHere(key); }
188
189 /// @brief Whether the calling thread is inside a task of any of these strands.
190 /// @return True inside any of their tasks.
191 [[nodiscard]] bool runningAnyHere() const noexcept { return _strands.runningAnyHere(); }
192
193 /// @brief Whether nothing is queued or running on any strand.
194 /// @return True when idle. Racy by nature where other threads post.
195 [[nodiscard]] bool idle() const { return _strands.idle(); }
196
197 /// @brief Blocks until nothing is queued or running on any strand,
198 /// including work posted while it waits, where the build has
199 /// threads.
200 ///
201 /// Not from one of these strands' own tasks, which would wait for itself: a
202 /// debug build asserts that. The single-threaded WebAssembly build has no
203 /// other thread to finish the work and allows no blocking wait, so there
204 /// this returns at once and `close` drops what is queued; a host that wants
205 /// it run pumps its executor until `idle()` first.
206 void drain() {
207#if CORE_CPP_ASYNC_HAS_THREADS
208 _strands.waitIdle();
209#endif
210 }
211
212 /// @brief Closes every strand: queued work is dropped, a task running on
213 /// another thread is waited for, and later posts are dropped too.
214 /// Idempotent.
215 void close() { _strands.close(); }
216
217 /// @brief Refuses the try-forms and keeps running what is queued:
218 /// `trySubmit` and `runOnStrand`'s post are refused, so their callers
219 /// run the work inline; a plain `post` is still queued until
220 /// `close`. Idempotent.
221 void seal() { _strands.seal(); }
222
223 /// @brief Takes the strands down without losing work: stops the Task
224 /// handlers, seals, drains and closes, with the stop and the seal in
225 /// @p order.
226 ///
227 /// Whatever arrives once the strands are sealed -- a resumption, a
228 /// handler's end -- is refused and runs inline, so nothing reaches a strand
229 /// that the close would drop: not between the drain and the close, and not
230 /// on the single-threaded build, where the drain waits for nothing.
231 ///
232 /// With threads, the stop is drained before the seal: until the strands go
233 /// idle, a handler's resumption or end arriving from another thread -- a
234 /// socket's loop -- is still queued, behind its key's other tasks, rather
235 /// than run inline beside one of them on a pool thread, which would enter
236 /// and leave the model's action gate on two threads at once. What arrives
237 /// once the first drain has returned is the stopped handlers' own last steps.
238 /// @param stopHandlers Requests stop on every Task handler still running.
239 /// @param order Which of stopping and sealing comes first.
240 template <typename Stop>
241 void teardown(Stop&& stopHandlers, TeardownOrder order = buildTeardownOrder) {
242 if (order == TeardownOrder::SealThenStop) {
243 seal();
244 std::forward<Stop>(stopHandlers)();
245 } else {
246 std::forward<Stop>(stopHandlers)();
247 drain();
248 seal();
249 }
250 drain();
251 close();
252 }
253
254 /// @brief Installs @p resumer's session and executor around every coroutine
255 /// resumed on @p key's strand, until `withdraw(key, resumer)`; a
256 /// posted callable runs without them.
257 ///
258 /// Called on the strand, when a Task handler starts. The action gate lets
259 /// one action at a time run on a model instance, so a key has at most one
260 /// suspended handler to resume. Held weakly: the handler's driver owns the
261 /// resumer.
262 /// @param key The model instance.
263 /// @param resumer The handler's resumer.
264 void enroll(ModelId key, const std::shared_ptr<TaskResumer>& resumer) {
265 std::unique_lock const lock{_enrolledMtx};
266 _enrolled.insert_or_assign(key, resumer);
267 _enrolledCount.store(_enrolled.size(), std::memory_order_release);
268 }
269
270 /// @brief Ends `enroll(key, resumer)`. Called when the handler has
271 /// finished; a key enrolled for another resumer since is left alone.
272 /// @param key The model instance.
273 /// @param resumer The resumer that was enrolled for it.
274 void withdraw(ModelId key, const TaskResumer* resumer) {
275 std::unique_lock const lock{_enrolledMtx};
276 if (auto const found = _enrolled.find(key);
277 found != _enrolled.end() && found->second.lock().get() == resumer) {
278 _enrolled.erase(found);
279 }
280 _enrolledCount.store(_enrolled.size(), std::memory_order_release);
281 }
282
283private:
284 /// The keyed around-task hook: runs a coroutine resumption inside its model
285 /// instance's enrolled resumer, if it has one. A posted callable --
286 /// `onBackendChanged`, an action queued behind the handler, the handler's
287 /// end -- is not the handler and runs bare. Touches nothing of this object
288 /// after the task has run, which may have released the last reference to
289 /// it.
290 struct AroundTask {
291 ModelStrands* self;
292 void operator()(const ModelId& key, ::core::async::RunTask run) const;
293 };
294
295 /// Shared by the hook, which only reads: tasks of different keys do not
296 /// serialise on it while handlers are enrolled.
297 std::shared_mutex _enrolledMtx;
298 /// How many keys are enrolled: the hook's one atomic load when none is.
299 std::atomic<std::size_t> _enrolledCount{0};
300 std::unordered_map<ModelId, std::weak_ptr<TaskResumer>, ModelIdHash> _enrolled;
301 AroundTask _hook{this};
302 /// Last, so it is destroyed first: its strands reference the hook.
303 ::core::async::KeyedStrands<ModelId, ModelIdHash> _strands;
304};
305
306/// @brief Resumes one Task handler's coroutines on its model instance's strand.
307///
308/// The current executor wherever the handler runs, so every awaitable that
309/// resumes on the current executor -- morph's `Completion` and `delay`,
310/// core-cpp's `AsyncQueue::pop` -- brings the handler back here, and `submit`
311/// queues it on the strand. Each resumption runs with the action's session
312/// installed: through the strand's around-task hook (`ModelStrands::enroll`),
313/// or here.
314///
315/// Once the strands are closed, a resumption runs inline, on the thread that
316/// submitted it, with the same context installed. The call it belongs to has
317/// already failed then, and the model instance is reachable only through the
318/// handler's own frame, so there is nothing left for the strand to serialise it
319/// against.
320///
321/// Always held by `std::shared_ptr`.
322class TaskResumer final : public ::core::async::IExecutor, public std::enable_shared_from_this<TaskResumer> {
323public:
324 /// @param strands The strands the model's actions run on.
325 /// @param key The model instance whose strand resumptions are queued on.
326 /// @param session The action's session context, installed for each resumption.
327 TaskResumer(std::shared_ptr<ModelStrands> strands, ModelId key, ::morph::session::Context session)
328 : _strands{std::move(strands)}, _key{key}, _session{std::move(session)} {}
329
330 using ::core::async::IExecutor::submit;
331
332 /// @brief Queues @p handle's resumption on the model's strand, or resumes
333 /// it here once the strands are closed.
334 /// @param handle The coroutine to resume; borrowed, as every handler frame
335 /// is owned by the driver that started it.
336 void submit(std::coroutine_handle<> handle) override {
337 if (!_strands->trySubmit(_key, handle)) {
338 resumeHere(handle);
339 }
340 }
341
342 /// @brief Queues @p work's resumption on the model's strand, with its
343 /// claim, or resumes it here once the strands are closed.
344 ///
345 /// The claim matters for a chain nobody owns: a `core::async::DetachedTask`
346 /// started inside the handler that parks on an awaitable resuming on the
347 /// current executor reaches here with its claim armed. Dropping the claim
348 /// would free that frame while its handle is still queued.
349 /// @param work The coroutine to resume, and its claim on the chain root.
350 void submit(::core::async::ParkedWork work) override {
351 if (_strands->trySubmit(_key, work)) {
352 return;
353 }
354 // Resumed, so the chain goes back to its owner: disarmed first, as
355 // core-cpp's own executors do before they resume a parked entry.
356 work.abandon.disarm();
357 resumeHere(work.resume);
358 }
359
360 /// @brief Resumes @p handle on the calling thread, inside this resumer.
361 /// @param handle The coroutine to resume.
362 void resumeHere(std::coroutine_handle<> handle) {
363 within([handle] { handle.resume(); });
364 }
365
366 /// @brief Calls @p body with the action's session installed and this
367 /// resumer as the current executor.
368 ///
369 /// Touches no member after @p body returns: the body may release the last
370 /// owner but the one this call holds.
371 /// @param body The work, called once.
372 template <typename Body>
373 void within(Body&& body) {
374 std::shared_ptr<void> const keep = shared_from_this();
375 ::morph::session::detail::ScopedContext const scoped{_session};
376 ::core::async::ExecutorScope const scope{*this, &keep, nullptr};
377 std::forward<Body>(body)();
378 }
379
380private:
381 std::shared_ptr<ModelStrands> _strands;
382 ModelId _key;
383 ::morph::session::Context _session;
384};
385
386inline void ModelStrands::AroundTask::operator()(const ModelId& key, ::core::async::RunTask run) const {
387 // Every task a model strand runs passes through here, on the thread that
388 // runs it, so this zone is one strand task and nothing more.
389 MORPH_ZONE("ModelStrands::task");
390 if (run.kind() != ::core::async::TaskKind::Resumption ||
391 self->_enrolledCount.load(std::memory_order_acquire) == 0) {
392 run();
393 return;
394 }
395 std::shared_ptr<TaskResumer> resumer;
396 {
397 std::shared_lock const lock{self->_enrolledMtx};
398 if (auto const found = self->_enrolled.find(key); found != self->_enrolled.end()) {
399 resumer = found->second.lock();
400 }
401 }
402 if (!resumer) {
403 run();
404 return;
405 }
406 resumer->within(run);
407}
408
409} // namespace morph::exec::detail
Command run(MutationHandle< Action > const &mutation, Body< Action > const &body={}, RunOptions const &options={})
run: runs a mutation.
Definition author.hpp:2755
AnyHandle session(std::string const &name)
A session attribute, {"session": name}.
Definition author.hpp:1214
Compile-time profiler instrumentation: Tracy zones, plots, messages and thread names,...
#define MORPH_ZONE(name)
Opens a profiler zone named name that ends with the enclosing scope. At most one per scope.
Definition profiler.hpp:118