morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
executor.hpp
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <chrono>
5#include <condition_variable>
6#include <core/async/ExecutorContext.hpp>
7#include <core/async/IExecutor.hpp>
8#include <core/async/ParkedWork.hpp>
9#include <coroutine>
10#include <exception>
11#include <functional>
12#include <mutex>
13#include <queue>
14#include <string>
15#include <thread>
16#include <vector>
17
18#include "../attributes.hpp"
19#include "logger.hpp"
20#include "profiler.hpp"
21
22namespace morph::exec {
23
24struct IExecutor;
25
26namespace detail {
27
28/// @brief A `core::async::IExecutor` over a morph executor: how a core-cpp
29/// strand's pump, and an `ExecutorScope`, reach a thread pool, a
30/// main-thread pump or Qt.
31///
32/// Every `morph::exec::IExecutor` owns exactly one (`IExecutor::coreExecutor`),
33/// so an executor has one core-cpp identity for its whole life.
34///
35/// A strand hands its base one bare coroutine handle per turn, which this
36/// posts as a lambda holding nothing else: trivially copyable, so it fits a
37/// `std::function`'s small buffer and a turn costs no allocation. The lambda
38/// does not refer to this object, so it may be destroyed while a pump it
39/// posted is still queued: the pump finds its strand closed and ends.
40class CoreExecutorOver final : public ::core::async::IExecutor {
41public:
42 /// @param executor Where every resumption is posted. Borrowed: it must
43 /// outlive every strand over this object and run what it queued.
44 explicit CoreExecutorOver(::morph::exec::IExecutor& executor MORPH_LIFETIMEBOUND) : _executor{&executor} {}
45
46 using ::core::async::IExecutor::submit;
47
48 /// @brief Posts @p handle's resumption.
49 /// @param handle The coroutine to resume; borrowed.
50 void submit(std::coroutine_handle<> handle) override;
51
52 /// @brief Posts @p work's resumption. An executor that drops the task
53 /// unrun releases its claim, which frees a chain nobody owns.
54 /// @param work The coroutine to resume, and its claim.
55 void submit(::core::async::ParkedWork work) override;
56
57private:
58 ::morph::exec::IExecutor* _executor;
59};
60
61} // namespace detail
62
63/// @brief Abstract executor interface.
64///
65/// Concrete implementations decide *where* and *when* posted tasks run
66/// (thread pool, main thread, Qt event loop, …).
67///
68/// An executor is an identity, so it is neither copyable nor movable: it owns
69/// the core-cpp adapter (`coreExecutor()`) that holds a pointer back to it.
70struct IExecutor {
71 IExecutor() = default;
72 IExecutor(const IExecutor&) = delete;
73 IExecutor& operator=(const IExecutor&) = delete;
74 IExecutor(IExecutor&&) = delete;
75 IExecutor& operator=(IExecutor&&) = delete;
76 virtual ~IExecutor() = default;
77
78 /// @brief Schedules @p task for asynchronous execution.
79 ///
80 /// Thread-safe. The task is invoked at some point after this call returns.
81 /// Any exception thrown by the task is silently swallowed unless the
82 /// implementation documents otherwise.
83 /// @param task Callable to execute.
84 virtual void post(std::function<void()> task) = 0;
85
86 /// @brief Whether this executor runs one task at a time, each after the
87 /// tasks posted before it: a strand, a GUI loop, a pumped
88 /// `MainThreadExecutor`.
89 ///
90 /// What an owner must be: a component whose state is touched only in its
91 /// owner's tasks needs those tasks never to overlap. A pool runs several
92 /// at once, and the inline executor runs each wherever it is posted from.
93 /// @return `true` unless an override says otherwise.
94 [[nodiscard]] virtual bool isSerial() const noexcept { return true; }
95
96 /// @brief This executor as a core-cpp executor: the identity an
97 /// `core::async::ExecutorScope` names.
98 /// @return The adapter that posts every resumption through `post()`. The
99 /// same object for the executor's whole life.
100 [[nodiscard]] ::core::async::IExecutor& coreExecutor() noexcept { return _core; }
101
102private:
103 // Stores only a pointer to this object, so constructing it from `*this`
104 // before this object is complete is sound.
105 detail::CoreExecutorOver _core{*this};
106};
107
108inline void detail::CoreExecutorOver::submit(std::coroutine_handle<> handle) {
109 _executor->post([handle] { handle.resume(); });
110}
111
112inline void detail::CoreExecutorOver::submit(::core::async::ParkedWork work) {
113 _executor->post([work] {
114 work.abandon.disarm();
115 work.resume.resume();
116 });
117}
118
119/// @brief Whether the calling thread is inside a task of @p executor.
120///
121/// True inside a task that @p executor's own worker is running, and inside
122/// anything that task calls synchronously, including a task posted to
123/// `detail::inlineExecutor()`, which states no scope of its own. Every scope in
124/// force is consulted, not only the innermost: a strand's batch runs inside the
125/// scope of the pool worker that drives it, and a task on that strand is still
126/// a task of the pool.
127///
128/// Cheap enough for a debug-build assertion: a walk over the calling thread's
129/// scope chain, which is a few entries deep.
130/// @param executor The executor asked about.
131/// @return True while a task of @p executor is running on the calling thread.
132[[nodiscard]] inline bool runningOn(IExecutor& executor) noexcept {
133 auto const* const identity = &executor.coreExecutor();
134 return ::core::async::ExecutorScope::anyInForce(
135 [identity](::core::async::ExecutorScope const& scope) { return &scope.executor() == identity; });
136}
137
138/// @brief Whether the calling thread is inside a task of the core-cpp
139/// executor @p executor: a `core::net::EventLoop`'s turn, a core-cpp
140/// strand's batch, or anything else that states an `ExecutorScope`.
141///
142/// The same walk as the overload above, for an owner that is a core-cpp
143/// executor rather than a morph one — `IoLoop::loop()` is the one morph hands
144/// out.
145/// @param executor The executor asked about.
146/// @return True while a task of @p executor is running on the calling thread.
147[[nodiscard]] inline bool runningOn(::core::async::IExecutor const& executor) noexcept {
148 return ::core::async::ExecutorScope::anyInForce(
149 [&executor](::core::async::ExecutorScope const& scope) { return &scope.executor() == &executor; });
150}
151
152/// @brief Multi-threaded executor backed by a fixed-size thread pool.
153///
154/// Tasks are placed in a FIFO queue and consumed by worker threads.
155/// Exceptions thrown by tasks are caught and logged via `morph::log` (they do
156/// not propagate out of the worker or abort sibling tasks).
157/// The destructor blocks until all worker threads have exited.
158// NOLINTNEXTLINE(cppcoreguidelines-special-member-functions)
159class ThreadPoolExecutor : public IExecutor {
160public:
161 /// @brief Constructs the pool with @p n worker threads.
162 ///
163 /// @p n is clamped to a minimum of 1: a pool with zero workers would accept
164 /// posted tasks that could never run (every `post()` would hang forever), so
165 /// `ThreadPoolExecutor(0)` yields a usable single-worker pool rather than a
166 /// silently dead one.
167 /// @param n Number of threads to spawn. Values below 1 are clamped to 1.
168 explicit ThreadPoolExecutor(std::size_t n) {
169 std::size_t const workers = n == 0 ? 1 : n;
170 for (std::size_t i = 0; i < workers; ++i) {
171 _workers.emplace_back([this] { loop(); });
172 }
173 }
174
175 /// @brief Signals all workers to stop and joins them.
176 ///
177 /// Workers drain the queue before exiting: after `_stop` is set they keep
178 /// popping and running already-queued tasks and only return once the queue is
179 /// empty, so the join blocks until every task queued before destruction has
180 /// run. Tasks `post()`ed concurrently with or after destruction race this and
181 /// may be silently lost.
183 {
184 // notify_all() while still holding _m, not after releasing it: a
185 // waiter woken by a notify issued after unlocking could reacquire
186 // the lock, see _stop, return from wait(), and let this object be
187 // destroyed while this thread is still physically inside the
188 // notify call -- a real data race on the condition variable's
189 // internal state (this pattern is what post()'s own notify_one(),
190 // below, must avoid too, for the same reason).
191 std::scoped_lock const lock{_m};
192 _stop = true;
193 _cv.notify_all();
194 }
195 for (auto& worker : _workers) {
196 worker.join();
197 }
198 }
199
200 /// @brief Enqueues @p task for execution on one of the pool threads.
201 /// @param task Callable to execute. Thread-safe.
202 void post(std::function<void()> task) override {
203 std::scoped_lock const lock{_m};
204 _q.push(std::move(task));
205 _cv.notify_one();
206 }
207
208 /// @brief Not serial: the workers run tasks at once.
209 /// @return `false`.
210 [[nodiscard]] bool isSerial() const noexcept override { return false; }
211
212private:
213 void loop() {
214 MORPH_THREAD_NAME("morph.pool");
215 for (;;) {
216 std::function<void()> task;
217 {
218 std::unique_lock lock{_m};
219 _cv.wait(lock, [this] { return _stop || !_q.empty(); });
220 if (_stop && _q.empty()) {
221 return;
222 }
223 task = std::move(_q.front());
224 _q.pop();
225 }
226 try {
227 // Stated per task, not once per worker: the scope ends with the
228 // task, on a throw too, so a thread never carries this pool's
229 // identity into anything it runs afterwards.
230 ::core::async::ExecutorScope const scope{coreExecutor()};
231 task();
232 } catch (const std::exception& exc) {
233 // A task failure must not kill the worker or unrelated tasks, but
234 // it must not vanish either — log and carry on.
235 ::morph::log::logError("[thread-pool] task threw: " + std::string{exc.what()});
236 } catch (...) {
237 ::morph::log::logError("[thread-pool] task threw unknown exception");
238 }
239 }
240 }
241 std::mutex _m;
242 std::condition_variable _cv;
243 std::queue<std::function<void()>> _q;
244 std::vector<std::thread> _workers;
245 bool _stop = false;
246};
247
248/// @brief Single-thread executor intended for use on the calling (main) thread.
249///
250/// Tasks posted from other threads are collected and dispatched only when
251/// the caller invokes `runFor()`. Useful in tests or event loops that have no
252/// native dispatcher.
253class MainThreadExecutor : public IExecutor {
254public:
255 /// @brief Enqueues @p task to be run on the next `runFor()` call.
256 ///
257 /// Thread-safe. The task is *not* executed immediately.
258 /// @param task Callable to execute.
259 void post(std::function<void()> task) override {
260 std::scoped_lock const lock{_m};
261 _q.push(std::move(task));
262 // Notified with the lock held: a consumer that polls (`runOnce()`) can
263 // take the task the moment the lock is released and destroy this
264 // executor, and a notify after that would touch a destroyed condition
265 // variable.
266 _cv.notify_all();
267 }
268
269 /// @brief Pumps the task queue for up to @p timeout.
270 ///
271 /// Runs queued tasks one by one. It does **not** return early when the queue
272 /// drains: while time remains before the deadline it blocks waiting for
273 /// newly posted tasks, and only returns once the deadline is reached (with
274 /// any still-queued tasks left for a subsequent call — this is a pump, not a
275 /// flush). Exceptions thrown by tasks are logged and execution continues with
276 /// the next task.
277 ///
278 /// Must be called from the thread that "owns" this executor.
279 /// @param timeout Maximum wall-clock time to spend draining.
280 void runFor(std::chrono::milliseconds timeout) {
281 auto deadline = std::chrono::steady_clock::now() + timeout;
282 while (std::chrono::steady_clock::now() < deadline) {
283 std::function<void()> task;
284 {
285 std::unique_lock lock{_m};
286 if (!_cv.wait_until(lock, deadline, [this] { return !_q.empty(); })) {
287 return;
288 }
289 task = std::move(_q.front());
290 _q.pop();
291 }
292 runTask(std::move(task));
293 }
294 }
295
296 /// @brief Dequeues and runs at most one pending task, without blocking.
297 ///
298 /// Never waits for a task to appear: if the queue is empty, returns `false`
299 /// immediately. Otherwise pops exactly one task, runs it, and returns `true`
300 /// whether or not that task threw. Unlike `runFor()`, there is no wall-clock
301 /// wait — this is the single-step primitive for callers (e.g. tests or
302 /// external event loops) that want deterministic, one-task-at-a-time control
303 /// instead of a timed pump.
304 ///
305 /// Must be called from the thread that "owns" this executor.
306 /// @return `true` if a task was dequeued and run, `false` if the queue was empty.
307 bool runOnce() {
308 std::function<void()> task;
309 {
310 std::scoped_lock const lock{_m};
311 if (_q.empty()) {
312 return false;
313 }
314 task = std::move(_q.front());
315 _q.pop();
316 }
317 runTask(std::move(task));
318 return true;
319 }
320
321 /// @brief Runs tasks until the queue is empty, with no wall-clock timeout.
322 ///
323 /// Repeatedly calls the same dequeue-and-invoke step as `runOnce()` until the
324 /// queue reports empty, then returns. It does **not** wait for tasks posted
325 /// after draining begins: a task that posts new work while running extends
326 /// the drain (the new task is still queued and gets consumed), but `drain()`
327 /// never blocks waiting for an *externally* posted task the way `runFor()`
328 /// does — it simply stops once the queue is observed empty.
329 ///
330 /// Must be called from the thread that "owns" this executor.
331 void drain() {
332 while (runOnce()) {
333 // Keep consuming until the queue reports empty.
334 }
335 }
336
337private:
338 void runTask(std::function<void()> task) {
339 try {
340 // The pumping thread is the caller's own, so the scope must end
341 // with the task: the caller runs other code on this thread between
342 // pumps.
343 ::core::async::ExecutorScope const scope{coreExecutor()};
344 task();
345 } catch (const std::exception& exc) {
346 ::morph::log::logError("[main-thread] callback threw: " + std::string{exc.what()});
347 } catch (...) {
348 // Mirrors ThreadPoolExecutor::loop's own catch-all. Without it a
349 // non-`std::exception` throw unwinds out of runFor()/runOnce()/
350 // drain(), each of which documents that a throwing task is logged
351 // and the pump continues -- runOnce() promises to return `true`
352 // "whether or not that task threw", and would not return at all.
353 ::morph::log::logError("[main-thread] callback threw unknown exception");
354 }
355 }
356
357 std::mutex _m;
358 std::condition_variable _cv;
359 std::queue<std::function<void()>> _q;
360};
361
362namespace detail {
363
364/// @brief Executor that runs each posted task on the posting thread, at once.
365///
366/// The base of an `OwnerStrand` that should run each task on the thread that
367/// posts it (a test's owner with no pool). It is not serial
368/// (`isSerial()` is `false`), so it cannot be a `Completion`'s owner: a
369/// completion delivered "wherever the producer settled" has no one place its
370/// callbacks are attached and run.
371///
372/// @warning Not a general-purpose executor. Posting to it re-enters the caller,
373/// so a handler that takes a lock the posting frame already holds
374/// self-deadlocks. Application code that wants "run it now" should
375/// call the function instead of posting it.
376///
377/// States no `ExecutorScope`: the task runs on the caller's thread, so
378/// whatever scope is already in force is the right answer to "where am I
379/// running". A task posted here from inside a pool task is still on the pool
380/// (`runningOn(pool)` stays true); one posted from a thread inside no
381/// executor's task is on none.
382// NOLINTNEXTLINE(cppcoreguidelines-special-member-functions)
383class InlineExecutor : public IExecutor {
384public:
385 /// @brief Runs @p task immediately, on the calling thread.
386 ///
387 /// An empty @p task is ignored rather than invoked, since `std::function`'s
388 /// empty state is a legal value to move around and calling it is undefined.
389 /// @param task Callable to execute.
390 void post(std::function<void()> task) override {
391 if (task) {
392 task();
393 }
394 }
395
396 /// @brief Not serial: a task runs on whichever thread posts it, beside
397 /// any other running elsewhere.
398 /// @return `false`.
399 [[nodiscard]] bool isSerial() const noexcept override { return false; }
400};
401
402/// @brief The process-wide `InlineExecutor`.
403///
404/// A function-local static, so it is constructed on first use and outlives
405/// everything built over it.
406/// @return Reference to the shared inline executor.
407inline IExecutor& inlineExecutor() {
408 static InlineExecutor executor;
409 return executor;
410}
411
412} // namespace detail
413
414} // namespace morph::exec
Single-thread executor intended for use on the calling (main) thread.
Definition executor.hpp:253
bool runOnce()
Dequeues and runs at most one pending task, without blocking.
Definition executor.hpp:307
void post(std::function< void()> task) override
Enqueues task to be run on the next runFor() call.
Definition executor.hpp:259
void drain()
Runs tasks until the queue is empty, with no wall-clock timeout.
Definition executor.hpp:331
void runFor(std::chrono::milliseconds timeout)
Pumps the task queue for up to timeout.
Definition executor.hpp:280
~ThreadPoolExecutor() override
Signals all workers to stop and joins them.
Definition executor.hpp:182
bool isSerial() const noexcept override
Not serial: the workers run tasks at once.
Definition executor.hpp:210
ThreadPoolExecutor(std::size_t n)
Constructs the pool with n worker threads.
Definition executor.hpp:168
void post(std::function< void()> task) override
Enqueues task for execution on one of the pool threads.
Definition executor.hpp:202
Compile-time profiler instrumentation: Tracy zones, plots, messages and thread names,...
#define MORPH_THREAD_NAME(name)
Names the calling thread in the capture.
Definition profiler.hpp:137
Abstract executor interface.
Definition executor.hpp:70
::core::async::IExecutor & coreExecutor() noexcept
This executor as a core-cpp executor: the identity an core::async::ExecutorScope names.
Definition executor.hpp:100
virtual bool isSerial() const noexcept
Whether this executor runs one task at a time, each after the tasks posted before it: a strand,...
Definition executor.hpp:94
virtual void post(std::function< void()> task)=0
Schedules task for asynchronous execution.