Scopes
Scope provides structured lifetime management for a group of tasks submitted to one ThreadPool.
A scope tracks every task spawned through it and waits for those tasks before the scope is destroyed.
#include <atomic>
#include <vix/threadpool/all.hpp>
int main()
{
vix::threadpool::ThreadPool pool(4);
std::atomic<int> counter{0};
{
vix::threadpool::Scope scope(pool);
scope.spawn([&counter](){
counter.fetch_add(1, std::memory_order_relaxed);
});
scope.spawn([&counter](){
counter.fetch_add(1, std::memory_order_relaxed);
});
}
return counter.load(std::memory_order_relaxed) == 2 ? 0 : 1;
}Leaving the scope waits for the tracked work before destruction completes.
Structured lifetime
Without a scope, independently submitted tasks have independent result objects:
submit task A → Future A
submit task B → Future B
submit task C → Future CThe caller must manage every lifetime explicitly.
A Scope groups those operations:
Scope
├── task A
├── task B
└── task C
↓
wait before Scope destruction completesThis is useful when several concurrent operations logically belong to one enclosing operation.
Create a Scope
A scope is bound to an existing ThreadPool:
vix::threadpool::ThreadPool pool(4);
vix::threadpool::Scope scope(pool);A new scope starts:
open
empty
not cancelledTherefore:
scope.empty() == true
scope.size() == 0
scope.closed() == false
scope.cancelled() == falseThe ThreadPool remains independently owned by the application.
Scope does not own the pool.
Spawn work
Use spawn() to submit work through the bound pool:
const bool accepted = scope.spawn([](){
perform_work();
});The scope converts the callable into a Future<void> and stores that future internally.
Conceptually:
callable
↓
Scope::spawn()
↓
ThreadPool::submit()
↓
Future<void>
↓
stored by ScopeThis stored Future allows the scope to wait for the task later.
Spawn multiple tasks
A scope can track several tasks:
vix::threadpool::Scope scope(pool);
scope.spawn([](){
perform_first_operation();
});
scope.spawn([](){
perform_second_operation();
});
scope.spawn([](){
perform_third_operation();
});
scope.wait();The tasks are ordinary ThreadPool submissions and may execute concurrently when workers are available.
The scope coordinates their lifetime. It does not serialize them.
spawn() tracks completion, not return values
Scope::spawn() stores work as Future<void>.
For example:
scope.spawn([](){
return 42;
});is valid, but the returned 42 is discarded.
Conceptually:
callable returns T
↓
Scope wrapper invokes callable
↓
return value discarded
↓
Future<void> tracks completionUse ThreadPool::submit() directly when the result value itself is needed.
Scope is intended for tracking completion of related work.
Callables take no arguments
The callable passed to spawn() must be invocable without arguments:
scope.spawn([](){
perform_work();
});Capture required state through the callable:
const int value = 42;
scope.spawn([value](){
consume(value);
});Scope does not inject arguments into the callable.
Track the number of tasks
Use:
const std::size_t count = scope.size();to inspect the number of Futures currently stored by the scope.
For example:
vix::threadpool::Scope scope(pool);
scope.spawn([](){
perform_first_operation();
});
scope.spawn([](){
perform_second_operation();
});
const std::size_t count = scope.size();At this point, before a wait operation removes the tracked Futures:
count = 2size() counts tracked Futures.
It does not mean that all of those tasks are still queued or running.
A task may already have completed while its Future remains stored by the scope.
Check whether the Scope is empty
Use:
if (scope.empty())
{
// No Future is currently tracked.
}A newly created scope is empty.
After spawning work:
scope.spawn([](){
perform_work();
});it is no longer empty until the tracked Futures are taken by a wait operation.
empty() therefore describes the scope's tracking container, not worker activity.
Wait for all tracked work
Use:
scope.wait();to wait for every currently tracked task.
Internally, wait() obtains the tracked Futures and calls:
future.get();for each one.
Conceptually:
tracked Futures
↓
take all Futures
↓
Future 1 get()
↓
Future 2 get()
↓
Future 3 get()
↓
returnWhen wait() returns, every Future that was tracked when waiting began has reached a terminal asynchronous result.
wait() closes the Scope
Calling:
scope.wait();also closes the scope against future spawn() calls.
The state transition is:
open Scope
↓
wait()
↓
close Scope
↓
take tracked Futures
↓
wait for all
↓
closed and emptyAfterward:
scope.closed() == true
scope.empty() == true
scope.size() == 0A later:
scope.spawn([](){
perform_work();
});returns false.
wait() is therefore a terminal operation for task submission through that Scope.
Waiting cannot reopen a Scope
Once closed, a scope cannot be reopened.
There is no:
open()
reset()
restart()operation.
After:
scope.wait();or:
scope.close();the scope remains closed for the rest of its lifetime.
Create another Scope when another structured group of tasks is needed.
wait() swallows task failures
wait() waits for every tracked Future but intentionally ignores exceptions from Future::get().
For example:
vix::threadpool::Scope scope(pool);
scope.spawn([](){
throw std::runtime_error("failure");
});
scope.wait();wait() does not propagate the std::runtime_error.
Its behavior is conceptually:
Future::get()
↓
throws?
┌────┴────┐
yes no
│ │
ignore continue
│
▼
continue waitingThis applies to any exception produced by Future::get(), including task exceptions and ThreadPool errors represented by std::system_error.
Use wait_and_rethrow() when failures must be observed.
Wait and propagate failures
Use:
scope.wait_and_rethrow();to wait for all tracked tasks and propagate a failure.
For example:
vix::threadpool::Scope scope(pool);
scope.spawn([](){
throw std::runtime_error("failure");
});
try
{
scope.wait_and_rethrow();
}
catch (const std::runtime_error&)
{
// Task failure observed.
}Like wait(), this operation closes the scope and consumes all tracked Futures.
All tasks are waited before rethrowing
wait_and_rethrow() does not stop at the first Future that throws.
It remembers the first exception encountered and continues waiting for every remaining tracked Future.
Conceptually:
Future A
↓
throws
↓
remember exception
↓
Future B
↓
wait
↓
Future C
↓
wait
↓
all tracked Futures consumed
↓
rethrow remembered exceptionThis preserves the structured lifetime guarantee even when one task fails.
The exception is rethrown only after all tracked work has reached a terminal result.
Which exception is rethrown
Futures are stored in spawn() order.
wait_and_rethrow() calls get() on them in that stored order and retains the first exception encountered during that traversal.
Therefore, the rethrown exception should be understood as:
the first exception encountered while consuming
the tracked FuturesIt is not a guarantee about which task failed first in wall-clock time.
Concurrent tasks can fail in a different temporal order.
wait_and_rethrow() also closes the Scope
Like wait():
scope.wait_and_rethrow();first closes the scope and removes its tracked Futures.
Even if it eventually throws:
Scope remains closed
tracked Future container is emptyThe structured work has already been waited for before the exception reaches the caller.
Destructor behavior
The destructor calls:
wait();automatically.
Therefore:
{
vix::threadpool::Scope scope(pool);
scope.spawn([](){
perform_work();
});
}does not allow destruction of the Scope to complete until its tracked task reaches a terminal result.
This is the main structured-lifetime guarantee provided by Scope.
Destructor failures are swallowed
The destructor is noexcept.
It catches and ignores any exception while waiting.
Conceptually:
~Scope()
↓
wait()
↓
exception?
┌────┴────┐
yes no
│ │
ignore finish
│
▼
finishIf task failures matter to application logic, call:
scope.wait_and_rethrow();explicitly before destruction.
Do not rely on the destructor to report them.
Destructor can block
Because destruction waits for tracked Futures, destroying a Scope can block until its work finishes.
For example:
{
vix::threadpool::Scope scope(pool);
scope.spawn([](){
perform_long_operation();
});
}the closing brace is a synchronization point.
Conceptually:
enter scope
↓
spawn concurrent work
↓
other code in scope
↓
leave scope
↓
wait for tracked work
↓
Scope destroyedThis behavior is intentional.
Close without waiting
Use:
scope.close();to prevent new tasks from being spawned without immediately waiting for existing tasks.
For example:
vix::threadpool::Scope scope(pool);
scope.spawn([](){
perform_work();
});
scope.close();After closing:
scope.closed() == trueand:
scope.spawn([](){
another_operation();
});returns:
falseThe already tracked task remains stored by the scope.
close() does not wait
This:
scope.close();only changes submission state.
It does not call:
wait()
cancel()
shutdown()Conceptually:
close()
↓
reject future spawn()
↓
existing tracked Futures remain
↓
existing tasks continueCall:
scope.wait();or:
scope.wait_and_rethrow();when existing tasks must be joined explicitly.
Otherwise, the destructor will eventually wait for them.
close() does not cancel
Closing and cancellation are separate operations.
scope.close();means:
do not accept more scoped workwhile:
scope.cancel();means:
request cooperative cancellation
through the shared scope tokenA scope can be:
closed but not cancelledor:
cancelled but still openuntil a wait or explicit close occurs.
Cancel scoped work
Use:
scope.cancel();to request cancellation through the scope's shared CancellationSource.
For example:
vix::threadpool::Scope scope(pool);
scope.spawn([](){
perform_work();
});
scope.cancel();
scope.wait();Cancellation is cooperative.
It does not forcibly terminate C++ code already running on a worker.
See Cancellation.
Check cancellation state
Use:
if (scope.cancelled())
{
// Scope cancellation was requested.
}Before cancellation:
cancelled() = falseAfter:
scope.cancel();the result is:
cancelled() = trueCancellation requests are idempotent because they use the underlying CancellationSource.
Access the Scope cancellation token
Use:
auto token = scope.cancellation_token();The returned token observes the same cancellation state used for spawned tasks.
For example:
vix::threadpool::Scope scope(pool);
auto token = scope.cancellation_token();
scope.cancel();
if (!token.cancelled())
{
return 1;
}This is useful when task code itself needs cooperative cancellation checkpoints.
Every spawned task receives the Scope token
Before submitting work, spawn() performs:
TaskOptions
↓
attach scope CancellationToken
↓
ThreadPool::submit()Conceptually:
Scope CancellationSource
│
▼
shared token
┌───┼───┐
▼ ▼ ▼
task task taskThis gives all scoped tasks one shared cancellation signal.
Cancelling before spawn()
Cancellation does not automatically close the Scope.
Therefore this is allowed:
vix::threadpool::Scope scope(pool);
scope.cancel();
const bool accepted = scope.spawn([](){
perform_work();
});accepted is true because the Scope is still open and the Future is accepted for tracking.
However, the scope cancellation token is already cancelled.
The result-producing ThreadPool path observes that state and skips the user callable.
Conceptually:
scope.cancel()
↓
Scope still open
↓
spawn()
↓
Future tracked
↓
cancellation already requested
↓
callable not executed
↓
Future becomes cancelledThis distinction is important.
What spawn() returning true means
spawn() returns false when the Scope itself cannot accept another tracked task.
In the current implementation, this means:
Scope is closed
or
no bound poolA normally constructed Scope always begins with a valid pool pointer.
Therefore the ordinary failure case is a closed Scope.
scope.close();
const bool accepted = scope.spawn([](){
perform_work();
});returns:
falsespawn() does not report ThreadPool acceptance
spawn() uses ThreadPool::submit() internally.
submit() always returns a Future, including when the ThreadPool rejects the actual task submission.
Scope stores that Future and returns true as long as the Scope itself was open.
For example:
vix::threadpool::ThreadPool pool(1);
vix::threadpool::Scope scope(pool);
pool.shutdown();
const bool accepted = scope.spawn([](){
return 42;
});The current Scope::spawn() can return:
truebecause the rejected Future was successfully added to scope tracking.
That Future itself represents:
status = rejected
result = rejected
error = rejectedTherefore the boolean from spawn() means:
accepted for Scope trackingnot:
accepted by a worker queueThis distinction matters when the pool may already be stopped or otherwise reject submissions.
wait() hides rejected scoped submissions
Because wait() catches every exception from Future::get(), a rejected scoped submission does not propagate from:
scope.wait();Use:
scope.wait_and_rethrow();when rejected, cancelled, failed, or otherwise exceptional asynchronous results must reach the caller.
A rejected Future causes get() to throw std::system_error, which wait_and_rethrow() can propagate after all tracked Futures have been processed.
TaskOptions
spawn() accepts TaskOptions:
vix::threadpool::TaskOptions options;
options.set_priority(
vix::threadpool::TaskPriority::high
);
const bool accepted = scope.spawn([](){
perform_work();
}, options);Options such as priority, timeout, deadline, affinity, and other submission properties continue through the normal ThreadPool submission path.
The scope adds its own cancellation behavior.
Scope cancellation replaces an existing task token
Before submission, Scope::spawn() calls:
options.set_cancellation(
scope.cancellation_token()
);Therefore, if the caller already supplied another cancellation token:
vix::threadpool::CancellationSource external;
vix::threadpool::TaskOptions options;
options.set_cancellation(external.token());
scope.spawn([](){
perform_work();
}, options);the external token is replaced by the Scope's cancellation token for that submission.
The effective cancellation relationship becomes:
Scope CancellationSource
↓
spawned taskrather than:
external CancellationSource
↓
spawned taskWhen scoped work needs additional cancellation conditions, combine that logic inside the callable instead of expecting TaskOptions to carry two cancellation tokens.
Cooperative cancellation inside scoped work
The scope token can be captured when running work must react after execution begins:
vix::threadpool::Scope scope(pool);
auto token = scope.cancellation_token();
scope.spawn([token](){
while (has_more_work())
{
if (token.stop_requested())
{
return;
}
process_next_item();
}
});Later:
scope.cancel();makes the request visible inside the callable.
This is necessary when already running C++ code must stop before naturally completing.
The ThreadPool cannot forcibly interrupt the callable.
Scope with several cancellable tasks
The same token can coordinate all spawned operations:
vix::threadpool::Scope scope(pool);
auto token = scope.cancellation_token();
scope.spawn([token](){
while (!token.stop_requested() && has_first_work())
{
process_first_item();
}
});
scope.spawn([token](){
while (!token.stop_requested() && has_second_work())
{
process_second_item();
}
});
scope.cancel();
scope.wait();Conceptually:
Scope
│
CancellationSource
│
shared state
┌────┴────┐
▼ ▼
Task A Task BThe cancellation relationship is shared, while each callable still chooses where it is safe to stop.
Thread safety of Scope coordination
Scope protects:
tracked Futures
closed statewith an internal mutex.
Operations such as:
spawn()
close()
wait()
wait_and_rethrow()
empty()
size()
closed()synchronize access to that scope state.
This allows multiple threads to coordinate around one Scope object.
Concurrent spawn() and wait()
wait() closes the Scope while holding the same mutex used by spawn().
Therefore concurrent operations are serialized.
Conceptually:
spawn() and wait()
↓
compete for Scope mutex
↓
one proceeds firstIf spawn() acquires the lock first:
task becomes tracked
↓
wait() later includes itIf wait() acquires the lock first:
Scope becomes closed
↓
tracked Futures are taken
↓
later spawn() returns falseThis ensures no new tracked work can slip into the Scope after its wait boundary has been established.
Concurrent close() and spawn()
The same rule applies to close().
If closing happens first:
closed = true
↓
later spawn() returns falseIf spawning happens first:
Future stored
↓
close() prevents later submissionsThe already stored Future remains tracked.
A Scope cannot be copied or moved
Scope disables both copying and moving:
copy construction disabled
copy assignment disabled
move construction disabled
move assignment disabledA scope represents one fixed structured lifetime bound to one pool and one tracking container.
Create it directly in the lifetime where the concurrent work belongs.
Pool lifetime
Scope stores a non-owning pointer to the ThreadPool supplied during construction.
Conceptually:
Scope ─────► ThreadPool
non-owningThe normal lifetime arrangement is:
vix::threadpool::ThreadPool pool(4);
{
vix::threadpool::Scope scope(pool);
scope.spawn([](){
perform_work();
});
}The pool exists before the Scope and remains alive until after the Scope has finished waiting for its work.
This is the natural ownership order for scoped execution.
Scope vs TaskHandle
TaskHandle controls one task:
TaskHandle
├── TaskId
├── Future
└── CancellationSourceScope coordinates several tasks:
Scope
├── shared CancellationSource
└── multiple Future<void> objectsUse TaskHandle when one task needs identity and cancellation control.
Use Scope when several concurrent operations belong to one structured lifetime.
Scope vs TaskGroup
Scope actively submits and tracks tasks:
Scope
↓
spawn()
↓
ThreadPool::submit()
↓
Future<void>
↓
tracked automaticallyTaskGroup is a lower-level coordination object for manually managed grouped work.
It does not itself provide the same spawn() ownership model.
Use Scope when the goal is:
start related tasks
+
guarantee waiting at scope boundarySee Task Groups for manual group coordination.
Typical Scope workflow
A complete structured workflow is:
#include <atomic>
#include <vix/threadpool/all.hpp>
int main()
{
vix::threadpool::ThreadPool pool(4);
std::atomic<int> completed{0};
{
vix::threadpool::Scope scope(pool);
for (int i = 0; i < 8; ++i)
{
const bool accepted = scope.spawn([&completed](){
completed.fetch_add(1, std::memory_order_relaxed);
});
if (!accepted)
{
return 1;
}
}
scope.wait_and_rethrow();
}
return completed.load(std::memory_order_relaxed) == 8 ? 0 : 1;
}The lifecycle is:
create Scope
↓
spawn related work
↓
Futures tracked internally
↓
wait_and_rethrow()
↓
Scope closes
↓
wait for every tracked Future
↓
propagate first observed failure if any
↓
Scope remains closed and emptyScope model summary
The core model is:
ThreadPool
▲
│ submit
│
Scope
│
├── Future<void>
├── Future<void>
├── Future<void>
│
└── CancellationSourceThe important properties are:
- A
Scopeis bound to one existingThreadPool. Scopedoes not own the pool.spawn()submits throughThreadPool::submit()and stores aFuture<void>.- Return values from spawned callables are discarded.
- Spawned callables must be invocable without arguments.
size()counts tracked Futures, not currently active tasks.wait()closes the Scope, waits for all tracked Futures, and swallows failures.wait_and_rethrow()closes the Scope, waits for all tracked Futures, then rethrows the first exception encountered while consuming them.- Both waiting operations leave the Scope closed and empty.
- The destructor calls
wait()and therefore waits automatically. - The destructor is
noexceptand does not propagate task failures. close()prevents future spawning but does not wait or cancel.cancel()requests cooperative cancellation but does not close the Scope.- All spawned tasks receive the Scope cancellation token.
- That token replaces any cancellation token already present in the supplied
TaskOptions. - Cancelling a Scope before
spawn()does not prevent tracking, but the resulting task observes the already cancelled token. spawn()returningtruemeans the Future was accepted for Scope tracking, not necessarily that the ThreadPool accepted the task for worker execution.Scopesynchronizes its tracking state sospawn(),close(), and waiting establish a consistent boundary.Scopeis neither copyable nor movable.- The natural lifetime order is to create the pool before the Scope and destroy the Scope before the pool.
Continue with Task Groups for manual grouped-task coordination or Synchronization for barriers and latches.