This guide walks through the core features of dispenso. The code in each section is a short fragment showing the relevant calls; surrounding declarations (input arrays, helper types, main()) are left out. Most sections end with a link to a complete program in examples/, which CI compiles and which you can build and run yourself (see below).
Coming from another library? Jump directly to the migration guide: Migrating from TBB | Migrating from OpenMP
Installation
See the README for installation instructions. Dispenso requires C++14 and CMake 3.12+.
To build the examples:
mkdir build && cd build
cmake .. -DDISPENSO_BUILD_EXAMPLES=ON
make
Basic Concepts
Thread Pools
At the heart of dispenso is the ThreadPool. A thread pool manages a set of worker threads that execute tasks. You can use the global thread pool or create your own:
Note: globalThreadPool() defaults to std::thread::hardware_concurrency() - 1 worker threads, since the calling thread typically participates in computation. Use dispenso::resizeGlobalThreadPool(n) to change it.
Task Sets
A TaskSet groups related tasks and provides a way to wait for their completion:
taskSet.schedule([]() { });
taskSet.schedule([]() { });
taskSet.wait();
Your First Parallel Loop
The simplest way to parallelize work is with parallel_for. It distributes loop iterations across available threads.
Simple per-element parallel loop:
dispenso::parallel_for(0, kArraySize, [&](size_t i) { output[i] = std::sqrt(input[i]); });
Reduction with per-thread state:
std::vector<double> partialSums;
dispenso::parallel_for(
partialSums,
[]() { return 0.0; },
size_t{0},
kArraySize,
[&](double& localSum, size_t start, size_t end) {
for (size_t i = start; i < end; ++i) {
localSum += input[i];
}
});
double totalSum = 0.0;
for (double partial : partialSums) {
totalSum += partial;
}
Adaptive chunking for uneven work:
The default chunking (kStatic) splits the range into equal chunks — ideal when every iteration costs about the same. When some iterations are far more expensive than others, pass kAdaptive so idle workers can steal the remaining work instead of waiting on a slow chunk:
dispenso::parallel_for(0, kArraySize, [&](size_t i) { expensiveVariableWork(i); }, options);
ParForChunking defaultChunking
Complete program: examples/parallel_for_example.cpp.
Key points:
- Use the simple form for independent per-element work
- Use chunked ranges when you want to control work distribution
- Per-thread state enables efficient reductions
- Options let you control parallelism and chunking strategy (
kStatic vs kAdaptive)
Parallel Iteration with for_each
When you have a container rather than an index range, use for_each:
Parallel for_each on a vector:
std::vector<double> values = {1.0, 4.0, 9.0, 16.0, 25.0, 36.0, 49.0, 64.0};
dispenso::for_each(values.begin(), values.end(), [](double& val) { val = std::sqrt(val); });
for_each_n with explicit count:
std::vector<int> partial = {10, 20, 30, 40, 50, 60, 70, 80, 90, 100};
dispenso::for_each_n(partial.begin(), 5, [](int& n) { n += 100; });
Complete program: examples/for_each_example.cpp.
Key points:
- Works with any iterator type (including non-random-access iterators)
for_each_n takes an explicit count
- Pass a
TaskSet for external synchronization control
Working with Tasks
For more complex task patterns, use TaskSet and ConcurrentTaskSet directly:
Basic TaskSet:
std::atomic<int> counter(0);
for (int i = 0; i < 10; ++i) {
taskSet.schedule([&counter, i]() { counter.fetch_add(i, std::memory_order_relaxed); });
}
taskSet.wait();
ConcurrentTaskSet with nested scheduling:
std::atomic<int> total(0);
for (int i = 0; i < 5; ++i) {
taskSet.schedule([&taskSet, &total, i]() {
for (int j = 0; j < 2; ++j) {
taskSet.schedule(
[&total, i, j]() { total.fetch_add(i * 10 + j, std::memory_order_relaxed); });
}
});
}
taskSet.wait();
Complete program: examples/task_set_example.cpp.
Key points:
TaskSet is for single-threaded scheduling
ConcurrentTaskSet allows scheduling from multiple threads
- Both support cancellation for cooperative early termination
- The destructor waits for all tasks to complete
Futures for Async Results
When you need return values from async operations, use Future:
Basic async and get:
int result = 0;
for (int i = 1; i <= 100; ++i) {
result += i;
}
return result;
});
int result = future.
get();
const Result & get() const
Chaining with then():
return 16.0;
})
return std::sqrt(prev.get());
})
return prev.get() * 2.0;
});
when_all for multiple futures:
auto tuple = allFutures.get();
int sum = std::get<0>(tuple).get() + std::get<1>(tuple).get() + std::get<2>(tuple).get();
Future< detail::ResultOf< F, Args... > > async(std::launch policy, F &&f, Args &&... args)
Future< std::vector< typename std::iterator_traits< InputIt >::value_type > > when_all(InputIt first, InputIt last)
when_any for the first ready future:
size_t firstReady = which.get();
Future< size_t > when_any(InputIt first, InputIt last)
Complete program: examples/future_example.cpp.
Key points:
async() launches work and returns a Future
then() chains dependent computations
when_all() waits for multiple futures; when_any() returns the index of the first ready
make_ready_future() creates an already-completed future
Task Graphs
For complex dependency patterns, build a task graph:
Diamond dependency pattern:
setAllNodesIncomplete(graph);
executor(taskSet, graph);
void dependsOn(Ns &... nodes)
Complete program: examples/graph_example.cpp.
Key points:
- Use
dependsOn() to specify prerequisites
- Multiple executors available: single-thread, parallel_for, ConcurrentTaskSet
- Graphs can be re-executed after calling
setAllNodesIncomplete()
- Subgraphs help organize large graphs
Visualizing a graph
graphsToDot writes a Graph or BiPropGraph as a Graphviz DOT file, which helps when checking a large dependency structure. Completed and incomplete nodes are coloured differently, so writing the file after a partial run shows what will execute next.
std::unordered_map<uintptr_t, std::string> names = {
{reinterpret_cast<uintptr_t>(&A), "A"},
{reinterpret_cast<uintptr_t>(&D), "D"},
};
void graphsToDot(const char *filename, const G &graph, const std::unordered_map< uintptr_t, std::string > *nodeNames)
Render it with dot -Tsvg graph.dot -o graph.svg.
Pipelines
For streaming data through stages, use pipelines:
3-stage pipeline (generator -> transform -> sink):
std::vector<int> results;
int counter = 0;
dispenso::pipeline(
[&counter]() -> dispenso::OpResult<int> {
if (counter >= 10) {
return {};
}
return counter++;
},
[](int value) { return value * value; },
[&results](int value) { results.push_back(value); });
Complete program: examples/pipeline_example.cpp.
Key points:
- Generator stage produces values (returns
OpResult<T> or std::optional<T>)
- Transform stages process values (can filter by returning empty result)
- Sink stage consumes final values
- Use
stage() with a limit for parallel stages
Parallel Invoke
For launching a fixed set of heterogeneous tasks in parallel (not a loop), use parallel_invoke. It's the classic fork-join idiom: submit siblings to the pool, run the last one inline on the calling thread.
Fork-join with parallel_invoke:
#include <dispenso/parallel_invoke.h>
double a = 0.0, b = 0.0, c = 0.0;
dispenso::parallel_invoke(
tasks,
[&]() { a = expensiveComputation1(); },
[&]() { b = expensiveComputation2(); },
[&]() { c = expensiveComputation3(); }
);
tasks.wait();
double result = a + b + c;
Complete program: examples/parallel_invoke_example.cpp.
Key points:
- The last functor runs inline on the calling thread
- Does not call
wait() — the caller drives synchronization
- Composes naturally with recursive divide-and-conquer algorithms
- No type erasure or allocation overhead
CPU Affinity and Topology
CpuSet provides portable CPU affinity and NUMA topology detection:
Query topology and pin threads:
printf("Available CPUs: %d\n", available);
for (const auto& group : l3Groups) {
printf("L3 cache group (id %d): %zu CPUs\n", group.cacheId, group.cpus.size());
}
A set of CPU IDs for affinity manipulation and topology queries.
static DISPENSO_DLL_ACCESS const std::vector< CacheGroup > & l3CacheGroups()
Returns the L3 cache sharing groups.
DISPENSO_DLL_ACCESS void add(int32_t hardwareThread)
Adds a single CPU to the set.
static DISPENSO_DLL_ACCESS int32_t availableCount()
Returns the number of hardware threads available to this process.
DISPENSO_DLL_ACCESS bool bindCurrentThread() const
Binds the calling thread to the CPUs in this set.
Complete program: examples/cpu_set_example.cpp.
Key points:
- Works on Linux (full support), Windows (full support), FreeBSD (full support), macOS (topology only)
CpuSet::l2CacheGroups() / l3CacheGroups() return CPU groups that share cache levels
- Use for NUMA-aware thread placement and cache-friendly scheduling
Thread-Safe Containers
ConcurrentVector
A vector that supports concurrent push_back and growth:
Concurrent push_back from multiple threads:
dispenso::parallel_for(0, 1000, [&vec](
size_t i) { vec.
push_back(
static_cast<int>(i)); });
iterator push_back(const T &val)
Iterator stability during concurrent modification:
int& firstElement = *it;
dispenso::parallel_for(0, 100, [&vec](
size_t i) { vec.
push_back(
static_cast<int>(i + 100)); });
assert(*it == 1);
assert(firstElement == 1);
Complete program: examples/concurrent_vector_example.cpp.
Key points:
- Iterators and references remain stable during growth
- Use
grow_by() for efficient batch insertion
- Reserve capacity upfront when size is known
- Not all operations are concurrent-safe (see docs)
SmallVector
A vector-like container with inline storage for small sizes, avoiding heap allocation when element count stays below a compile-time threshold:
for (int i = 0; i < 6; ++i) {
}
void push_back(const T &value)
Complete program: examples/small_vector_example.cpp.
Key points:
- Template parameter
N controls inline capacity (default 4)
- Transparent fallback to heap when size exceeds
N
- Once on heap, stays on heap until
clear() — preserves reserve() guarantees
- Same API as
std::vector (iterators, push_back, operator[], etc.)
ChaseLevDeque
A lock-free single-producer, multi-consumer work-stealing deque. The owner pushes and pops from the bottom; thieves steal from the top:
for (int i = 0; i < 100; ++i) {
}
int val = 0;
int stolen = 0;
A lock-free SPMC bounded work-stealing deque.
bool try_pop(T &out)
Pops the most recently pushed element (LIFO, owner-only).
bool try_steal(T &out)
Steals the oldest element from the top of the deque (FIFO).
bool try_push(const T &item)
Pushes an element onto the bottom of the deque (owner-only).
Complete program: examples/chase_lev_deque_example.cpp.
Key points:
- Lock-free and wait-free for the owner (push/pop)
steal() is lock-free for thieves
- Dynamically resizable — grows as needed
- Classic data structure for work-stealing schedulers
MpmcRingBuffer
A bounded, lock-free multi-producer multi-consumer ring buffer. try_push and try_pop are fail-fast — they return false rather than block when the buffer is full or empty:
}
int value = 0;
}
std::vector<int> items = {1, 2, 3};
A lock-free multi-producer multi-consumer ring buffer with fixed capacity.
size_type try_push_batch(T *items, size_type count)
Attempts to push multiple elements into the buffer.
bool try_pop(T &item)
Attempts to pop an element from the buffer.
bool try_push(T &&item)
Attempts to push an element into the buffer by moving.
Complete program: examples/mpmc_ring_buffer_example.cpp.
Key points:
- Capacity is fixed at compile time (rounded up to a power of two by default)
- Fail-fast: no blocking and no retry loops inside the buffer
try_push_batch enqueues K items with a single reservation
SPSCRingBuffer
When there is exactly one producer thread and one consumer thread, prefer SPSCRingBuffer — it avoids the CAS overhead of the MPMC variant:
}
int value = 0;
}
A lock-free single-producer single-consumer ring buffer with fixed capacity.
bool try_push(T &&item)
Attempts to push an element into the buffer by moving.
bool try_pop(T &item)
Attempts to pop an element from the buffer.
Complete program: examples/spsc_ring_buffer_example.cpp.
Key points:
- Designed for exactly one producer and one consumer thread
- Lower overhead than
MpmcRingBuffer (no CAS on the hot path)
Synchronization Primitives
Latch
A one-shot barrier for thread synchronization:
count_down + wait pattern:
constexpr int kNumWorkers = 3;
std::vector<int> results(kNumWorkers, 0);
for (int i = 0; i < kNumWorkers; ++i) {
taskSet.schedule([&workComplete, &results, i]() {
results[static_cast<size_t>(i)] = (i + 1) * 10;
workComplete.count_down();
});
}
workComplete.wait();
Complete program: examples/latch_example.cpp.
Key points:
arrive_and_wait() decrements and blocks
count_down() decrements without blocking
wait() blocks without decrementing
- Cannot be reset (one-shot)
Resource Pooling
Manage expensive-to-create resources with ResourcePool:
Basic buffer pool with RAII:
dispenso::parallel_for(0, 100, [&bufferPool](size_t i) {
auto resource = bufferPool.acquire();
resource.get().process(static_cast<int>(i));
});
Complete program: examples/resource_pool_example.cpp.
Key points:
- Resources automatically return to pool when RAII wrapper destructs
acquire() blocks if no resources available
- Good for database connections, buffers, etc.
- Can be used to limit concurrency
Next Steps
- Browse the API Reference for complete documentation
- Check out the tests for more usage examples
- See the benchmarks for performance testing patterns