Task Groups
TaskGroup is a thread-safe coordination object for manually tracking a group of related tasks.
It records:
task IDs
pending task count
completed task count
failed task count
cancelled task count
timed-out task count
rejected task count
first reported exception
shared cancellation stateUnlike Scope, a TaskGroup does not submit work itself.
The application registers tasks and reports their final outcomes explicitly.
Basic model
The lifecycle is:
create TaskGroup
↓
register task IDs
↓
work executes elsewhere
↓
report each task outcome
↓
wait until pending == 0For example:
#include <chrono>
#include <thread>
#include <vix/threadpool/all.hpp>
int main()
{
vix::threadpool::TaskGroup group;
group.add_task(vix::threadpool::TaskId{1});
group.add_task(vix::threadpool::TaskId{2});
std::thread first([&group](){
std::this_thread::sleep_for(
std::chrono::milliseconds{10}
);
group.finish_task(
vix::threadpool::TaskStatus::completed,
vix::threadpool::TaskResult::success
);
});
std::thread second([&group](){
std::this_thread::sleep_for(
std::chrono::milliseconds{20}
);
group.finish_task(
vix::threadpool::TaskStatus::completed,
vix::threadpool::TaskResult::success
);
});
group.close();
group.wait();
first.join();
second.join();
return group.completed_tasks() == 2 ? 0 : 1;
}TaskGroup coordinates the accounting and waiting. The threads or ThreadPool tasks performing the actual work are managed separately.
A new group
Create an empty group with:
vix::threadpool::TaskGroup group;Its initial state is:
empty true
done true
closed false
cancelled false
total_tasks 0
pending_tasks 0
completed_tasks 0
failed_tasks 0
cancelled_tasks 0
timed_out_tasks 0
rejected_tasks 0A new group is considered done because there are no pending tasks.
Register a task
Use:
const bool added = group.add_task(
vix::threadpool::TaskId{1}
);A successful registration increments both:
total_tasks
pending_tasksFor example:
group.add_task(vix::threadpool::TaskId{1});
group.add_task(vix::threadpool::TaskId{2});
group.add_task(vix::threadpool::TaskId{3});produces:
total_tasks 3
pending_tasks 3and:
group.done();returns false.
Invalid task IDs are rejected
invalid_task_id cannot be registered:
const bool added = group.add_task(
vix::threadpool::invalid_task_id
);The result is:
falseThe group counters are not modified.
invalid_task_id has value zero.
Task IDs are tracking information
The group stores every registered ID:
const auto ids = group.task_ids();For:
group.add_task(vix::threadpool::TaskId{1});
group.add_task(vix::threadpool::TaskId{2});the returned vector contains:
1
2in registration order.
task_ids() returns a copy of the stored vector.
Changing the returned vector does not modify the group.
Task IDs are not required to be unique
add_task() validates that an ID is non-zero, but it does not check whether the same ID was already registered.
For example:
group.add_task(vix::threadpool::TaskId{1});
group.add_task(vix::threadpool::TaskId{1});registers two entries.
The resulting counters are:
total_tasks 2
pending_tasks 2When task identity is important, the caller is responsible for registering unique IDs.
IDs obtained from:
pool.next_task_id();provide the normal ThreadPool task-ID sequence.
TaskGroup does not submit tasks
This is the central difference from Scope.
TaskGroup has no:
spawn()
submit()
post()
handle()operation.
This:
vix::threadpool::TaskGroup group;
group.add_task(vix::threadpool::TaskId{1});only registers accounting state.
It does not create or execute a ThreadPool task.
Conceptually:
TaskGroup
↓
register task identity
execution system
↓
runs actual work
application integration
↓
finish_task()The application connects those pieces.
Report task completion
Call finish_task() once the registered work reaches its final outcome.
For successful work:
group.finish_task(
vix::threadpool::TaskStatus::completed,
vix::threadpool::TaskResult::success
);This changes:
pending_tasks -1
completed_tasks +1When the last pending task is finished:
group.done();returns true.
Report a failure
Use:
group.finish_task(
vix::threadpool::TaskStatus::failed,
vix::threadpool::TaskResult::failure
);This increments:
failed_tasksand decrements the pending count.
A failure can also include the captured exception:
group.finish_task(
vix::threadpool::TaskStatus::failed,
vix::threadpool::TaskResult::failure,
std::make_exception_ptr(
std::runtime_error{"task failed"}
)
);The first reported exception is retained by the group.
Report cancellation
Use:
group.finish_task(
vix::threadpool::TaskStatus::cancelled,
vix::threadpool::TaskResult::cancelled
);This increments:
cancelled_tasksand decrements:
pending_tasksRequesting group cancellation itself does not perform this accounting automatically.
The task integration must still report its final outcome.
Report timeout
Use:
group.finish_task(
vix::threadpool::TaskStatus::timed_out,
vix::threadpool::TaskResult::timeout
);This increments:
timed_out_tasksand decrements the pending count.
Report rejection
Use:
group.finish_task(
vix::threadpool::TaskStatus::rejected,
vix::threadpool::TaskResult::rejected
);This increments:
rejected_tasksand decrements the pending count.
This is useful when manually integrating the group with an execution system whose submission can fail.
Use final status and result values
finish_task() is designed to receive the final task status and result.
The normal pairs are:
| Status | Result |
|---|---|
completed | success |
failed | failure |
cancelled | cancelled |
timed_out | timeout |
rejected | rejected |
Use consistent terminal pairs when reporting group outcomes.
For example:
group.finish_task(
vix::threadpool::TaskStatus::completed,
vix::threadpool::TaskResult::success
);rather than mixing an unrelated status and result.
finish_task() does not identify the task
Notice that the API is:
group.finish_task(
status,
result
);not:
finish_task(task_id, status, result)TaskGroup therefore does not match completion reports against individual registered IDs.
Its IDs are retained for identification and inspection, while completion accounting is counter-based.
The caller must maintain the relationship between actual tasks and their completion reports.
Call finish_task() exactly once per registered task
Because completion is counter-based, the caller should report exactly one final outcome for every successful add_task().
The intended relationship is:
one add_task()
↓
one finish_task()For example:
3 registered tasks
↓
3 completion reports
↓
pending_tasks == 0If a registered task never calls finish_task(), wait() can remain blocked indefinitely.
Extra completion reports are not validated
finish_task() decrements pending_tasks only when the count is greater than zero.
However, it still updates the outcome counter even when no tasks remain pending.
Therefore an extra call such as:
group.finish_task(
vix::threadpool::TaskStatus::completed,
vix::threadpool::TaskResult::success
);after all registered tasks are already finished can increase completed_tasks() beyond total_tasks().
TaskGroup does not validate completion reports against individual IDs.
Correct accounting depends on the caller maintaining the one-registration, one-completion rule.
Wait for all registered tasks
Use:
group.wait();The operation blocks until:
pending_tasks == 0Conceptually:
wait()
↓
pending == 0?
┌─────┴─────┐
yes no
│ │
return block
↓
finish_task()
↓
condition variable
↓
check againfinish_task() notifies waiting threads after updating the group state.
wait() does not close the group
This differs from Scope.
Calling:
group.wait();does not prevent future task registration.
For example:
vix::threadpool::TaskGroup group;
group.wait();
const bool added = group.add_task(
vix::threadpool::TaskId{1}
);added can still be true because the group remains open.
This means a bare wait() is only a wait for the current pending count.
Close before waiting for a stable boundary
When no more tasks should enter the group, use:
group.close();
group.wait();The sequence creates a stable completion boundary:
close()
↓
reject new registrations
↓
wait()
↓
pending reaches zero
↓
group remains completeThis is the normal pattern when the complete group membership is known.
Close the group
Call:
group.close();to prevent future registrations.
Afterward:
group.closed();returns:
trueand:
group.add_task(vix::threadpool::TaskId{4});returns:
falseExisting registered tasks remain pending until their outcomes are reported.
close() does not wait
This:
group.close();does not wait for registered tasks.
For example:
total_tasks 3
pending_tasks 2can remain true after the group has been closed.
Use:
group.close();
group.wait();when both membership finalization and completion are required.
close() does not cancel
Closing and cancellation are independent:
close()
↓
prevent new task registration
cancel()
↓
request cooperative cancellationA group can be:
closed and not cancelledor:
cancelled and still openuntil the application explicitly changes the other state.
Check completion
Use:
if (group.done())
{
// No registered task is pending.
}done() is equivalent to:
pending_tasks == 0A new empty group is therefore already done.
A group becomes not done after successful task registration:
group.add_task(vix::threadpool::TaskId{1});and becomes done again after its pending count returns to zero.
done() does not mean closed
These are separate states.
A group can be:
done true
closed falseFor example, a new group has exactly this state.
It can also be:
done false
closed truewhen membership has been finalized but registered work is still outstanding.
The normal final state after:
group.close();
group.wait();is:
done true
closed trueempty() means no task was ever registered
Use:
group.empty();to check whether:
total_tasks == 0This differs from done().
For example:
group.add_task(vix::threadpool::TaskId{1});
group.finish_task(
vix::threadpool::TaskStatus::completed,
vix::threadpool::TaskResult::success
);now gives:
empty() false
done() trueThe task has finished, but the group is not empty because one task was registered during its lifetime.
Counters are cumulative
TaskGroup does not remove completed registrations or reset its counters after waiting.
For example:
total_tasks 3
pending_tasks 0
completed_tasks 2
cancelled_tasks 1remains available after:
group.wait();This makes the group useful for inspecting aggregate outcomes after all work finishes.
There is no public reset operation.
Inspect total tasks
Use:
const auto total = group.total_tasks();This is the number of successful add_task() registrations during the group's lifetime.
It does not decrease when tasks finish.
Inspect pending tasks
Use:
const auto pending = group.pending_tasks();This is the number of registered tasks for which completion has not yet been accounted.
Conceptually:
pending_tasks
↓
successful registrations
minus
reported completionsWhen it reaches zero:
group.done();returns true.
Inspect completed tasks
Use:
const auto completed = group.completed_tasks();This counts outcomes reported with:
vix::threadpool::TaskStatus::completedor a successful fallback result when a non-terminal status is supplied.
Normal code should report the terminal completed status directly.
Inspect failed tasks
Use:
const auto failed = group.failed_tasks();This counts tasks reported as failed.
Check whether at least one failure occurred with:
if (group.has_failure())
{
// At least one failed task was reported.
}has_failure() only refers to the failed-task counter.
Cancellation, timeout, and rejection do not make has_failure() return true.
Inspect all non-success outcomes
Use:
if (group.has_error())
{
// At least one non-success outcome was reported.
}has_error() returns true when any of these counters is non-zero:
failed_tasks
cancelled_tasks
timed_out_tasks
rejected_tasksTherefore:
has_failure()
↓
only failure
has_error()
↓
failure
cancellation
timeout
rejectionA fully successful group reports:
has_failure() false
has_error() falseFirst exception
When a failed task reports an exception:
group.finish_task(
vix::threadpool::TaskStatus::failed,
vix::threadpool::TaskResult::failure,
std::make_exception_ptr(
std::runtime_error{"failure"}
)
);the first non-null exception is stored.
Inspect it with:
const auto exception = group.first_exception();When no exception was captured:
nullptris returned.
Only the first exception is retained
Suppose several tasks report exceptions:
Task A → exception A
Task B → exception B
Task C → exception CThe group retains:
exception Aassuming Task A's failure report reached finish_task() first.
Later exceptions do not replace it.
This keeps one representative exception for wait_and_rethrow().
Failure does not require an exception
This is valid:
group.finish_task(
vix::threadpool::TaskStatus::failed,
vix::threadpool::TaskResult::failure
);It increments:
failed_tasksbut does not create an exception.
The group can therefore have:
has_failure() == true
first_exception() == nullptrwait_and_rethrow() only throws when an actual exception pointer was reported.
Wait and rethrow
Use:
group.wait_and_rethrow();to wait until all registered work has been reported complete and then rethrow the first captured exception.
For example:
vix::threadpool::TaskGroup group;
group.add_task(vix::threadpool::TaskId{1});
group.finish_task(
vix::threadpool::TaskStatus::failed,
vix::threadpool::TaskResult::failure,
std::make_exception_ptr(
std::runtime_error{"task failed"}
)
);
try
{
group.wait_and_rethrow();
}
catch (const std::runtime_error&)
{
// First captured exception.
}The exception is rethrown only after:
pending_tasks == 0wait_and_rethrow() does not close the group
Like wait(), it does not modify the group's open or closed state.
For a fixed membership boundary, use:
group.close();
try
{
group.wait_and_rethrow();
}
catch (...)
{
// Handle the reported task exception.
}The group remains closed afterward because close() was called explicitly.
wait_and_rethrow() only rethrows exceptions
Cancellation, timeout, and rejection contribute to:
group.has_error();but do not automatically cause:
group.wait_and_rethrow();to throw.
For example:
group.finish_task(
vix::threadpool::TaskStatus::timed_out,
vix::threadpool::TaskResult::timeout
);increments the timeout counter but stores no exception.
After waiting, inspect the counters or has_error() when these outcomes matter.
Shared cancellation
Every TaskGroup owns a CancellationSource.
Obtain its token with:
auto token = group.cancellation_token();Request cancellation with:
group.cancel();Afterward:
group.cancelled();returns:
trueand every token connected to the group source observes the same request.
Cancellation is not automatic task integration
TaskGroup does not automatically attach its token to ThreadPool tasks.
This:
vix::threadpool::TaskGroup group;
group.cancel();only changes the group's shared cancellation state.
Actual work must explicitly observe:
group.cancellation_token();if it should react to group cancellation.
Conceptually:
TaskGroup
↓
CancellationSource
↓
CancellationToken
application integration
↓
actual task observes tokenCancellation does not finish pending tasks
Calling:
group.cancel();does not change:
pending_tasks
completed_tasks
failed_tasks
cancelled_tasks
timed_out_tasks
rejected_tasksFor example:
before cancel:
pending_tasks = 3
after cancel:
pending_tasks = 3Each registered task must still eventually be reported through finish_task().
Otherwise:
group.wait();can remain blocked.
Use the cancellation token inside work
A manually coordinated task can observe the group token:
auto token = group.cancellation_token();
std::thread worker([&group, token](){
if (token.stop_requested())
{
group.finish_task(
vix::threadpool::TaskStatus::cancelled,
vix::threadpool::TaskResult::cancelled
);
return;
}
perform_work();
group.finish_task(
vix::threadpool::TaskStatus::completed,
vix::threadpool::TaskResult::success
);
});The application is responsible for reporting whichever final outcome actually occurred.
Cancellation is cooperative
Group cancellation follows the same model as other ThreadPool cancellation:
group.cancel()
↓
shared cancellation state becomes true
↓
linked code observes token
↓
linked code decides where to stop safelyIt does not:
terminate threads
interrupt arbitrary C++ instructions
automatically remove queued tasks
automatically update TaskGroup countersSee Cancellation.
Access the cancellation source
The source itself is available through:
auto& source = group.cancellation_source();For ordinary cancellation:
group.cancel();is simpler.
Direct source access is useful when another API specifically requires a CancellationSource.
The source is owned by the group and should not outlive it by reference.
Be careful when resetting the cancellation source
CancellationSource::reset() creates a new cancellation state.
Existing tokens remain attached to the old state.
Therefore:
auto token = group.cancellation_token();
group.cancellation_source().reset();
group.cancel();requests cancellation on the new state, not on the state still observed by token.
Avoid resetting the group cancellation source while registered work depends on existing tokens.
Thread safety
TaskGroupState protects group accounting with a mutex and uses a condition variable for waiting.
The public operations can therefore be called from multiple threads.
For example:
Thread A
finish_task()
│
│
Thread B│
finish_task()
│
▼
TaskGroup
│
▼
Thread C
wait()Counter updates and state inspection are synchronized internally.
Waiting and registration can occur concurrently
add_task() and wait() use the same synchronized state, but wait() does not close registrations.
This creates an important semantic distinction.
Suppose:
pending_tasks = 0A thread calling:
group.wait();can return immediately.
Another thread can then successfully call:
group.add_task(...);if the group has not been closed.
Therefore, when registration may happen concurrently, call:
group.close();
group.wait();once membership is complete.
Closing establishes the registration boundary
close() and add_task() synchronize through the same mutex.
If add_task() obtains the lock first:
task registered
↓
close happens afterwardthe new task belongs to the group.
If close() obtains the lock first:
closed = true
↓
later add_task()
↓
falseThis provides a clear boundary for concurrent task registration.
Completion notifications
Every call to:
group.finish_task(...);notifies the group's condition variable.
Waiting threads then reevaluate:
pending_tasks == 0close() and cancel() also notify waiters, although neither operation by itself makes outstanding tasks complete.
The wait predicate remains based only on the pending-task count.
Destructor does not wait
This is an important difference from Scope.
TaskGroup has a default destructor:
~TaskGroup()and destruction does not automatically call:
wait()
close()
cancel()Therefore this is unsafe when another thread can still access the group:
{
vix::threadpool::TaskGroup group;
group.add_task(vix::threadpool::TaskId{1});
// Work using &group continues elsewhere.
}The caller must ensure all users of the group have finished before destroying it.
Scope and TaskGroup have different lifetime guarantees
Scope provides automatic structured waiting:
Scope destructor
↓
wait for tracked FuturesTaskGroup does not:
TaskGroup destructor
↓
destroy state immediatelyTherefore:
Scope
automatic task lifetime boundary
TaskGroup
manual coordination and accountingUse Scope when automatic ownership of spawned task completion is the main requirement.
Use TaskGroup when manual registration, aggregate counters, task IDs, and explicit completion reporting are required.
TaskGroup does not own task lifetime
A TaskGroup stores:
Task IDs
counters
exception pointer
cancellation sourceIt does not store:
Future objects
TaskHandle objects
Worker objects
ThreadPoolTherefore the group itself does not keep an asynchronous operation alive or wait on its Future.
The system integrating with TaskGroup remains responsible for the actual execution objects.
TaskGroup is not tied to ThreadPool
A group can coordinate work from any execution mechanism capable of reporting the required outcomes.
The existing API only requires:
register TaskId
report TaskStatus
report TaskResult
optionally report exceptionFor example, it can coordinate plain std::thread work, as shown in the basic example.
This is why the type does not require a ThreadPool constructor argument.
A ThreadPool integration must be explicit
When connecting a TaskGroup to ThreadPool work, the integration must handle both sides:
before execution
↓
add_task(id)
after every possible outcome
↓
finish_task(...)That includes:
success
failure
cancellation
timeout
rejectionA missing completion path leaves the group's pending count non-zero.
TaskGroup does not automatically observe a Future or TaskHandle.
Do not attach the group token without completion accounting
For example, attaching:
options.set_cancellation(
group.cancellation_token()
);to a ThreadPool submission can cause the ThreadPool to skip a callable before that callable begins.
If the application's only finish_task() call is inside that callable, it will never run.
The result would be:
registered task
↓
cancelled before callable
↓
callable skipped
↓
finish_task() never called
↓
pending_tasks remains non-zeroWhen integrating TaskGroup with ThreadPool cancellation, ensure the outer integration observes the terminal asynchronous result and reports it to the group.
Aggregate outcome example
The module's task-group example coordinates three manually reported outcomes:
#include <chrono>
#include <iostream>
#include <thread>
#include <vix/threadpool/all.hpp>
int main()
{
vix::threadpool::TaskGroup group;
group.add_task(vix::threadpool::TaskId{1});
group.add_task(vix::threadpool::TaskId{2});
group.add_task(vix::threadpool::TaskId{3});
std::thread first([&group](){
std::this_thread::sleep_for(
std::chrono::milliseconds{20}
);
group.finish_task(
vix::threadpool::TaskStatus::completed,
vix::threadpool::TaskResult::success
);
});
std::thread second([&group](){
std::this_thread::sleep_for(
std::chrono::milliseconds{40}
);
group.finish_task(
vix::threadpool::TaskStatus::completed,
vix::threadpool::TaskResult::success
);
});
std::thread third([&group](){
std::this_thread::sleep_for(
std::chrono::milliseconds{60}
);
group.finish_task(
vix::threadpool::TaskStatus::cancelled,
vix::threadpool::TaskResult::cancelled
);
});
group.close();
group.wait();
first.join();
second.join();
third.join();
std::cout << "Total tasks: "
<< group.total_tasks()
<< '\n';
std::cout << "Completed: "
<< group.completed_tasks()
<< '\n';
std::cout << "Cancelled: "
<< group.cancelled_tasks()
<< '\n';
std::cout << "Has error: "
<< (group.has_error() ? "yes" : "no")
<< '\n';
return 0;
}Output:
Total tasks: 3
Completed: 2
Cancelled: 1
Has error: yesThe output demonstrates that the group keeps aggregate outcome information after all registered work has finished.
TaskGroupState
TaskGroupState is the underlying public state type used by TaskGroup.
It provides the same core operations:
add_task()
finish_task()
close()
cancel()
wait()
wait_and_rethrow()
cancellation_token()
cancellation_source()
done()
closed()
empty()
task counters
task_ids()
first_exception()TaskGroup is a user-facing wrapper around one TaskGroupState.
Normal application code should prefer:
vix::threadpool::TaskGroupunless it specifically needs to work with the lower-level state type.
No reset operation
A TaskGroup accumulates information for its entire lifetime.
After:
total_tasks 10
completed_tasks 8
failed_tasks 2
pending_tasks 0there is no operation that resets these counters to zero.
Create another group for another independent accounting lifetime.
This also keeps task IDs and first-exception state tied to one logical group.
Not copyable or movable
TaskGroup disables:
copy construction
copy assignment
move construction
move assignmentCreate the group directly in the lifetime where its coordination state belongs.
This also keeps references used by concurrent completion reporters stable.
Choosing between Scope and TaskGroup
Use Scope when you want:
submit related work
track Futures automatically
wait automatically at destruction
shared cancellationUse TaskGroup when you want:
manual task registration
explicit task IDs
manual completion reporting
aggregate outcome counters
first-exception storage
shared cancellation state
explicit waitingThe distinction is:
Scope
owns structured tracking of submitted Futures
TaskGroup
owns coordination state and accountingNeither abstraction replaces the other.
Recommended TaskGroup lifecycle
For a fixed set of manually coordinated work, the normal pattern is:
create group
↓
add_task() for each logical task
↓
start work
↓
close()
↓
each task calls finish_task() exactly once
↓
wait() or wait_and_rethrow()
↓
inspect counters
↓
destroy group after all users are finishedIn code:
vix::threadpool::TaskGroup group;
group.add_task(vix::threadpool::TaskId{1});
group.add_task(vix::threadpool::TaskId{2});
start_work();
group.close();
group.wait();
if (group.has_error())
{
inspect_group_outcomes();
}The important properties are:
TaskGroupcoordinates work but does not submit it.- Tasks are registered manually with
add_task(). - Zero is not a valid task ID.
- Duplicate non-zero IDs are currently accepted.
- Every successful registration should receive exactly one
finish_task()report. finish_task()does not identify or validate the task being finished.- Outcome counters are cumulative.
empty()means no task has ever been registered.done()means no registered task is currently pending.wait()waits forpending_tasks == 0.wait()does not close the group.wait_and_rethrow()also does not close the group.- Call
close()before waiting when the registration boundary must be final. close()prevents new registrations but does not wait or cancel.cancel()requests shared cooperative cancellation but does not update completion counters.- The cancellation token is not automatically connected to ThreadPool tasks.
has_failure()reports only failed tasks.has_error()also includes cancellation, timeout, and rejection.- Only the first reported non-null exception is retained.
wait_and_rethrow()throws only when an exception was actually reported.- The destructor does not wait.
TaskGroupdoes not own Futures, tasks, workers, or a ThreadPool.- The caller must guarantee that no concurrent code accesses the group after destruction.
TaskGroupis thread-safe for its own state and is neither copyable nor movable.
Continue with Synchronization for Latch and Barrier, or Parallel Algorithms for higher-level parallel work.