A small, dependency-free C++20 thread pool and the thread primitives it is built on, extracted from Xapiand.
threadpool is a fixed-size worker pool. You construct it with a worker-name
format and a thread count, then hand it work three ways: fire-and-forget
(enqueue), bulk (enqueue_bulk), or call-and-await-result (async, which
returns a std::future). Each worker is a long-lived thread that pulls tasks off
a shared blocking queue and runs them until the pool is told to finish. It is the
pool Xapiand uses for its HTTP/binary servers and clients, its document
indexers/preparers, and other background work.
Alongside the pool it ships the pieces underneath it:
Thread<Impl, policy>— a small CRTP base that turns any class with aname()andoperator()into a runnable, joinable thread.TaskQueue— a one-shot queue ofstd::packaged_tasks you drain by calling them with arguments (used for "callbacks waiting on a condition").ConcurrentQueue/BlockingConcurrentQueue— the mutex-backed queues the pool sits on.
ThreadPool<TaskType, ThreadPolicyType> owns a std::vector of worker threads
and a single BlockingConcurrentQueue<TaskWrapper<TaskType>>. Producers push
tasks; each worker loops on wait_dequeue_timed (100 ms timeout), runs whatever
it gets, and bumps a few atomic counters (_enqueued, _running, _workers)
so size(), running_size(), and threadpool_workers() can report state
without locking. Two flags control shutdown: end() lets workers drain the
queue first, finish() tells them to stop as soon as possible. join() waits
for them, splitting the timeout across the running workers.
TaskType defaults to std::function<void()>, but it can be a
std::shared_ptr<P> or std::unique_ptr<P> to a callable; TaskWrapper
specializes on each so the worker calls it correctly (and skips a null pointer).
async(func, args...) wraps the call in a PackagedTask, enqueues a closure
that runs it, and hands you the std::future for the result.
ThreadPolicyType is a compile-time tag (regular, http_clients,
committers, ...) carried as a template parameter. It is currently
runtime-ignored — run_thread and setup_thread take it unnamed and never
read it — but it is kept so Xapiand's many ThreadPool<T, ThreadPolicyType::x>
call sites compile unchanged, and so a consumer can wire policy-specific behavior
(affinity, priority) into run_thread/setup_thread later without touching call
sites.
Despite the names, ConcurrentQueue and BlockingConcurrentQueue are not
lock-free. ConcurrentQueue is a std::deque behind a std::mutex;
BlockingConcurrentQueue adds a std::condition_variable so consumers can wait
for work instead of spinning. The names mirror the lock-free
moodycamel API they were
originally a drop-in for, but this implementation trades the lock-free machinery
for a simple, obviously-correct mutex-backed queue. For Xapiand's task rates the
mutex is not the bottleneck; if you need genuine lock-free throughput, swap these
two headers for moodycamel's, whose API they match.
CMake with FetchContent:
include(FetchContent)
FetchContent_Declare(
threadpool
GIT_REPOSITORY https://github.com/Kronuz/threadpool.git
GIT_TAG main
)
FetchContent_MakeAvailable(threadpool)
target_link_libraries(your_target PRIVATE threadpool::threadpool)The threadpool target is a small STATIC library (it compiles thread.cc)
that requests cxx_std_20, links Threads::Threads, and puts both the source
dir and the generated config.h on your include path. Then:
#include "threadpool.hh"Requires C++20 (for std::format in worker naming). On macOS it builds with
clang/libc++, the same toolchain Xapiand uses.
#include "threadpool.hh"
#include <future>
// 4 workers named "W00".."W03"; format is a std::format string, {} gets the index.
ThreadPool<> pool("W{:02}", 4);
// Fire and forget.
pool.enqueue([] { do_some_work(); });
// Call and await a result.
std::future<int> f = pool.async([](int a, int b) { return a + b; }, 2, 3);
int sum = f.get(); // 5
pool.end(); // let workers drain the queue, then exit
pool.join(); // wait for them (default 60s, split across workers)A pool of callables held by pointer (so tasks can carry state / be polymorphic):
struct Job { void operator()(); };
ThreadPool<std::shared_ptr<Job>, ThreadPolicyType::doc_indexers> pool("DI{:02}", 8);
pool.enqueue(std::make_shared<Job>());TaskQueue for one-shot callbacks you drain yourself:
TaskQueue<std::packaged_task<void()>> callbacks;
auto fut = callbacks.enqueue(std::packaged_task<void()>([]{ /* ... */ }));
// later, when the awaited condition is ready:
while (callbacks.call()) { } // runs each queued callback onceThreadPool(const char* format, size_t num_threads, size_t queue_size = 1000)— startsnum_threadsworkers immediately;formatis astd::formatstring rendered with the worker index to name each thread (e.g."CH{:02}").enqueue(Func&&)/enqueue_bulk(It first, size_t count)— push work; returnfalseif the queue rejects it.async(Func&&, Args&&...) -> std::future<R>— package a call and get its future; throwsstd::runtime_errorif it can't enqueue.package(Func&&, Args&&...) -> PackagedTask<...>— build the packaged task without enqueuing (the building blockasyncuses).end()— stop once the queue drains.finish()— stop as soon as possible.join(timeout = 60s) -> bool— wait for workers;falseon timeout.clear()— drop queued (not-yet-running) tasks.size(),running_size(),threadpool_size(),threadpool_capacity(),threadpool_workers(),finished()— lock-free state queries.
CRTP base (thread.hh). Derive class Foo : public Thread<Foo, policy> and
give Foo a const std::string& name() and an operator(). Then:
run()— start the thread (idempotent while running).join(timeout = 60s)orjoin(time_point)— wait; rethrows any exception the thread'soperator()threw.
set_thread_name(const std::string&)/get_thread_name([id])— name the current OS thread and look names up (kept in an internal map).setup_thread(name, policy)— what a worker calls on startup (names itself).run_thread(routine, arg, policy)— start a detachedstd::thread.sched_getcpu()— current CPU id, or-1when unknowable (provided on macOS; x86 reads the APIC id, Apple Silicon returns-1).
An enum of policy tags (regular, wal_writer, logging, replication,
doc_matchers, doc_preparers, doc_indexers, committers, fsynchers,
updaters, http_servers, binary_servers, http_clients, binary_clients).
Carried as a template parameter; runtime-ignored today (see above).
enqueue(task) -> std::future<R>— queue a packaged task, get its future.call(Args&&...) -> bool— dequeue and run one task withargs;falseif empty.clear() -> size_t— drop all queued tasks, returns how many.
threadpool.hh and thread.cc instrument themselves through two hooks that are
no-ops by default, so the library builds with zero dependency on any logging or
crash-reporting header:
L_EXC(...)— logs an exception swallowed in the pool destructor and in a worker's task catch-all (two sites). No-op by default.THREADPOOL_THREAD_REGISTER(pthread, name)— called right after a thread is named, so an external crash handler can dump per-thread callstacks. No-op by default; this is the seam where Xapiand'sinit_thread_infoplugs back in.
The bundled threadpool_trace.h supplies both as #ifndef-guarded no-ops.
There are two ways to plug in real implementations:
-
Point
THREADPOOL_TRACE_HEADERat a header that defines them; the library includes it instead ofthreadpool_trace.h:c++ -std=c++20 -DTHREADPOOL_TRACE_HEADER='"my_trace.h"' ... -
Define the macros before including the library headers.
To recover Xapiand's exact behavior, that header maps the hooks back to Xapiand's own facilities:
#include "log.h"
#include "traceback.h"
#define THREADPOOL_THREAD_REGISTER(pthread, name) init_thread_info(pthread, name)
// L_EXC comes from log.hOne more optional knob: THREADPOOL_THREAD_NAME_PREFIX (a string, empty by
default) is prepended to OS-level thread names. Set
-DTHREADPOOL_THREAD_NAME_PREFIX='"Xapiand:"' to reproduce the original
Xapiand:-prefixed names.
cmake -B build && cmake --build build && ctest --test-dir build
# or directly:
c++ -std=c++20 -I. test/test.cc thread.cc -o test/test && ./test/testThe test submits 1000 tasks to a pool and checks all ran and summed correctly,
resolves 50 async futures, and exercises TaskQueue's enqueue/call/clear. It
prints all threadpool tests passed and exits 0.
examples/demo.cc is a runnable tour. A top-level CMake build
produces it next to the test:
cmake -B build && cmake --build build && ./build/threadpool_demoIt submits a batch of jobs with async() and collects the results from their
futures; fans a few hundred tasks across four named workers and tallies how many
each one ran, so you can watch the load spread across real threads (each task
asks get_thread_name() who it is running as); fires a batch with enqueue()
and peeks at the lock-free threadpool_workers() / running_size() / size()
counters while the work is still in flight; runs the same batch under both
shutdowns (end() drains the queue, finish() stops as soon as possible) so you
see drain-vs-asap side by side; and ends with TaskQueue, the one-shot queue of
callbacks you enqueue now and call/clear yourself later.
Extracted from Xapiand, where this pool runs
the HTTP and binary servers/clients, the document indexers and preparers, the
committers and fsynchers, and the replication workers. The standalone delta is
pure decoupling: Xapiand's log.h, strings::format, and traceback.h were
replaced by the injectable trace header and std::format, and the pthread
feature detection moved into a CMake-generated config.h. The pool logic is
otherwise identical. See ARCHITECTURE.md for the design and
AGENTS.md for the repo map and invariants.
MIT, Copyright (c) 2015-2019 Dubalu LLC. See LICENSE.