A thread pool with work-stealing and dependency-graph scheduling, written in C++23. A personal project to consolidate concurrency fundamentals — mutexes, condition variables, futures/promises, and the concurrency-safety habits (ASan/UBSan/TSan, deliberate lock scoping) that come with writing multithreaded code correctly rather than just getting it to pass once.
- Arbitrary task submission —
submit(fn, args...)accepts any callable and argument list, returning aTaskHandle<T>(a thin wrapper overstd::shared_future<T>) viastd::invoke_result_tand variadic templates. Return type and exceptions both propagate through to.get(). - Dependency graph scheduling —
submit_with_deps(deps, fn, args...)lets a task wait on the completion of others before it becomes eligible to run. Dependents are tracked via awaiter_liston each task rather than a central registry; a task becomes runnable the moment its dependency count reaches zero. - Failure propagation — if a dependency throws, everything downstream of it (including transitively, through multi-level chains and diamond-shaped graphs) is skipped rather than run, and the original exception surfaces at
.get()on any dependent — not just the task that actually failed. - Work-stealing — each worker owns its own deque. Newly-ready dependent tasks are pushed onto the worker that just completed their dependency (for cache locality), and idle workers steal from each other's deques when their own is empty rather than blocking on a single shared queue.
- Clean shutdown — the pool signals every worker to stop and joins all of them before any worker's internal state is torn down, avoiding a use-after-free between still-running workers and the ones already being destroyed.
flowchart LR
submit["submit() / submit_with_deps()"]
subgraph W0["Worker 0"]
Q0[["deque"]]
end
subgraph W1["Worker 1"]
Q1[["deque"]]
end
subgraph W2["Worker 2"]
Q2[["deque"]]
end
submit -->|round-robin| Q0
submit -->|round-robin| Q1
submit -->|round-robin| Q2
Q0 -.->|steal when idle| Q2
Q2 -.->|steal when idle| Q0
Fresh submissions are spread round-robin across workers. When a worker finishes a task, any dependent tasks that just became ready are pushed onto that same worker's own queue (cache-local, per the design notes above) rather than round-robined — which is also why a worker can end up with more queued work than its neighbors. Idle workers correct that imbalance by stealing from a busy neighbor's queue rather than blocking.
- PIMPL on both
ThreadPoolandWorker, keeping<mutex>,<condition_variable>,<deque>, and<thread>out of the public headers. - Type erasure via
std::functionwraps each task'sstd::packaged_task<R()>so the pool's internal queues can hold tasks of any return type uniformly. std::jthread+std::stop_tokenfor worker lifecycle — cooperative shutdown without a hand-rolled atomic flag.- Each
Workerowns its own deque and its own mutex — deliberately fine-grained, since this is the point of the per-worker-queue design: one worker's queue operations never serialize against another's. - Dependency-graph bookkeeping (
waiter_list,dep_count,completed/failed) is protected by one mutex shared across the whole pool, not per-task. Asubmit_with_depsfor task A and acomplete_taskfor an unrelated task B will serialize against each other under this scheme, even though they touch disjoint data — a per-task mutex would remove that, at the cost of real complexity. Chosen as the simpler default on the assumption that task granularity (loops, sleeps, string formatting) dwarfs the time spent holding this lock — a named contention/simplicity tradeoff, not an oversight.
- No cycle detection. A cyclic dependency graph will deadlock rather than fail fast at submission time.
- No central pending-task registry. Tasks blocked on a dependency are reachable only via their dependency's
waiter_list; there's no O(1) view of everything currently pending, and tasks still pending at pool destruction are silently dropped rather than erroring.
Both are accepted scope boundaries for a learning project, not oversights — a production scheduler would need at least the first.
cmake -S . -B build
cmake --build build -j$(nproc)
ctest --test-dir build --output-on-failureSanitizer builds use separate build directories, since ASan/UBSan and TSan can't coexist in one binary:
# ASan + UBSan (default)
cmake -S . -B build-asan
cmake --build build-asan -j$(nproc)
ctest --test-dir build-asan --output-on-failure
# ThreadSanitizer
cmake -S . -B build-tsan -DENABLE_TSAN=ON
cmake --build build-tsan -j$(nproc)
ctest --test-dir build-tsan --output-on-failureCI runs three jobs on every push and pull request: actionlint (lints the workflow file itself), then the ASan+UBSan and TSan builds/test suites in parallel.
To run the same workflow lint locally before pushing:
curl -sL https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_linux_amd64.tar.gz | tar xz
sudo mv actionlint /usr/local/bin/
actionlintNo output and exit code 0 means clean. actionlint bundles shellcheck for the run: blocks, so it catches shell-scripting issues (like unquoted command substitutions) in addition to workflow YAML errors.
include/ Public headers (Task.h, ThreadPool.h, Worker.h)
src/ Implementation (ThreadPool.cpp, Worker.cpp, main.cpp)
tests/ GoogleTest suite (fetched via FetchContent)
.github/workflows/ CI (workflow lint, ASan+UBSan, and TSan, run as separate jobs)
- C++23 compiler (developed against GCC 14)
- CMake ≥ 3.20