|
ThreadSchedule 2.2.0
Modern C++ thread management library
|
A modern C++ library for advanced thread management on Linux and Windows. ThreadSchedule provides enhanced wrappers for std::thread, std::jthread, and pthread with extended functionality including thread naming, priority management, CPU affinity, and high-performance thread pools.
Available as header-only, as a C++20 module (import threadschedule;), or with optional shared runtime for multi-DSO applications.
Version 2.2 focuses on broader thread-control coverage, more modern callable handling on newer standards, and wider C++26 CI coverage. Highlights:
| Area | What changed |
|---|---|
| ThreadInfo | ThreadInfo can now bind a specific Tid, not just the current thread. Use it to query or configure name, priority, policy, and affinity for library-owned background threads or other known thread IDs. |
| Background thread control | ScheduledThreadPoolT exposes scheduler_thread_info() / configure_scheduler_thread(...), and ChaosController exposes thread_info() / configure_thread(...). |
| Callable modernization | Internal task/callback storage is feature-gated: move-only hot paths can use std::move_only_function, reusable hooks can use std::copyable_function, and older toolchains keep the std::function path. |
| Move-only task support | post/try_post, one-shot scheduled tasks, pthread entry trampolines, and error-handling wrappers now accept more move-only payloads cleanly on newer standard libraries. |
| Tests & benchmarks | New regression tests cover move-only tasks/callbacks and invalid ThreadInfo(Tid) targets. A new callable_benchmarks target compares small, large, and move-only task capture overhead. |
| CI | Linux C++26 coverage now includes gcc-16 and clang-22 in addition to the existing modern compiler jobs. |
Version 2.0 focuses on lower-overhead submission, more control over shutdown and tuning, and better ergonomics for modern C++ (ranges, coroutines, std::stop_token). Highlights:
| Area | What changed |
|---|---|
| Lightweight pool | LightweightPoolT<TaskSize> / LightweightPool - fire-and-forget only, configurable SBO buffer (default 64 B), no futures or stats. Workers are still ThreadWrapper (name, affinity, policy). Ideal for maximum throughput when you do not need a return value. |
| post() / try_post() | On HighPerformancePool, ThreadPool / FastThreadPool, and GlobalPool - same queue path as submit() but skips packaged_task / future overhead. |
| Non-throwing submit | try_submit() returns expected<future<R>, error_code>; try_submit_batch() returns expected<vector<future<void>>, error_code> instead of throwing on shutdown. |
| Scheduled dispatch | ScheduledThreadPoolT dispatches with post() internally. Alias ScheduledLightweightPool uses LightweightPool as the backend. |
| Shutdown | ShutdownPolicy::drain (default) vs drop_pending; shutdown_for(timeout) for a timed drain. |
| Parallel loops | Chunked parallel_for_each on all pool types (shared helper across single-queue and work-stealing pools). |
| Tuning | PollingWait<IntervalMs> for FastThreadPool, configurable work-stealing deque capacity on HighPerformancePool, GlobalPool::init(n) before first use. |
| C++20 | Ranges overloads for batch submit and parallel_for_each; submit/try_submit with std::stop_token (cooperative skip). |
| Futures | when_all, when_any, when_all_settled in futures.hpp. |
| Coroutines | schedule_on{pool}, pool_executor, run_on(pool, coro_fn) for pool-aware task. |
| Observability | Optional auto-registration of pool workers in the thread registry; per-task set_on_task_start / set_on_task_end hooks. |
| Errors | ErrorHandler callbacks get stable IDs; remove_callback(id) / has_callback(id). |
See CHANGELOG.md for the full list, including breaking changes when upgrading from v1.x.
Upgrading from v1.x: Migration guide (v2.0)
ThreadSchedule is designed to work on any platform with a C++17 (or newer) compiler and standard threading support. The library is continuously tested on:
| Platform | Compiler | C++17 | C++20 | C++23 | C++26 |
|---|---|---|---|---|---|
| Linux (x86_64) | |||||
| Ubuntu 22.04 | GCC 11 | yes | yes | yes | - |
| Ubuntu 22.04 | GCC 12 | - | yes | - | - |
| Ubuntu 22.04 | Clang 14 | yes | yes | yes | - |
| Ubuntu 22.04 | Clang 15 | - | yes | yes | - |
| Ubuntu 24.04 | GCC 13 | yes | yes | yes | - |
| Ubuntu 24.04 | GCC 14 | yes | yes | yes | yes |
| Ubuntu 24.04 | GCC 15 | - | yes | yes | yes |
| Ubuntu 24.04 | GCC 16 | - | - | - | yes |
| Ubuntu 24.04 | Clang 16 | yes | yes | - | - |
| Ubuntu 24.04 | Clang 18 | yes | yes | - | - |
| Ubuntu 24.04 | Clang 19 | - | yes | yes | yes |
| Ubuntu 24.04 | Clang 21 | - | yes | yes | yes |
| Ubuntu 24.04 | Clang 22 | - | - | - | yes |
| Linux (ARM64) | |||||
| Ubuntu 24.04 ARM64 | GCC 13 (system) | yes | yes | yes | - |
| Ubuntu 24.04 ARM64 | GCC 14 | - | yes | yes | yes |
| Windows | |||||
| Windows Server 2022 | MSVC 2022 | yes | yes | yes | - |
| Windows Server 2022 | MinGW-w64 (GCC 15) | yes | yes | yes | - |
| Windows Server 2025 | MSVC 2022 | yes | yes | yes | - |
| Windows Server 2025 | MinGW-w64 (GCC 15) | yes | yes | yes | - |
Additional platforms: ThreadSchedule should work on other platforms (macOS, FreeBSD, other Linux distributions) with standard C++17+ compilers, but these are not regularly tested in CI.
C++23: GCC 12's libstdc++ lacks monadic std::expected operations (and_then, transform, ...). Clang 16/18 on Ubuntu 24.04 use GCC 14's libstdc++ headers which expose std::expected incorrectly to those Clang versions. These combinations are therefore only tested up to C++20.
C++26: Requires GCC 14+ or Clang 19+. MSVC does not yet expose cxx_std_26 to CMake; C++26 on Windows is not tested.
Reflection APIs: The optional threadschedule::reflect API and reflection-backed registry queries require GCC 16+ with THREADSCHEDULE_ENABLE_REFLECTION=ON. These APIs are not built on other toolchains or standards.
GCC 15: Installed via ppa:ubuntu-toolchain-r/test on Ubuntu 24.04.
GCC 16: Installed via ppa:ubuntu-toolchain-r/test on Ubuntu 24.04.
Clang 21: Installed via the official LLVM apt repository (apt.llvm.org) on Ubuntu 24.04.
Clang 22: Installed via the official LLVM apt repository (apt.llvm.org) on Ubuntu 24.04.
Windows ARM64: Not currently covered by GitHub-hosted runners, requires self-hosted runner for testing.
MinGW: MinGW-w64 (MSYS2) ships GCC 15 and provides full Windows API support including thread naming (Windows 10+).
Add to your CMakeLists.txt using CPM.cmake:
Other integration methods: See docs/INTEGRATION.md for FetchContent, Conan, system installation, and shared runtime option.
ThreadSchedule can also be consumed as a C++20 module (requires CMake 3.28+ and Ninja or Visual Studio 17.4+):
Operate on existing threads without owning their lifetime.
You can also pass threads directly to APIs that take views; the view is created implicitly (non-owning):
std::jthread (C++20):
Use ThreadInfo when you already know a Tid and want a lightweight control handle without wrapping ownership.
Opt-in registered threads with process-wide control, without imposing overhead on normal wrappers.
For multi-DSO applications: Use the shared runtime option (THREADSCHEDULE_RUNTIME=ON) to ensure a single process-wide registry. See docs/REGISTRY.md for detailed patterns.
Notes:
When THREADSCHEDULE_ENABLE_REFLECTION=ON is active on GCC 16+ with -std=c++26, ThreadSchedule exposes field metadata and faster field-oriented registry queries.
You can also inspect reflected library types directly:
Find by name (Linux):
ThreadSchedule uses threadschedule::expected<T, std::error_code> (and expected<void, std::error_code>). When available, this aliases to std::expected, otherwise, a compatible fallback based on P0323R3 is used.
Note: when building with -fno-exceptions, behavior is not standard-conforming because value()/operator* cannot throw bad_expected_access on error (exceptions are disabled). In that mode, always check has_value() or use value_or() before accessing the value.
Recommended usage:
Lazy coroutine primitives - no boilerplate promise types required.
For more details: See the Coroutines Guide.
| Class | Description | Available On |
|---|---|---|
| ThreadWrapper | Enhanced std::thread with naming, priority, affinity | Linux, Windows |
| JThreadWrapper | Enhanced std::jthread with cooperative cancellation (C++20) | Linux, Windows |
| PThreadWrapper | Modern C++ interface for POSIX threads | Linux only |
Zero-overhead helpers to operate on existing threads without taking ownership.
| Class | Description | Available On |
|---|---|---|
| ThreadWrapperView | View over an existing std::thread | Linux, Windows |
| JThreadWrapperView | View over an existing std::jthread (C++20) | Linux, Windows |
| ThreadByNameView | Locate and control a thread by its name | Linux only |
| Class | Use Case | Notes |
|---|---|---|
| ThreadPool | Single shared queue, blocks while idle | submit, try_submit, post, batches, parallel_for_each |
| FastThreadPool | Same as ThreadPool with polling wait policy | Tunable via PollingWait<IntervalMs> |
| HighPerformancePool | Work-stealing + overflow queue | Highest throughput for large batches; tunable deque capacity |
| LightweightPool | Fire-and-forget only, SBO tasks | No futures; use post / post_batch. Alias of LightweightPoolT<64> |
All of the above support shutdown(ShutdownPolicy) and shutdown_for(timeout) where applicable. Use post() when you do not need a std::future (lower overhead than submit()). On newer standard libraries, internal queueing and hook/error-callback storage can transparently use standard move-only/copyable call wrappers.
For more details: See the Integration Guide, Registry Guide, and CMake Reference linked at the top of this README.
Performance varies by system configuration, workload characteristics, and task complexity. The charts below were captured in a single environment; reproduce them on your own machine with ./run_benchmark_graphs.sh (HTML report) or regenerate the SVGs with benchmarks/generate_readme_graphs.py.
| Setting | Value |
|---|---|
| CPU | AMD Ryzen 5 5600X (6 cores / 12 threads, 32 MiB L3, up to ~4.65 GHz) |
| OS / kernel | Fedora 44, Linux 7.0.4-200.fc44.x86_64 |
| Compiler | GCC 16.1.1 (-std=c++23 for the pool charts; C++17/20/23/26 for the callable charts) |
| Build type | Release (-O3 -DNDEBUG) |
| Extra flags | -march=native -ffast-math -fno-omit-frame-pointer |
| Google Benchmark | v1.9.4 |
| Threads | 4 worker threads unless noted |
The exact compile flags used for every benchmark target (see benchmarks/CMakeLists.txt):
Absolute numbers are only meaningful relative to each other on the same machine and build. -march=native and -ffast-math in particular mean results are not comparable across CPUs. Re-run the benchmarks locally before drawing conclusions for your hardware.
Throughput scales with batch size. For tiny tasks the fire-and-forget LightweightPool consistently leads, while the work-stealing HighPerformancePool pays for its extra machinery and only shines on larger, unbalanced workloads:
Pick the right pool for the workload. Running 100,000 trivial tasks, the LightweightPool finishes ~1.9x faster than the baseline ThreadPool, whereas the work-stealing pool is slower because the tasks are too small to benefit from stealing:
The gap depends heavily on how much work each task does. With the pool built once and the per-task work swept from tiny to heavy, the picture changes: for tiny/medium tasks submission overhead dominates and LightweightPool wins by ~2-3x, but as the per-task work grows the field converges to within ~20% and the pool choice stops mattering much. The work-stealing HighPerformancePool climbs from last place (tiny) to nearly the front (heavy):
Skip the future when you do not need it. post() reuses the same queue path as submit() but avoids the packaged_task / std::future overhead, which is dramatic for very short tasks:
These numbers measure submission/scheduling overhead with light tasks, so they represent a worst case for pool overhead. As the "workload weights" chart shows, real workloads with heavier per-task work narrow these gaps considerably.
With THREADSCHEDULE_ENABLE_REFLECTION=ON the registry exposes ergonomic, field-oriented queries (where / project / find_by). These trade a little performance for readability and compile-time field checking: against hand-written STL-style lambdas over 16,384 registered threads they currently run slightly slower, so reach for them when expressiveness matters more than the last few percent of throughput.
The pools store type-erased tasks in one of two ways: ThreadPool / FastThreadPool / HighPerformancePool use detail::move_callable (std::function on C++17/20, std::move_only_function on C++23+), while LightweightPool uses a custom small-buffer callable (SboCallable<64>). The callable_std_benchmarks target isolates the build + invoke cost of these wrappers (away from thread-scheduling noise) and is compiled under every standard.
Does replacing std::function help? For small captures, switching to std::move_only_function on C++23+ cuts the per-task wrapper cost by ~30% (~4.6 ns to ~3.1 ns). For larger captures the heap allocation dominates and the wrapper choice barely matters:
Do C++26 copyable callbacks help? Yes, for the callback-heavy APIs that still need copyable type erasure (set_on_task_start, set_on_task_end, registry hooks, and error callbacks). On this GCC 16.1 / libstdc++ setup, switching from std::function to std::copyable_function cuts wrapper cost by about 29% for small captures, 11% for medium captures, and 5% for large ones:
Do the SBO callables help? Yes — and this is the bigger effect. A 48-byte capture fits LightweightPool's 56-byte inline buffer but overflows the standard-library callables' small buffer, so the latter heap-allocate. The SBO path is then ~6x faster (~3.4 ns vs ~21 ns per task). Once a capture is too big for any inline buffer (128 B), both allocate and the advantage disappears:
A task is usually a lambda, and a lambda's size is the sum of what it captures (plus alignment padding). A capture-less lambda is effectively free; each captured pointer or reference adds 8 bytes, and capturing objects by value adds their full size. Concrete sizes on this platform (GCC 16 / libstdc++, x86_64):
| What the task captures | Example | Size |
|---|---|---|
| nothing (stateless) | pool.post([]{ tick(); }); | ~1 B |
| one pointer / reference / this | pool.post([&q]{ q.drain(); }); | 8 B |
| two pointers / references | pool.post([&a, &b]{ join(a, b); }); | 16 B |
| a std::shared_ptr by value | pool.post([h]{ h->run(); }); | 16 B |
| a std::vector by value | pool.post([data]{ process(data); }); | 24 B |
| a std::string by value | pool.post([name]{ log(name); }); | 32 B |
| ~6 small values / handles (the chart's "medium") | pool.post([id,a,b,c,d,e]{ ... }); | 48 B |
| a big array / struct by value (the chart's "large") | pool.post([frame]{ encode(frame); }); | 128 B |
Each storage type keeps small callables inline (no allocation) up to a fixed buffer size, and falls back to a heap allocation above it:
| Storage | Inline buffer | Used by |
|---|---|---|
| std::function | ≤ 16 B | ThreadPool family on C++17/20 |
| std::move_only_function | ≤ 24 B | ThreadPool family on C++23+ |
| SboCallable<64> | ≤ 56 B | LightweightPool (= LightweightPoolT<64>) |
SboCallable<TaskSize> lays each task out as one cache line:
Typical real tasks capture a few pointers/handles plus maybe a small value, so they land in the ~8-48 B range. That fits LightweightPool's 56 B buffer with no allocation, but overflows std::function's 16 B buffer (one allocation per task). If you capture large objects by value you blow past every inline buffer - capture a pointer/handle to the data instead, or bump the buffer with LightweightPoolT<128>.
Takeaway: keep task captures small. They stay inline (no allocation) in LightweightPool, and on C++23+ the other pools also benefit from the move-only wrapper. This is exactly why post() and LightweightPool are the recommended low-overhead paths.
See benchmarks/ for detailed performance analysis, real-world scenario testing, and optimization recommendations.
Note: PThreadWrapper is Linux-only. Use ThreadWrapper or JThreadWrapper for cross-platform code.
Contributions are welcome! Please:
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.