threadman 0.1.0
Header-only C++23 managed threads, dynamic pools, futures, and executors
Loading...
Searching...
No Matches
ThreadMan

CI Docs

ThreadMan is a header-only C++23 library for running work on background threads and getting results back. It gives you a dynamically-scaling thread pool, composable futures (Future<T> / SharedFuture<T> with .then and .on_error), a managed-thread RAII wrapper, and a central manager that watches every thread and pool in your process. It also bridges std::future into its own future type, exposes plain-value snapshots for diagnostics, and emits structured logs and Prometheus-style metrics — both of which stay silent until your application turns them on.

Everything lives in namespace threadman.


Why use this library?

You reach for ThreadMan when raw std::thread / std::async stops being enough and you want a small, inspectable layer on top.

  • Good for server/framework code that submits many short tasks and wants a pool that grows under load and shrinks when idle.
  • Good for composing async work: submit(...).then(...).then(...) instead of manually chaining std::promise/std::future.
  • Good for observability — every thread, pool, task, and future can produce a copyable snapshot, and the manager can publish periodic summaries to your own listener.
  • Useful when you already have std::futures (from std::async, a database driver, an HTTP client) and want to fold their blocking waits into one elastic pool instead of burning a dedicated thread each.
  • Avoids silent deadlocks: calling join() on a still-running pool throws instead of hanging.
  • Not ideal for a fixed-size, lock-free, work-stealing pool tuned for maximum CPU throughput — ThreadMan uses a single mutex-guarded queue and optimizes for clarity and introspection, not raw scheduler speed.
  • Not ideal for projects that cannot take three small header-only dependencies (commons, logman, prom); they are always linked.

Quick example

The smallest useful program: make a pool, submit one task, read the result.

#include <iostream>
int main() {
threadman::ThreadPool pool; // 1 core + up to N workers
auto future = pool.submit([] { return 21 * 2; }); // Future<int>
std::cout << "answer: " << future.get() << '\n'; // blocks, prints 42
}
Definition thread_pool.hpp:92
Umbrella header for ThreadMan — pulls in every public type.

Why this works:

  • ThreadPool pool; starts immediately with min_workers (default 1) core worker threads. Construction never blocks.
  • submit takes any zero-argument callable, runs it on a worker, and hands you a Future<R> where R is the callable's return type (here int).
  • future.get() blocks the calling thread until the task finishes, then returns the value. If the task had thrown, get() would rethrow that exception here.
  • You don't shut the pool down manually: the destructor drains queued work and joins all workers (see Lifecycle and shutdown).

Chaining continuations and recovering from errors, without touching threads directly:

#include <iostream>
#include <stdexcept>
#include <string>
int main() {
// Each .then runs on the pool once the previous stage is ready.
auto text = pool.submit([] { return 6; })
.then(pool, [](int x) { return x * 7; })
.then(pool, [](int x) { return std::to_string(x); })
.get();
std::cout << "chained: " << text << '\n'; // "42"
// .on_error turns a thrown exception into a fallback value.
auto recovered = pool.submit([]() -> int { throw std::runtime_error("boom"); })
.on_error(pool, [](std::exception_ptr) { return -1; })
.get();
std::cout << "recovered: " << recovered << '\n'; // -1
}
std::shared_ptr< spdlog::logger > & pool()
Logger for tm.pool — scaling, shutdown, queue back-pressure.
Definition log.hpp:27

Installation

ThreadMan is header-only and ships as a CMake INTERFACE library exporting the target threadman::threadman. Its three required dependencies (commons, logman, prom, plus prom's own dimval and spdlog) are fetched automatically when you build it through CMake.

CMake + FetchContent (recommended)

include(FetchContent)
FetchContent_Declare(
threadman
URL https://github.com/aurimasniekis/cpp-threadman/archive/refs/tags/v0.1.0.tar.gz
URL_HASH SHA256=194e96ba5dcbd328986f9e321c14e9d6364f9a47542a68d559a94f1ffeb62aad
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
)
FetchContent_MakeAvailable(threadman)
target_link_libraries(your_app PRIVATE threadman::threadman)

A minimal complete consumer project:

cmake_minimum_required(VERSION 3.25)
project(example LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
include(FetchContent)
FetchContent_Declare(
threadman
URL https://github.com/aurimasniekis/cpp-threadman/archive/refs/tags/v0.1.0.tar.gz
URL_HASH SHA256=194e96ba5dcbd328986f9e321c14e9d6364f9a47542a68d559a94f1ffeb62aad
DOWNLOAD_EXTRACT_TIMESTAMP TRUE
)
FetchContent_MakeAvailable(threadman)
add_executable(example main.cpp)
target_link_libraries(example PRIVATE threadman::threadman)

CMake <tt>add_subdirectory</tt>

If you vendor the source (e.g. a git submodule):

add_subdirectory(third_party/cpp-threadman)
target_link_libraries(your_app PRIVATE threadman::threadman)

Installed package

When ThreadMan and all its dependencies come from system packages (not FetchContent), it installs a CMake package config:

find_package(threadman CONFIG REQUIRED)
target_link_libraries(your_app PRIVATE threadman::threadman)

‍Note: THREADMAN_INSTALL is automatically disabled if any dependency was pulled in via FetchContent, because a FetchContent-built dependency cannot be re-exported. To produce installable artifacts, provide the dependencies as installed packages.

Optional integrations

Both are off by default and enabled via CMake options:

set(THREADMAN_WITH_NLOHMANN_JSON ON) # to_json / from_json for snapshots
set(THREADMAN_WITH_PARCEL ON) # cpp-parcel cell adapters (also forces JSON on)

They also self-enable by header detection: if <nlohmann/json.hpp> (or <parcel/parcel.h>) is already on the include path, the corresponding hooks turn on automatically. See Optional: JSON and Optional: parcel.


Requirements

  • C++23. The build sets CMAKE_CXX_STANDARD 23 with extensions off.
  • CMake 3.25+ and a threading library (Threads::Threads / pthread).
  • Required dependencies (fetched automatically): commons (≥ 0.1.4, for DisplayInfo), logman (≥ 0.1.0, spdlog-backed logging), and prom (≥ 0.1.0, metrics; pulls in dimval). spdlog 1.17.0 is fetched transitively.
  • Optional dependencies: nlohmann/json (3.12.0) and cpp-parcel (0.2.0).
  • Platform: native thread naming and ids use pthread on Linux and macOS; other platforms fall back to a hashed std::thread::id and skip naming. The library otherwise has no OS-specific behavior.

Core concepts

<tt>ThreadPool</tt> — the workhorse

A pool of worker threads with a FIFO task queue. It starts with min_workers core threads that never retire, and scales up to max_workers non-core threads when the queue backs up; non-core threads retire after idle_timeout. Scaling is driven by the ThreadManager housekeeper (see below), so a pool only grows while a manager is ticking it.

opts.name = "io";
opts.min_workers = 2; // core threads, never retire
opts.max_workers = 16;
opts.scale_up_when_queue_exceeds = 4; // grow when queue depth passes this
opts.scale_up_wait = std::chrono::milliseconds{50}; // ...or when oldest task waits this long
opts.idle_timeout = std::chrono::seconds{30}; // non-core retirement
opts.max_queue_size = 1024; // 0 = unbounded; else PoolQueueFullError on overflow
Definition thread_pool.hpp:70
std::size_t scale_up_when_queue_exceeds
Definition thread_pool.hpp:77
std::string name
Definition thread_pool.hpp:71
std::chrono::milliseconds idle_timeout
Definition thread_pool.hpp:78
std::size_t max_workers
Definition thread_pool.hpp:75
std::size_t min_workers
Definition thread_pool.hpp:74
std::chrono::milliseconds scale_up_wait
Definition thread_pool.hpp:76
std::size_t max_queue_size
Definition thread_pool.hpp:82

ThreadPool is non-copyable and non-movable (workers hold a pointer back to it). Construct it where it will live, or keep it on the heap behind a std::unique_ptr. It implements IExecutor, so it can be passed anywhere an executor is expected.

Default options: min_workers = 1, max_workers = hardware_concurrency(), unbounded queue. max_workers == 0 is coerced to 1, and min_workers is clamped down to max_workers.

<tt>Future<T></tt>, <tt>SharedFuture<T></tt>, <tt>Promise<T></tt>

  • **Future<T>** is move-only and one-shot: exactly one get() (which consumes it) and at most one continuation (then or on_error).
  • **SharedFuture<T>** is copyable and multi-shot: many get()s (returning const T&) and many continuations. Get one with Future::share().
  • **Promise<T>** is the producer side. You rarely create one directly — ThreadPool::submit and FutureWaitPool::add make them for you — but it's there when you need to bridge a callback-based API.
p.set_value(7);
std::cout << f.get() << '\n'; // 7
Definition future.hpp:239
U get()
Block, then consume — second get() throws.
Definition future.hpp:277
Definition future.hpp:436
void set_value(U &&v)
Definition future.hpp:478
Future< T > get_future()
Retrieve the future.
Definition future.hpp:465

Lifetime: the result lives in a heap-allocated shared state owned jointly by the promise and its future(s). A Future/SharedFuture keeps that state alive on its own, so it stays valid even after the producing pool is gone.

Executors

An executor is "anything that can run a `std::function<void()>`". ThreadMan offers a virtual interface and a concept-based duck-typed version:

  • IExecutor — abstract base with execute(...) and name().
  • Executor — a concept matching any type with execute(std::function<void()>).
  • InlineExecutor — runs the task synchronously on the calling thread. Singleton: threadman::InlineExecutor::instance().
  • SingleThreadExecutor — owns a one-worker pool; tasks run FIFO on a single thread.
  • ExecutorRef<E> — wraps a concept-conforming executor behind IExecutor.

Future::then(exec, fn) takes the executor as its first argument and dispatches the continuation through it. ThreadPool, FutureWaitPool, SingleThreadExecutor, and InlineExecutor are all valid executors.

<tt>ManagedThread</tt>

An RAII wrapper around std::jthread. It publishes lifecycle state (Starting → Running → Completed/Failed) through a shared ControlBlock, applies a best-effort OS thread name, captures any escaped exception, and registers itself with a ThreadManager so it shows up in snapshots.

threadman::ManagedThread t({.name = "worker"}, [](std::stop_token tok) {
while (!tok.stop_requested()) {
// do periodic work
}
});
t.join();
Definition thread.hpp:79
bool request_stop() noexcept
Request the thread to stop. The body sees tok.stop_requested().
Definition thread.hpp:203

The body may take an optional leading std::stop_token; the constructor selects the right overload automatically. ManagedThread is move-only.

<tt>ThreadManager</tt>

A registry of every live ManagedThread and ThreadPool, with a dedicated housekeeper thread that, on each tick:

  1. prunes dead threads from the registry,
  2. calls scale_tick(now) on every registered pool (this is what makes pools scale),
  3. detects tasks running longer than the pool's stuck_task_threshold,
  4. periodically publishes a ManagerSummary to subscribed listeners.

There is a process-wide singleton (ThreadManager::instance()) used by default. You can also construct a private ThreadManager for tests or isolation and point pools/threads at it via the manager option.

The housekeeper starts lazily when the first pool registers (or eagerly if Options::start_housekeeper_eagerly is set). A pool with no manager ticking it keeps only its core workers and never scales.

Snapshots

Every major type produces a plain, copyable value snapshot — no locks, no references into live state — so you can pass them across threads, log them, or serialize them: ThreadSnapshot, ThreadPoolStats, TaskSnapshot, FutureSnapshot, ManagerSummary, and StuckTaskEvent.


Common usage patterns

Submit work and collect results

threadman::ThreadPool pool{{.name = "fanout", .min_workers = 4, .max_workers = 8}};
std::vector<threadman::Future<int>> futures;
for (int i = 0; i < 1000; ++i) {
futures.emplace_back(pool.submit_named("job-" + std::to_string(i), [i] { return i; }));
}
long long sum = 0;
for (auto& f : futures) {
sum += f.get(); // gather in submission order
}

Submission flavours:

Call Returns Use when
pool.execute(fn) void Fire-and-forget; fn takes no args, result discarded.
pool.submit(fn) Future<R> You want the result or to chain continuations.
pool.submit_named("n", fn) Future<R> Same, but tags the task name for snapshots.
pool.submit_stoppable(fn) Future<R> fn receives a std::stop_token to observe cancellation.

execute silently ignores an empty std::function. submit* accept any callable; the return type is deduced from the callable.

Chain work with <tt>.then</tt>

auto result = pool.submit([] { return load_config(); })
.then(pool, [](Config c) { return validate(c); })
.then(pool, [](Config c) { return apply(c); })
.get();

Each .then(exec, fn) returns a new Future<U> where U is fn's return type. The continuation receives the previous stage's value. If any stage throws (or fn's argument stage failed), the exception propagates straight through — later then stages are skipped and the final get() rethrows.

void stages work too: a Future<void> continuation takes no argument, and a continuation returning void produces a Future<void>.

Recover from errors with <tt>.on_error</tt>

auto value = pool.submit([]() -> int { return risky(); })
.on_error(pool, [](std::exception_ptr e) {
try { std::rethrow_exception(e); }
catch (const std::exception& ex) { log(ex.what()); }
return fallback_value();
})
.get();

on_error(exec, fn) returns a Future<T>. If the source succeeded, the value passes through unchanged and fn is not called. If it failed, fn receives the std::exception_ptr and its return value becomes the result. Note that then and on_error each count as the one continuation a Future allows — you can't attach both to the same one-shot future (use share()).

Fan out to multiple consumers with <tt>share()</tt>

auto source = pool.submit([] { return compute(); }).share(); // SharedFuture<int>
auto a = source.then(pool, [](int x) { return x + 1; });
auto b = source.then(pool, [](int x) { return x * 2; });
auto c = source.then(pool, [](int x) { return x - 3; });

Future::share() converts a one-shot future into a copyable SharedFuture that supports many get()s and many continuations off the same result. share() consumes the original Future.

Cancellable tasks and immediate shutdown

auto longjob = pool.submit_stoppable([](std::stop_token tok) {
for (int i = 0; i < 1000 && !tok.stop_requested(); ++i) {
do_chunk(i);
}
return tok.stop_requested() ? "stopped" : "finished";
});
pool.shutdown_now(); // request stop on running tasks, cancel queued ones
std::cout << longjob.get() << '\n'; // sees the stop and returns "stopped"

submit_stoppable hands your callable a std::stop_token. A running task is responsible for checking it — ThreadMan can't preempt CPU-bound work, it can only request a stop. Tasks still sitting in the queue when shutdown_now() fires are cancelled; their futures rethrow TaskCancelledError.

Adopt a <tt>std::future</tt> with <tt>FutureWaitPool</tt>

A std::future has no completion callback, so the only way to know it's ready is to block a thread on get(). A fixed-size pool is a bad host for that — a few slow futures can occupy every worker. FutureWaitPool is a ThreadPool tuned for blocking waits: a worker parked in get() counts as busy (never idle), so saturation reliably triggers scale-up, and it adds capacity eagerly (scale_up_when_queue_exceeds == 0 by default).

auto wp = mgr.make_future_wait_pool({.name = "io-waits",
.min_workers = 1,
.max_workers = 8});
std::future<int> std_fut = some_api_returning_std_future();
threadman::Future<int> f = wp.add(std::move(std_fut)); // now a tm::Future
auto chained = f.then(cpu_pool, [](int v) { return v * 2; }); // full .then support
auto then(Exec &exec, Fn &&fn)
Register a continuation; consumes this future.
Definition future.hpp:568
static ThreadManager & instance() noexcept
Definition manager.hpp:138

add accepts std::future<T> (including move-only T) and std::shared_future<T>. add_blocking(fn) runs an arbitrary blocking callable on the same elastic pool without first wrapping it in a std::future. The returned Future<T> is fully first-class.

‍ThreadMan's Future/SharedFuture do not implicitly convert to or from std::future. FutureWaitPool::add is the supported bridge.

Observe the system with the manager

threadman::ThreadManager mgr{{.name = "demo", .summary_interval = std::chrono::milliseconds{100}}};
auto summary_tok = mgr.subscribe_summary([](const threadman::ManagerSummary& s) {
std::cout << "threads=" << s.live_threads
<< " pools=" << s.total_pools
<< " queued=" << s.total_queued
<< " done=" << s.total_completed << '\n';
});
auto stuck_tok = mgr.subscribe_stuck_tasks([](const threadman::StuckTaskEvent& ev) {
std::cout << "task " << ev.task.id << " stuck on pool " << ev.pool_id << '\n';
});
threadman::ThreadPool pool{{.name = "work", .manager = &mgr}}; // attach to this manager
// ... submit work ...
Definition manager.hpp:47
SubscriptionToken subscribe_summary(SummaryListener cb)
Definition manager.hpp:322
Aggregate publish-payload produced periodically by the ThreadManager's housekeeper.
Definition stats.hpp:101
std::size_t live_threads
Definition stats.hpp:103
std::size_t total_pools
Definition stats.hpp:104
std::size_t total_queued
Definition stats.hpp:106
std::uint64_t total_completed
Definition stats.hpp:107
A single stuck-task event published to StuckTaskListeners.
Definition stats.hpp:114
std::uint64_t pool_id
Definition stats.hpp:117
TaskSnapshot task
Definition stats.hpp:115
std::uint64_t id
Definition stats.hpp:80

subscribe_summary / subscribe_stuck_tasks return a move-only SubscriptionToken. The listener stays active until the token is destroyed — keep it alive for as long as you want callbacks. Listeners run on the housekeeper thread; an exception thrown from one is caught and logged, never propagated.

Read snapshots directly

threadman::ThreadPoolStats st = pool.stats();
std::cout << "workers=" << st.workers
<< " active=" << st.active
<< " queued=" << st.queued
<< " completed="<< st.completed << '\n';
for (const auto& w : pool.snapshot_workers()) { /* ThreadSnapshot */ }
for (const auto& t : pool.snapshot_recent_tasks()) { /* TaskSnapshot */ }
auto summary = threadman::ThreadManager::instance().build_summary(); // on demand
ManagerSummary build_summary() const
Definition manager.hpp:277
A snapshot of a pool's headline counters.
Definition stats.hpp:60
std::size_t queued
Tasks waiting in the queue.
Definition stats.hpp:69
std::uint64_t completed
Definition stats.hpp:70
std::size_t workers
Currently live workers.
Definition stats.hpp:64
std::size_t active
Workers currently in Running.
Definition stats.hpp:67

Snapshots are point-in-time copies; reading them never blocks workers for longer than a short critical section.


Lifecycle and shutdown

A ThreadPool moves through Running → ShuttingDown/ShutdownNow → Terminated.

  • **shutdown()** — stop accepting new work, drain everything already queued, then let workers exit. Graceful.
  • **shutdown_now()** — stop accepting new work, cancel queued tasks (their futures throw TaskCancelledError), and request stop on running tasks.
  • **join()** — block until all workers have exited. You must call shutdown() or shutdown_now() first.
  • Destructor — if still Running, calls shutdown() (the draining variant) and joins. So the common case "just let the pool go out of scope" drains queued work.
pool.shutdown(); // or shutdown_now()
pool.join(); // safe now

Submitting to a pool that is no longer Running throws PoolShuttingDownError.


Error handling

ThreadMan reports problems through a typed exception hierarchy rooted at threadman::Exception (itself a std::runtime_error). Catch the base to handle anything from the library, or narrow down:

Exception Thrown when
FutureError Operating on a future/promise with no state (default-constructed or moved-from). Base of the future errors below.
BrokenPromiseError A Promise is destroyed before being satisfied; its future's get() rethrows this.
FutureAlreadyRetrievedError Promise::get_future() called a second time.
PromiseAlreadySatisfiedError set_value/set_exception called twice on one promise.
ContinuationAlreadyRegisteredError A second .then/.on_error on a one-shot Future. Use share().
PoolShuttingDownError submit/execute on a pool past shutdown().
PoolQueueFullError A bounded pool's queue is at max_queue_size.
TaskCancelledError A queued task was cancelled (e.g. by shutdown_now()).
HousekeeperError Reserved for internal housekeeper bookkeeping failures.

Task exceptions propagate through the future. A callable that throws does not crash the worker — the exception is captured and rethrown at get() (or routed to on_error):

auto f = pool.submit([]() -> int { throw std::runtime_error("nope"); });
try {
f.get();
} catch (const std::runtime_error& e) {
std::cerr << "task failed: " << e.what() << '\n';
}

Backpressure on a bounded pool:

threadman::ThreadPool pool{{.max_queue_size = 2}};
try {
pool.execute(slow_task);
pool.execute(slow_task);
pool.execute(slow_task); // may throw if the first two haven't started
// apply your own backpressure: retry later, drop, or block
}
Submitting work to a bounded pool whose queue is already at capacity.
Definition exceptions.hpp:71

Edge cases and pitfalls

  • **get() consumes a Future.** A second get() (or any use after get()) throws FutureError, because the first get() moved the shared state out. Use share() if you need to read a result more than once.
  • One continuation per Future. then and on_error together allow a single continuation on a one-shot future; a second attempt throws ContinuationAlreadyRegisteredError. SharedFuture has no such limit.
  • Continuations never run inline (unless you ask). .then/.on_error always dispatch through the supplied executor — even when the source future is already ready, dispatch is deferred to the executor rather than running on the calling thread. The one exception is InlineExecutor, which runs the continuation synchronously on whatever thread satisfies the future.
  • The executor must outlive the continuation. then(exec, fn) captures exec by reference. If exec is destroyed before the source future is satisfied, dispatch is undefined. Pass a long-lived pool, not a temporary.
  • A pool only scales while a manager ticks it. Scaling happens inside the housekeeper's scale_tick. The default singleton starts its housekeeper on first pool registration; a private ThreadManager starts its housekeeper lazily on first pool registration too, or eagerly if configured. Without a ticking manager, the pool keeps just its core workers.
  • **join() before shutdown() throws.** This is deliberate — it surfaces a logic error (std::logic_error) instead of deadlocking.
  • **shutdown_now() cannot preempt CPU work.** It cancels queued tasks and requests stop on running ones; a running task only stops if it checks its std::stop_token. Use submit_stoppable for cancellable work.
  • A dropped Promise breaks its Future. If you create a Promise manually and let it die without set_value/set_exception, the future's get() throws BrokenPromiseError.
  • **SharedFuture::get() returns const T&.** The value is owned by the shared state; the reference is valid as long as a SharedFuture keeps that state alive. Don't hold the reference past the last SharedFuture copy.
  • Bounded queue overflow throws on submit, it does not block. If you want blocking backpressure, build it around the PoolQueueFullError.
  • OS thread naming is best-effort and truncated. macOS caps names at 63 chars, Linux at 15; other platforms skip naming entirely. Names are only a diagnostic aid.
  • Move-only results: FutureWaitPool::add(std::future<T>) supports move-only T. std::shared_future<T> requires copyable T (the value is copied out).

Logging

ThreadMan logs through logman channels, all prefixed tm.:

Channel Covers
tm.pool pool scaling, shutdown, queue backpressure
tm.thread ManagedThread lifecycle and faults
tm.future future satisfaction, continuation dispatch
tm.manager housekeeper lifecycle, listener faults
tm.task stuck-task warnings (only when no listener is subscribed)
tm.executor executor wiring

Initialization is your responsibility — ThreadMan only ever calls logman::get(...), which lazily creates a channel using whatever defaults logman has when first touched. Set it up once at startup:

logman::initialize(spdlog::level::info);
logman::set_levels_by_prefix("tm.", spdlog::level::debug); // turn tm.* up to debug

If you never initialize logman, logging falls back to logman's own defaults.


Metrics

ThreadMan is instrumented with prom Prometheus/OpenMetrics families under the scope "threadman" (prefix tm_), mirroring the tm.* log channels: tm_pool_*, tm_threads_*, tm_manager_*, tm_tasks_stuck_*, tm_futures_*, and tm_future_continuations_*.

Metrics are always-on but inert by default — exactly like logging with no sinks. Until your application installs a prom backend adapter, every metric operation is a noexcept no-op through prom's NullAdapter. The host owns backend selection:

#include <prom/prom.hpp>
prom::Registry::global()->set_adapter(my_backend); // e.g. a prometheus-cpp adapter

The only dynamic labels are pool (the operator-chosen pool name) and pool_id (numeric) — never task ids, thread ids, or exception text, which would be unbounded cardinality. examples/threadman_metrics.cpp runs a workload and lists the registered tm_* families.


DisplayInfo

Every public type exposes static const comms::DisplayInfo& display_info() (a name, description, and icon, from commons): ManagedThread, TaskHandle, Future, SharedFuture, Promise, ThreadPool, FutureWaitPool, SingleThreadExecutor, InlineExecutor, and ThreadManager. The enums (ThreadState, TaskState, PoolState) are made displayable non-intrusively in <threadman/display.hpp>. This is purely for tooling/diagnostics and has no runtime cost if unused.


Optional: JSON serialization

Enable with -DTHREADMAN_WITH_NLOHMANN_JSON=ON (or by placing <nlohmann/json.hpp> on the include path). Every snapshot type and the three enums round-trip:

nlohmann::json j = pool.stats(); // ThreadPoolStats -> json
auto rt = j.get<threadman::ThreadPoolStats>();// json -> ThreadPoolStats

Optional: parcel serialization

Enable with -DTHREADMAN_WITH_PARCEL=ON (which forces JSON on, since the parcel cells reuse the JSON hooks). Five cells are provided — ThreadSnapshotCell, ThreadPoolStatsCell, TaskSnapshotCell, FutureSnapshotCell, ManagerSummaryCell — with wire kinds tm:thread_snapshot, tm:thread_pool_stats, and so on. Register them once per registry:

parcel::ParcelRegistry reg;
void register_cells(parcel::ParcelRegistry &registry)
Definition parcel.hpp:184

Configuration macros

Defaults live in <threadman/config.hpp> and can be redefined by the consumer before including the umbrella header. Per-pool ThreadPoolOptions always take precedence over these compile-time defaults.

Macro Default Meaning
THREADMAN_RECENT_TASKS_CAPACITY 256 Ring-buffer size for snapshot_recent_tasks()
THREADMAN_DEFAULT_POOL_NAME "tm::default" Name of ThreadManager::default_pool()
THREADMAN_DEFAULT_SCALE_UP_WAIT_MS 50 Oldest-queued wait before forcing scale-up
THREADMAN_DEFAULT_IDLE_TIMEOUT_MS 30000 Non-core worker retirement window
THREADMAN_DEFAULT_SCALE_UP_QUEUE_THRESHOLD 4 Queue depth that triggers scale-up
THREADMAN_DEFAULT_SCALE_CHECK_INTERVAL_MS 100 Housekeeper tick / scale cadence
THREADMAN_DEFAULT_STUCK_TASK_THRESHOLD_MS 60000 Running duration that flags a task as stuck
THREADMAN_DEFAULT_SUMMARY_INTERVAL_MS 1000 Summary publish cadence
THREADMAN_DEFAULT_FUTURE_WAIT_SCALE_UP_WAIT_MS 10 FutureWaitPool scale-up wait window

API overview

API Purpose Notes
ThreadPool Dynamic-scaling worker pool Non-copyable, non-movable; IExecutor
ThreadPoolOptions Pool configuration name, workers, scaling, queue bound
Future<T> One-shot async result move-only; one get, one continuation
SharedFuture<T> Multi-shot async result copyable; get()const T&
Promise<T> Producer side of a future get_future, set_value, set_exception
FutureWaitPool Elastic pool for blocking waits add(std::future), add_blocking
ManagedThread RAII std::jthread wrapper move-only; lifecycle + stop-token
ThreadManager Registry + housekeeper instance() singleton or private
IExecutor / Executor Executor interface / concept ThreadPool implements IExecutor
InlineExecutor Runs tasks on the calling thread singleton instance()
SingleThreadExecutor One-worker FIFO executor owns its pool
ThreadPoolStats, ThreadSnapshot, TaskSnapshot, FutureSnapshot, ManagerSummary, StuckTaskEvent Plain-value snapshots copyable, lock-free

Examples

All live under examples/ and are built when ThreadMan is the top-level project (or with -DTHREADMAN_BUILD_EXAMPLES=ON).

Example Demonstrates
threadman_hello.cpp Minimal pool + single submit + get
threadman_pool_submit.cpp Fan-out of named tasks, gathering results, reading stats
threadman_then_chain.cpp .then chaining and .on_error recovery
threadman_shared_future.cpp share() fan-out to multiple continuations
threadman_shutdown_now.cpp Stoppable task + queued-task cancellation
threadman_dynamic_scaling.cpp Watch a pool scale up under load and retire idle workers
threadman_future_wait_pool.cpp Adopt std::futures into the elastic wait pool
threadman_listener_summary.cpp Subscribe to periodic summaries and stuck-task events
threadman_log_setup.cpp Initialize logman and raise tm.* log levels
threadman_metrics.cpp Run a workload and list the registered tm_* metric families

Performance notes

ThreadMan optimizes for clarity and introspection over raw scheduler throughput.

  • Task dispatch goes through a single mutex-guarded std::deque per pool, with a condition variable to wake workers. This is simple and predictable, not lock-free or work-stealing.
  • **get() blocks** the calling thread on a condition variable until the result is ready.
  • Continuations allocate a small shared state per stage (shared_ptr), so a long .then chain trades a few heap allocations for composability.
  • Scaling decisions are evaluated on the housekeeper tick (scale_check_interval, default 100 ms), so growth is not instantaneous — a burst shorter than a tick may never trigger scale-up.
  • Snapshots copy out under a short lock; reading them is cheap relative to task execution but is not free.

FAQ

Do I need to link a library, or is it header-only? Header-only, but it's not zero-config: link the CMake target threadman::threadman, which transitively brings in commons, logman, and prom.

What happens if a task throws? The exception is captured and rethrown when you call Future::get(), or routed to .on_error if you attached one. The worker thread keeps running.

Can I use a pool from multiple threads? Yes. submit/execute/stats/snapshots are safe to call concurrently. A single Future<T> is not meant to be shared across threads (it's move-only and one-shot); use SharedFuture<T> if multiple threads need the result.

Does a future own its result, or borrow it? It owns it. The shared state outlives the producing pool, so a Future stays valid even after the pool is destroyed.

Why isn't my pool growing past min_workers? Scaling runs on the ThreadManager housekeeper. Make sure a manager is ticking the pool (the default singleton does this automatically once the pool registers), and that the load actually exceeds scale_up_when_queue_exceeds or that the oldest task waits longer than scale_up_wait.

Why does join() throw? Because you called it before shutdown()/shutdown_now(). Shut the pool down first; the library refuses to silently deadlock.

How do I see what's running? Call pool.stats(), pool.snapshot_workers(), pool.snapshot_recent_tasks(), or subscribe to ThreadManager::subscribe_summary.

Which compilers work? Any C++23 toolchain with std::jthread. Consult the CI workflow for the exact tested versions.


Contributing

Contributions to the library are welcome! If you encounter any issues or have suggestions for improvements, please feel free to submit a pull request or open an issue on the project's repository.

License

This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.