work 0.1.0
A gig is a bunch of related work. The pool does the work.
Loading...
Searching...
No Matches
work.ccm
Go to the documentation of this file.
1// SPDX-FileCopyrightText: 2026 Edward Kmett <ekmett@gmail.com>
2// SPDX-License-Identifier: BSD-2-Clause OR Apache-2.0
3
6module;
7#include <hint.h>
8#include <algorithm>
9#include <atomic>
10#include <chrono>
11#include <concepts>
12#include <condition_variable>
13#include <cstddef>
14#include <cstdint>
15#include <functional>
16#include <memory>
17#include <mutex>
18#include <random>
19#include <span>
20#include <stdexcept>
21#include <thread>
22#include <type_traits>
23#include <utility>
24#include <vector>
25
26export module work;
27
28export namespace work {
30template<class T>
31concept task = std::is_nothrow_move_constructible_v<T> &&
32 std::is_nothrow_move_assignable_v<T> && std::is_nothrow_destructible_v<T>;
33template<task T> class context;
34template<task T, class Handler> class gig;
36template<class Handler, class T>
37concept handler_for = task<T> &&
38 (std::is_nothrow_invocable_v<Handler &, T, context<T> &> ||
39 std::is_nothrow_invocable_v<Handler &, T> ||
40 std::is_nothrow_invocable_v<Handler &, context<T> &>);
43 std::size_t concurrency = 0;
44 bool caller_only = false;
45};
46
47namespace detail {
48inline constexpr unsigned batch_limit = 4096;
49struct activation {
50 activation * next = nullptr;
51 void (*run)(activation *) noexcept = nullptr;
52 void const * group = nullptr;
53};
54struct binding {
55 inline static thread_local binding * current = nullptr;
56 binding * previous = current;
57 void const * group;
58 explicit binding(void const * value) noexcept : group(value) { current = this; }
59 ~binding() noexcept { current = previous; }
60};
61// Sender-initiated private queues: Acar/Chargueraud/Rainey, PPoPP 2013,
62// Figure 3. Deadlines accumulate rather than resetting to observation time.
63struct donation_clock {
64 using clock = std::chrono::steady_clock;
65 std::minstd_rand random;
66 std::exponential_distribution<double> interval{1.0 / 30'000.0};
67 clock::time_point deadline;
68 explicit donation_clock(unsigned seed) noexcept
69 : random(seed + 1), deadline(clock::now()) { advance(); }
70 void advance() noexcept {
71 auto const ns = std::chrono::nanoseconds{
72 std::max(std::int64_t{1}, static_cast<std::int64_t>(interval(random)))};
73 deadline += std::max(clock::duration{1}, std::chrono::duration_cast<clock::duration>(ns));
74 }
75};
76template<task T> struct state;
77}
78
83class pool {
84 template<task T> friend struct detail::state;
85 template<task T, class Handler> friend class work::gig;
86 std::size_t const width;
87 std::mutex mutex;
88 std::condition_variable ready;
89 detail::activation * first = nullptr;
90 detail::activation * last = nullptr;
91 bool stopping = false;
92 std::vector<std::jthread> threads;
93
94 void schedule(detail::activation & job) noexcept;
95 void notify() noexcept;
96 [[nodiscard]] detail::activation * take() noexcept;
97 static void execute(detail::activation * job) noexcept;
98 void worker() noexcept;
99 void help(std::atomic<bool> const & done) noexcept;
100public:
102 explicit pool(std::size_t workers = 1) noexcept;
103 pool(pool const &) = delete;
104 pool & operator=(pool const &) = delete;
106 ~pool() noexcept;
108 [[nodiscard]] hint_inline hint_pure std::size_t workers() const noexcept { return width; }
110 [[nodiscard]] hint_inline hint_pure std::size_t available_workers() const noexcept {
111 return detail::binding::current ? 1 : width;
112 }
113
114 template<task T, class Handler> requires handler_for<std::decay_t<Handler>, T>
115 [[nodiscard]] auto gig(Handler && handler, gig_options options = {}) noexcept;
116};
117
118namespace detail {
119template<task T>
120struct state {
121 struct alignas(64) lane : activation {
122 state * owner;
123 std::size_t index;
124 std::vector<T> pending; // Only the active lane's executor may touch this.
125 donation_clock timer;
126 bool active = false; // Protected by mutex, including while queued/in transit.
127 lane(state & s, std::size_t i) noexcept : owner(&s), index(i), timer(static_cast<unsigned>(i)) {}
128 };
129 pool & workers;
130 bool const caller_only;
131 std::vector<std::unique_ptr<lane>> lanes;
132 std::mutex mutex;
133 std::vector<T> incoming; // External producers only touch this under mutex.
134 std::size_t active = 0; // Counts reservations, queued activations and running lanes.
135 bool closed = false;
136 std::atomic<bool> done{false};
137
138 state(pool & p, gig_options options) noexcept
139 : workers(p), caller_only(options.caller_only) {
140 auto const n = caller_only ? 1 : std::min(p.workers(), options.concurrency ? options.concurrency : p.workers());
141 for (std::size_t i = 0; i != n; ++i) lanes.push_back(std::make_unique<lane>(*this, i));
142 }
143 // mutex is held. Publish only after moving the batch and counting its owner.
144 void dispatch() noexcept {
145 if (incoming.empty()) return;
146 if (!active) {
147 // A new busy period excludes arbitrary open-idle time. Within that
148 // period, keep accumulating deadlines across donations and resumption.
149 auto const now = donation_clock::clock::now();
150 for (auto & target : lanes) { target->timer.deadline = now; target->timer.advance(); }
151 }
152 for (auto & target : lanes) if (!target->active) {
153 target->pending.swap(incoming);
154 target->active = true;
155 ++active;
156 if (!caller_only) workers.schedule(*target);
157 return;
158 }
159 }
160 void close() noexcept {
161 {
162 std::lock_guard lock(mutex);
163 closed = true;
164 if (!active) done.store(true, std::memory_order_release);
165 }
166 workers.notify();
167 }
168 // Called only with an empty owner stack. The mutex arbitrates final retirement
169 // against external push/close; a live callback always owns a counted lane.
170 [[nodiscard]] bool refill(lane & source) noexcept {
171 pool * wake = &workers;
172 bool completed = false;
173 {
174 std::lock_guard lock(mutex);
175 if (!incoming.empty()) { source.pending.swap(incoming); return true; }
176 source.active = false;
177 --active;
178 if (closed && !active) {
179 done.store(true, std::memory_order_release);
180 completed = true;
181 }
182 }
183 // No access to state after publication of completion: join may destroy it.
184 if (completed) wake->notify();
185 return false;
186 }
187 void yield(lane & source) noexcept {
188 {
189 std::lock_guard lock(mutex);
190 // Admit external work even when descendants keep the local stack full.
191 for (auto & item : incoming) source.pending.push_back(std::move(item));
192 incoming.clear();
193 }
194 if (!caller_only) workers.schedule(source); // Last access if another executor acquires it.
195 }
196 void donate(lane & source, std::size_t peer) noexcept {
197 if (source.pending.size() < 2 || peer == source.index) return;
198 std::unique_lock lock(mutex, std::try_to_lock);
199 auto & target = *lanes[peer];
200 if (!lock || target.active) return;
201 auto const half = source.pending.size() / 2;
202 // Handoffs stay typed. The mutex is off the owner-local push/pop path.
203 for (std::size_t i = 0; i != half; ++i) target.pending.push_back(std::move(source.pending[i]));
204 source.pending.erase(source.pending.begin(), source.pending.begin() + half);
205 target.active = true;
206 ++active;
207 workers.schedule(target);
208 }
209 hint_inline void poll(lane & source) noexcept {
210 if (!caller_only && lanes.size() > 1) communicate(source);
211 }
212 hint_noinline void communicate(lane & source) noexcept {
213 auto & timer = source.timer;
214 while (timer.deadline <= donation_clock::clock::now()) {
215 auto peer = std::uniform_int_distribution<std::size_t>{0, lanes.size() - 2}(timer.random);
216 if (peer >= source.index) ++peer;
217 donate(source, peer);
218 timer.advance();
219 }
220 }
221};
222}
223
227template<task T>
228class context {
229 template<task U, class Handler> friend class work::gig;
230 using state = detail::state<T>;
231 state & owner;
232 typename state::lane & local;
233 unsigned remaining = detail::batch_limit;
234 context(state & s, typename state::lane & l) noexcept : owner(s), local(l) {}
235public:
236 context(context const &) = delete;
238 hint_inline void push(T value) noexcept { local.pending.push_back(std::move(value)); }
240 hint_inline void poll() noexcept { owner.poll(local); }
244 [[nodiscard]] hint_inline bool pop(T & result) noexcept {
245 if (!remaining || local.pending.empty()) return false;
246 poll();
247 result = std::move(local.pending.back());
248 local.pending.pop_back();
249 --remaining;
250 return true;
251 }
252
253 [[nodiscard]] hint_inline hint_pure std::size_t index() const noexcept { return local.index; }
254};
255
262template<task T, class Handler>
263class gig {
264 friend class pool;
265 Handler handler;
266 detail::state<T> state;
267 std::mutex caller_join;
268 using lane = typename detail::state<T>::lane;
269 template<class H>
270 gig(pool & workers, H && fn, gig_options options) noexcept
271 : handler(std::forward<H>(fn)), state(workers, options) {
272 for (auto & local : state.lanes) { local->run = &execute; local->group = this; }
273 }
274 static void execute(detail::activation * raw) noexcept {
275 auto & local = *static_cast<lane *>(raw);
276 auto & self = *static_cast<gig *>(const_cast<void *>(local.group));
277 auto & state = self.state;
278 context<T> queue{state, local};
279 // Only the activation boundary is erased. Handler and T remain concrete.
280 // Context-only handlers can bind TLS/RAII once and drain the bounded batch.
281 if constexpr (!std::is_nothrow_invocable_v<Handler &, T, context<T> &> &&
282 !std::is_nothrow_invocable_v<Handler &, T>) {
283 std::invoke(self.handler, queue);
284 } else {
285 for (unsigned i = 0; i != detail::batch_limit && !local.pending.empty(); ++i) {
286 {
287 T value{std::move(local.pending.back())};
288 local.pending.pop_back();
289 if constexpr (std::is_nothrow_invocable_v<Handler &, T, context<T> &>)
290 std::invoke(self.handler, std::move(value), queue);
291 else std::invoke(self.handler, std::move(value));
292 }
293 queue.poll();
294 }
295 }
296 // Publish retirement only after the handler and its batch scopes end.
297 if (local.pending.empty() && !state.refill(local)) return;
298 state.yield(local);
299 }
300public:
301 gig(gig const &) = delete;
302 gig & operator=(gig const &) = delete;
304 void push(T value) {
305 std::lock_guard lock(state.mutex);
306 if (state.closed) throw std::logic_error("work: push into a closed gig");
307 state.incoming.push_back(std::move(value));
308 state.dispatch();
309 }
310
311 void push(std::span<T const> values) requires std::copy_constructible<T> {
312 std::lock_guard lock(state.mutex);
313 if (state.closed) throw std::logic_error("work: push into a closed gig");
314 // Copy first so a failed allocation/copy cannot leave unaccounted work.
315 std::vector<T> batch(values.begin(), values.end());
316 state.incoming.reserve(state.incoming.size() + batch.size());
317 for (auto & value : batch) state.incoming.push_back(std::move(value));
318 state.dispatch();
319 }
320
321 void close() noexcept { state.close(); }
324 void join() noexcept {
325 for (auto * at = detail::binding::current; at; at = at->previous)
326 if (at->group == this) std::terminate();
327 std::unique_lock lock(caller_join, std::defer_lock);
328 // Ordinary joins must stay cooperative: helping B while joining A can
329 // legitimately enter a B callback that also joins A.
330 if (state.caller_only) lock.lock();
331 close();
332 if (state.caller_only) {
333 while (!state.done.load(std::memory_order_acquire)) pool::execute(state.lanes[0].get());
334 } else state.workers.help(state.done);
335 }
336
337 ~gig() noexcept { join(); }
338};
339
340template<task T, class Handler> requires handler_for<std::decay_t<Handler>, T>
341auto pool::gig(Handler && handler, gig_options options) noexcept {
342 return work::gig<T, std::decay_t<Handler>>{*this, std::forward<Handler>(handler), options};
343}
344}
345
346namespace work {
347void pool::schedule(detail::activation & job) noexcept {
348 {
349 std::lock_guard lock(mutex);
350 job.next = nullptr;
351 if (last) last->next = &job; else first = &job;
352 last = &job;
353 }
354 ready.notify_one();
355}
356void pool::notify() noexcept {
357 // Pair with the condition-variable wait's mutex to prevent a completion
358 // notification from falling between its predicate check and actual wait.
359 std::lock_guard lock(mutex);
360 ready.notify_all();
361}
362detail::activation * pool::take() noexcept {
363 auto * job = first;
364 first = job->next;
365 if (!first) last = nullptr;
366 return job;
367}
368void pool::execute(detail::activation * job) noexcept {
369 auto const run = job->run;
370 detail::binding scope{job->group};
371 run(job); // Must not touch job again: the callback can publish its retirement.
372}
373void pool::worker() noexcept {
374 for (;;) {
375 std::unique_lock lock(mutex);
376 ready.wait(lock, [&] { return stopping || first; });
377 if (stopping) return;
378 auto * job = take();
379 lock.unlock();
380 execute(job);
381 }
382}
383void pool::help(std::atomic<bool> const & done) noexcept {
384 while (!done.load(std::memory_order_acquire)) {
385 std::unique_lock lock(mutex);
386 ready.wait(lock, [&] { return first || done.load(std::memory_order_acquire); });
387 if (done.load(std::memory_order_acquire)) return;
388 auto * job = take();
389 lock.unlock();
390 execute(job);
391 }
392}
393pool::pool(std::size_t workers) noexcept : width(std::max(workers, std::size_t{1})) {
394 for (std::size_t i = 1; i != width; ++i) threads.emplace_back([this] { worker(); });
395}
396pool::~pool() noexcept {
397 { std::lock_guard lock(mutex); stopping = true; }
398 ready.notify_all();
399 threads.clear();
400}
401}
Borrowed owner-local submissions; valid only during its handler call. Children are accepted after clo...
Definition work.ccm:228
std::size_t index() const noexcept
This activation's lane index, stable for the lifetime of the callback.
Definition work.ccm:253
bool pop(T &result) noexcept
Pop one task in a context-only batch handler, up to this activation's quantum. False ends this batch,...
Definition work.ccm:244
void push(T value) noexcept
Push a child on this worker's private stack without synchronization.
Definition work.ccm:238
void poll() noexcept
Donate older queued work when the accumulated stochastic deadline is due.
Definition work.ccm:240
A typed, nonmovable lifetime boundary for submitted tasks and descendants. Each accepted task runs ex...
Definition work.ccm:263
void push(std::span< T const > values)
Submit a copied batch under one admission lock.
Definition work.ccm:311
~gig() noexcept
Close and join before releasing handler and task storage.
Definition work.ccm:337
void close() noexcept
Stop external submissions. Already accepted descendants still run.
Definition work.ccm:321
void push(T value)
Submit external work. Throws logic_error if close() won the race.
Definition work.ccm:304
void join() noexcept
Close and cooperatively await completion. Repeated joins are harmless.
Definition work.ccm:324
~pool() noexcept
Definition work.ccm:396
pool(std::size_t workers=1) noexcept
Create a pool whose width includes the joining caller.
Definition work.ccm:393
std::size_t workers() const noexcept
Maximum number of lanes in a gig by default.
Definition work.ccm:108
std::size_t available_workers() const noexcept
Preserve caller-only execution for nested bulk phases.
Definition work.ccm:110
A handler consumes a task, optionally with a borrowed child-submission context.
Definition work.ccm:37
Tasks can move between workers without throwing.
Definition work.ccm:31
Bound a gig's concurrency or keep all its callbacks on the joining caller.
Definition work.ccm:42
bool caller_only
Work starts in join(), on that caller alone.
Definition work.ccm:44
std::size_t concurrency
Zero uses the pool's full width.
Definition work.ccm:43