Lifecycle and Shutdown
ThreadPool owns the scheduler and worker threads that execute submitted tasks.
Its normal lifecycle is:
construct
↓
starts automatically
↓
submit work
↓
optional wait_idle()
↓
shutdown()
↓
workers stop and joinThe destructor calls shutdown() automatically.
#include <vix/threadpool/all.hpp>
int main()
{
vix::threadpool::ThreadPool pool(4);
auto future = pool.submit([](){
return 42;
});
const int result = future.get();
pool.shutdown();
return result == 42 ? 0 : 1;
}Calling shutdown() explicitly is optional when normal object destruction already provides the desired lifetime boundary.
Automatic startup
Every ThreadPool constructor starts the pool automatically.
This applies to:
vix::threadpool::ThreadPool pool;vix::threadpool::ThreadPool pool(4);and:
vix::threadpool::ThreadPoolConfig config;
config.thread_count = 4;
vix::threadpool::ThreadPool pool(config);Immediately after successful construction:
pool.running();returns:
trueConstruction path
The high-level construction sequence is:
ThreadPoolConfig
↓
normalize configuration
↓
construct Scheduler
↓
Scheduler creates Worker objects
↓
ThreadPool::start()
↓
Scheduler::start()
↓
start every worker threadWorkers are created before their physical threads start.
Default worker count
The default pool uses:
vix::threadpool::ThreadPoolConfig::default_thread_count();which returns:
std::thread::hardware_concurrency()or:
1when hardware concurrency is unavailable.
Configuration normalization always guarantees at least one worker.
Check whether the pool is running
Use:
if (pool.running())
{
// Pool currently accepts ordinary work.
}ThreadPool::running() requires both:
ThreadPool running flag
+
Scheduler running flagto be true.
Conceptually:
pool running
↓
ordinary submissions allowedAfter shutdown completes:
pool.running() == falsestart()
Although construction starts the pool automatically, the lifecycle API also exposes:
const bool started = pool.start();Calling start() while the pool is already running returns:
falsebecause no new running transition occurred.
For example:
vix::threadpool::ThreadPool pool(4);
const bool started = pool.start();produces:
started = falseThe existing workers continue running normally.
Successful startup
When transitioning from stopped to running, start():
sets ThreadPool running flag
↓
calls Scheduler::start()
↓
starts worker threadsIf scheduler startup fails, the ThreadPool running flag is restored to false.
The return value therefore indicates whether a new running worker set was successfully started.
Pool state and work state are different
A pool can be:
running and busy
running and idle
stopped with no pending work
stopped with retained pending workThese concepts should not be conflated.
Use:
pool.running();for lifecycle state.
Use:
pool.idle();for observed work state.
Check whether the pool is idle
Use:
if (pool.idle())
{
// No queued or active task is currently observed.
}The check is based on:
pending tasks == 0
+
active tasks == 0It does not mean:
pool is shut downA normally running ThreadPool often spends most of its lifetime idle between workloads.
Pending tasks
Use:
const std::size_t pending = pool.pending();This returns the sum of tasks currently waiting in all worker-local queues.
For example:
Worker 1 queue = 2
Worker 2 queue = 1
Worker 3 queue = 0
Worker 4 queue = 3
pool.pending() = 6Tasks that workers have already removed for execution are no longer counted as pending.
Active tasks
There is no direct:
pool.active()method.
Use:
const auto metrics = pool.metrics();
const auto active = metrics.active_tasks;when the active task count is required.
Wait until current work becomes idle
Use:
pool.wait_idle();This waits until the pool observes:
pending_tasks == 0
and
active_tasks == 0For example:
vix::threadpool::ThreadPool pool(4);
for (int i = 0; i < 8; ++i)
{
const bool accepted = pool.post([](){
perform_work();
});
if (!accepted)
{
return 1;
}
}
pool.wait_idle();After wait_idle() returns, the tasks submitted in that stable workload have finished executing or otherwise reached their runtime terminal path.
wait_idle() does not shut down the pool
This:
pool.wait_idle();does not stop workers.
The pool remains reusable:
pool.wait_idle();
auto future = pool.submit([](){
return 42;
});The lifecycle is:
running
↓
work
↓
wait_idle()
↓
still running
↓
more workUse shutdown() when the worker runtime itself should stop.
How wait_idle() observes idle state
The current implementation does not return after only one idle observation.
It requires two consecutive observations:
check idle
↓
idle?
│
├── no → continue
│
└── yes
↓
yield
↓
check idle again
↓
idle again?
┌────┴────┐
yes no
│ │
return continueThe loop uses:
std::this_thread::yield();between observations.
This reduces sensitivity to a transient single idle snapshot.
wait_idle() is not a submission barrier
wait_idle() does not close the pool against concurrent producers.
Suppose one thread waits:
Thread A
↓
wait_idle()while another thread can still submit:
Thread B
↓
post new taskwait_idle() only waits for an observed idle condition.
It does not establish:
no task can ever be submitted after this pointWhen a final lifecycle boundary is required:
stop producers
↓
wait for desired work
↓
shutdown poolis the application-level pattern.
wait_idle() has no timeout
The current API provides:
pool.wait_idle();but not:
wait_idle_for()
wait_idle_until()If work never reaches an idle state, wait_idle() can continue indefinitely.
This includes tasks that:
never return
deadlock
wait forever on external state
continuously create more workThe ThreadPool does not impose a timeout on wait_idle() itself.
Shutdown
Use:
pool.shutdown();to stop the ThreadPool.
The operation performs:
ThreadPool running flag = false
↓
Scheduler::stop()
↓
request every Worker to stop
↓
Scheduler::join()
↓
join every worker threadWhen shutdown() returns, the worker threads that can be joined through the normal external shutdown path have completed their worker loops and have been joined.
Shutdown is cooperative
Stopping a worker does not forcibly kill its std::thread.
The worker observes its stop state through its loop.
If a callable is already executing:
worker
↓
callable running
↓
shutdown requested
↓
callable continues
↓
callable returns
↓
worker can stopshutdown() therefore waits for currently executing C++ callables to return naturally.
There is no forced thread termination.
A running task can delay shutdown
For example:
vix::threadpool::ThreadPool pool(1);
pool.post([](){
std::this_thread::sleep_for(
std::chrono::seconds{5}
);
});
pool.shutdown();If the worker has already started the callable, shutdown cannot interrupt the sleep.
The shutdown call waits for the worker thread to finish its current execution path.
This is why long-running work should use cooperative cancellation when early termination is required.
See Cancellation.
Shutdown is idempotent
Calling:
pool.shutdown();
pool.shutdown();
pool.shutdown();is safe.
After the first completed shutdown:
running = false
workers already stopped and joinedLater calls repeat the stop/join operations safely without creating new workers.
This also makes automatic destructor shutdown safe after explicit shutdown.
Post after shutdown
Ordinary posted work is rejected after shutdown:
vix::threadpool::ThreadPool pool(1);
pool.shutdown();
const bool accepted = pool.post([](){
perform_work();
});The result is:
falseThe callable does not execute.
Submit after shutdown
submit() still returns a Future, but that Future immediately represents rejection:
vix::threadpool::ThreadPool pool(1);
pool.shutdown();
auto future = pool.submit([](){
return 42;
});The Future reports:
ready() true
status() rejected
result() rejected
error() rejectedCalling:
future.get();throws std::system_error.
Handle after shutdown
handle() behaves similarly:
vix::threadpool::ThreadPool pool(1);
pool.shutdown();
auto handle = pool.handle([](){
return 42;
});The returned handle still contains its task ID, Future, and cancellation source, but its asynchronous result is already rejected.
The handle is not evidence that the runtime accepted the task.
Default shutdown drains queued work
The default configuration is:
vix::threadpool::ThreadPoolConfig config;
config.drain_on_shutdown == true;This means worker loops continue consuming their existing local queues after stop is requested.
Conceptually:
shutdown()
↓
stop accepting ordinary work
↓
workers receive stop request
↓
currently running task finishes
↓
queued tasks remain?
┌────┴────┐
yes no
│ │
execute exit
│
repeatWith the default configuration, shutdown acts as a draining shutdown.
Draining example
#include <atomic>
#include <vix/threadpool/all.hpp>
int main()
{
vix::threadpool::ThreadPoolConfig config;
config.thread_count = 1;
config.drain_on_shutdown = true;
vix::threadpool::ThreadPool pool(config);
std::atomic<int> completed{0};
for (int i = 0; i < 8; ++i)
{
const bool accepted = pool.post([&completed](){
completed.fetch_add(1, std::memory_order_relaxed);
});
if (!accepted)
{
return 1;
}
}
pool.shutdown();
return completed.load(std::memory_order_relaxed) == 8 ? 0 : 1;
}Accepted queued tasks are processed before the worker loop exits.
drain_on_shutdown
Configure shutdown behavior before construction:
vix::threadpool::ThreadPoolConfig config;
config.thread_count = 4;
config.drain_on_shutdown = false;
vix::threadpool::ThreadPool pool(config);This field is transferred to the scheduler as:
SchedulerConfig::drain_on_stopand then to every worker.
It cannot currently be changed through the high-level ThreadPool after construction.
Shutdown without draining
With:
config.drain_on_shutdown = false;the worker loop exits after its current active task when stop is observed.
Queued tasks are not executed merely to empty the queue.
Conceptually:
active task
↓
shutdown()
↓
active task finishes
↓
drain_on_shutdown == false
↓
worker exitsTasks still in the local queue remain queued in the worker object.
Non-draining shutdown does not clear queues
This distinction is important.
Non-draining shutdown does not call:
pool.clear();The current path is:
shutdown()
↓
stop workers
↓
worker exits without draining
↓
queued Task objects remain in TaskQueueTherefore after non-draining shutdown:
pool.pending();can still be greater than zero.
Verified retained-queue behavior
For example, with one worker:
Task A running
Task B queued
drain_on_shutdown = falseafter shutdown:
running = false
Task A finished
Task B still queued
pending() = 1This is the current implementation behavior.
Non-draining shutdown should therefore be understood as:
stop workers without consuming remaining queuesnot:
discard and finalize every queued taskNon-draining shutdown and Futures
This has an important consequence for result-producing work.
Suppose:
Task A running
Task B queuedand Task B was created through:
auto future = pool.submit([](){
return 42;
});If non-draining shutdown stops the worker before Task B executes, the current implementation does not automatically complete that Future as:
cancelled
rejected
timeout
broken promiseThe queued wrapper simply remains in the worker queue.
Therefore:
future.ready();can remain:
falseafter shutdown.
Do not wait on abandoned Futures after non-draining shutdown
This pattern can block indefinitely:
pool.shutdown();
future.get();when the corresponding task remained queued during a non-draining shutdown.
The current runtime does not resolve that Future automatically.
If result-producing work must always reach a terminal Future state, prefer draining shutdown or explicitly coordinate cancellation and completion before stopping the pool.
Destroying abandoned queued tasks does not create broken_promise
When the pool is eventually destroyed, retained queued task wrappers are destroyed with their worker queues.
The current custom Promise implementation does not automatically publish a broken_promise result when its producer disappears.
A surviving Future for such abandoned work can therefore remain non-ready rather than being converted to a terminal error.
This is an important limitation of non-draining shutdown for result-producing submissions.
Prefer non-draining shutdown for disposable posted work
drain_on_shutdown = false is easiest to reason about for work where abandoning queued execution is explicitly acceptable.
For example:
best-effort telemetry
discardable refresh work
non-essential background notificationsFor work represented by Futures that callers must consume, draining shutdown provides a clearer completion contract with the current implementation.
clear()
Use:
const std::size_t removed = pool.clear();to remove tasks that are still waiting in worker queues.
Conceptually:
Worker 1 queue ──┐
Worker 2 queue ──┼──► clear()
Worker 3 queue ──┤
Worker 4 queue ──┘
↓
remove queued tasksThe return value is the total number removed.
clear() does not stop workers
Calling:
pool.clear();does not change:
pool.running();A running pool remains running.
New tasks can still be submitted immediately afterward.
clear() does not affect active tasks
A task already executing has already left its worker queue.
Therefore:
running task
↓
clear()
↓
running task continuesOnly queued tasks are removed.
clear() and post()
For fire-and-forget work:
const std::size_t removed = pool.clear();simply means those removed callbacks will not execute through their worker queues.
The pool remains available for other work.
clear() and Future-producing tasks
For submit() and handle(), the current clear() behavior has the same important limitation as abandoned non-draining shutdown.
A queued task wrapper can contain the producer for a Future.
When clear() removes that wrapper, the current implementation does not publish a terminal result into the Future.
Conceptually:
submit()
↓
Future returned
↓
task queued
↓
clear()
↓
queued wrapper destroyed
↓
Future can remain non-readyDo not use clear() as result-aware cancellation
For result-producing work, this is not a safe cancellation protocol:
auto future = pool.submit([](){
return 42;
});
pool.clear();
future.get();If the corresponding task was removed before execution, future.get() can block because no terminal value or error was published.
Use explicit cancellation and ensure the asynchronous operation reaches a result path when callers depend on its Future.
See Cancellation.
clear() can race with workers
Workers and clear() operate concurrently on the thread-safe local queues.
If a worker removes a task before clear() reaches it:
worker pops task
↓
task becomes active
↓
clear()
↓
task is no longer removableTherefore:
const std::size_t removed = pool.clear();can be smaller than the number of tasks that appeared pending immediately before the call.
The return value is the number actually removed.
clear() followed by wait_idle()
For posted work, a useful pattern can be:
const std::size_t removed = pool.clear();
pool.wait_idle();This waits for any tasks that escaped clearing because they were already active or were removed by workers first.
It does not restore or execute the tasks that clear() removed.
For Future-producing work, remember the unresolved-Future limitation.
Shutdown and clear() are different
shutdown() controls worker lifetime:
stop worker runtimeclear() controls queued work:
remove current queued tasksThey can be composed:
pool.clear();
pool.shutdown();but the semantics differ from draining shutdown.
With result-producing queued tasks, manually clearing them first can leave their Futures unresolved.
Default recommended shutdown
For most applications using Futures, the simplest lifecycle is:
vix::threadpool::ThreadPool pool(4);
// Submit and use work.
pool.shutdown();with the default:
drain_on_shutdown = trueThe accepted queue is processed before workers exit.
Explicit wait_idle() before shutdown is optional when draining shutdown itself provides the desired completion boundary.
wait_idle() before shutdown
This pattern is also valid:
pool.wait_idle();
pool.shutdown();It separates two intentions:
wait_idle()
↓
wait until current work finishes
while pool is still running
shutdown()
↓
stop and join worker runtimeThis can make application lifecycle logic easier to read.
Draining shutdown does not require wait_idle() first
With the default configuration:
config.drain_on_shutdown = true;this:
pool.shutdown();already requests worker stop while allowing queued tasks to drain before the threads exit.
Calling wait_idle() first is not required merely to make queued tasks execute.
Use it when the application specifically needs an idle point before stopping the runtime.
Destructor
ThreadPool::~ThreadPool() is noexcept and calls:
shutdown();Therefore:
{
vix::threadpool::ThreadPool pool(4);
pool.post([](){
perform_work();
});
}performs ThreadPool shutdown at the closing brace.
With the default drain configuration, accepted queued work is drained before destruction completes.
Destructor can block
Because shutdown joins workers, destruction can block while:
active callable finishes
queued work drainsFor example, a five-second running task can delay destruction by several seconds.
ThreadPool destruction is a lifecycle synchronization point.
Scope lifetime around the pool
When using Scope, create the pool first:
vix::threadpool::ThreadPool pool(4);
{
vix::threadpool::Scope scope(pool);
scope.spawn([](){
perform_work();
});
}The scope is destroyed and waits for its tracked tasks before the pool itself is destroyed.
Conceptually:
ThreadPool lifetime
┌──────────────────────────────┐
│ │
│ Scope lifetime │
│ ┌────────────────────┐ │
│ │ scoped tasks │ │
│ └────────────────────┘ │
│ │
└──────────────────────────────┘This is the natural ownership order.
PeriodicTask lifetime around the pool
A PeriodicTask stores a non-owning reference to its executor.
Stop and join it before the pool is destroyed:
vix::threadpool::ThreadPool pool(4);
auto periodic = pool.schedule_every(
[](){
perform_periodic_work();
}
);
periodic.start();
// ...
periodic.stop();
periodic.join();
pool.shutdown();Do not leave a periodic scheduler running after the pool it references has been destroyed.
See Periodic Tasks.
Restart after shutdown
The public lifecycle currently supports starting the same ThreadPool again after shutdown() has completed.
For example:
vix::threadpool::ThreadPool pool(2);
pool.shutdown();
const bool restarted = pool.start();In the current implementation:
restarted = truewhen the worker threads can be created again successfully.
The worker objects themselves are retained by the scheduler and receive new std::thread instances.
Restart model
The sequence is:
running
↓
shutdown()
↓
workers stop
↓
workers join
↓
stopped
↓
start()
↓
worker threads start again
↓
runningTask counters and task ID generators are not reset.
The restarted pool continues the lifetime of the same ThreadPool object.
Retained queues can execute after restart
This is especially important with:
config.drain_on_shutdown = false;Queued tasks retained during shutdown remain in their worker queues.
If the pool is restarted:
before shutdown:
Task A active
Task B queued
shutdown without drain:
Task A finishes
Task B remains queued
start again:
Worker restarts
↓
Task B executesThis behavior has been verified against the current implementation.
Therefore non-draining shutdown is not equivalent to permanently discarding queued work while the same pool object remains restartable.
Clear retained work before restart when required
If non-draining shutdown is used and retained queued work must not execute after restart:
pool.shutdown();
const std::size_t removed = pool.clear();
const bool restarted = pool.start();removes the retained queue before workers resume.
For posted disposable work, this can provide the intended reset.
For result-producing queued work, remember that clear() can leave associated Futures unresolved.
Restart is not a state reset
Restarting does not reset:
task IDs
queue sequence numbers
metrics counters
statistics counters
retained queued tasksIt restarts worker execution.
Conceptually:
same ThreadPool object
same Scheduler
same Worker objects
same counters
new worker std::threadsCreate a new ThreadPool object when a completely fresh runtime state is required.
allow_after_stop
TaskOptions exposes:
options.set_allow_after_stop(true);This is an advanced lifecycle option.
At the high-level ThreadPool boundary, ordinary submissions are accepted when:
ThreadPool running == trueIf the ThreadPool running flag is already false, allow_after_stop can only pass the first acceptance check while the internal scheduler is still running.
This creates a narrow concurrent shutdown window.
After completed shutdown, allow_after_stop does not help
This does not work:
vix::threadpool::ThreadPool pool(1);
pool.shutdown();
vix::threadpool::TaskOptions options;
options.set_allow_after_stop(true);
const bool accepted = pool.post(
[](){
perform_work();
},
options
);The result is:
falsebecause after shutdown() returns:
ThreadPool running = false
Scheduler running = falseallow_after_stop is therefore not a way to submit work to a fully stopped pool.
Do not design ordinary work around the shutdown window
The intended high-level lifecycle remains:
running
↓
accept work
shutdown begins
↓
stop accepting ordinary work
shutdown complete
↓
stoppedallow_after_stop exists in the lower-level task model, but normal application work should not rely on racing submissions against shutdown.
Coordinate producers before final pool shutdown instead.
Shutdown from an owning thread
The clearest lifecycle is for an external owner to perform shutdown:
application owner
↓
stop producers
↓
stop periodic schedulers
↓
wait/cancel structured work
↓
ThreadPool::shutdown()This keeps worker lifetime management separate from the tasks the workers execute.
Lifecycle with post()
For fire-and-forget work:
post()
↓
accepted?
┌───┴───┐
no yes
│ │
caller queued
handles ↓
failure worker executes
↓
shutdown drains by defaultThe caller should check the bool returned by post() when losing the work is not acceptable.
Lifecycle with submit()
For result-producing work:
submit()
↓
Future returned
↓
task accepted?
┌────┴────┐
no yes
│ │
Future queued
rejected ↓
executes
↓
Future resultWith normal draining shutdown, accepted queued tasks continue toward execution.
With non-draining shutdown or clear(), queued Future-producing wrappers can be left without a terminal result.
Lifecycle with handle()
handle() adds cooperative cancellation:
TaskHandle
├── TaskId
├── Future
└── CancellationSourceBefore final shutdown, application code can request cancellation for work it no longer needs:
handle.cancel();Cancellation remains cooperative and does not forcibly terminate a running callable.
Recommended ownership order
A typical application lifetime can be organized as:
create ThreadPool
↓
create components that reference pool
↓
submit work
↓
stop new producers
↓
stop PeriodicTask schedulers
↓
close/wait Scopes or other structured work
↓
optional wait_idle()
↓
shutdown ThreadPool
↓
destroy dependent components
↓
destroy ThreadPoolThe exact application structure can differ, but non-owning executor references should never outlive the pool they reference.
Lifecycle model summary
The normal default path is:
ThreadPool construction
↓
automatic start
↓
workers running
↓
post / submit / handle
↓
optional wait_idle()
↓
shutdown()
↓
running = false
↓
Scheduler::stop()
↓
Workers receive stop
↓
drain_on_shutdown?
┌───────┴───────┐
true false
│ │
finish queued finish current
tasks active task
│ │
└────────┬────────┘
↓
worker loops exit
↓
Scheduler::join()
↓
stoppedThe important properties are:
- Every
ThreadPoolconstructor starts the pool automatically. running()describes lifecycle state, not whether work currently exists.idle()means no pending or active task is currently observed.pending()counts tasks waiting in worker-local queues.wait_idle()waits for two consecutive idle observations and yields between checks.wait_idle()does not stop the pool.wait_idle()is not a barrier against concurrent future submissions.- The current API has no timed
wait_idle()variant. shutdown()sets the ThreadPool to stopped, stops the scheduler, and joins workers.- Shutdown is cooperative and does not forcibly interrupt active C++ callables.
- Shutdown is safe to call repeatedly.
- The destructor calls
shutdown()automatically. - Destruction can block while active or draining work finishes.
- The default
drain_on_shutdownvalue istrue. - Draining shutdown executes accepted queued work before worker exit.
- With
drain_on_shutdown=false, workers stop after their active task rather than consuming remaining queues. - Non-draining shutdown currently leaves queued task objects in their worker queues.
pending()can therefore remain non-zero after non-draining shutdown.- Futures corresponding to retained queued tasks are not automatically completed as cancelled, rejected, or broken promises.
- A surviving Future for abandoned queued work can remain non-ready.
clear()removes queued work without stopping the pool.clear()does not affect already active tasks.- Removing queued
submit()orhandle()work withclear()can also leave the corresponding Future unresolved. clear()should not be treated as result-aware cancellation.post()after completed shutdown returnsfalse.submit()after completed shutdown returns an immediately rejected Future.handle()after completed shutdown returns a handle whose Future is rejected.allow_after_stopdoes not allow normal submission after shutdown has fully completed.- The same ThreadPool object can currently be restarted with
start()aftershutdown(). - Restart creates new worker threads around the existing worker objects and retained runtime state.
- Queued work preserved by non-draining shutdown can execute after restart.
- Restart does not reset IDs, counters, statistics, or queues.
ScopeandPeriodicTaskobjects referencing the pool should complete or stop before the pool is destroyed.- For result-producing work, the default draining shutdown provides the clearest current completion model.
Continue with Errors for ThreadPool error codes, Future error propagation, rejection, cancellation, and timeout failures.