Vix.cpp v2.8.5 is here Read the blog
Skip to content

Task Handles

TaskHandle<T> represents a result-producing task together with its task identifier and cancellation control.

Create one with ThreadPool::handle():

cpp
#include <vix/threadpool/all.hpp>

int main()
{
  vix::threadpool::ThreadPool pool(4);

  auto handle = pool.handle([](){
    return 42;
  });

  return handle.get() == 42 ? 0 : 1;
}

Use a task handle when a Future alone is not enough and the caller also needs to identify the task or request cancellation.

What a TaskHandle contains

A task handle combines three pieces of state:

text
TaskHandle<T>
├── TaskId
├── Future<T>
└── CancellationSource

These parts serve different purposes.

text
TaskId

identify the submitted task

Future<T>

wait for and retrieve the result

CancellationSource

request cancellation before execution begins

The handle does not own the thread pool or worker executing the task.

Create a handle

Use handle() instead of submit():

cpp
auto handle = pool.handle([](){
  return 42;
});

The result type is inferred from the callable, just as it is with submit().

For example:

cpp
auto integer = pool.handle([](){
  return 42;
});

auto text = pool.handle([](){
  return std::string{"Vix.cpp"};
});

A callable returning void produces a TaskHandle<void>:

cpp
auto handle = pool.handle([](){
  perform_work();
});

handle.get();

For TaskHandle<void>, get() waits for successful completion without returning a value.

TaskHandle vs Future

submit() returns a Future:

cpp
auto future = pool.submit([](){
  return 42;
});

handle() returns a TaskHandle:

cpp
auto handle = pool.handle([](){
  return 42;
});

The difference is:

text
Future<T>
├── wait
├── result
└── asynchronous state


TaskHandle<T>
├── everything available through its Future
├── TaskId
└── cancellation source

Use submit() when the result is all the caller needs.

Use handle() when task identity or cancellation control is also required.

Task identity

Every handle created by ThreadPool::handle() receives a TaskId.

cpp
auto handle = pool.handle([](){
  return 42;
});

const auto id = handle.id();

The identifier is assigned by the pool before the task is submitted to the scheduler.

The first automatically generated identifier in a pool starts at 1, because 0 is reserved as:

cpp
vix::threadpool::invalid_task_id

Task identifiers are generated by each ThreadPool instance.

They should be treated as identifiers for tasks belonging to that pool, not as globally unique process-wide identifiers.

Check handle validity

Use:

cpp
if (handle.valid())
{
  // The handle contains a valid task ID and Future state.
}

The boolean conversion provides the same check:

cpp
if (handle)
{
  // Valid handle.
}

A default-constructed handle is invalid:

cpp
vix::threadpool::TaskHandle<int> handle;

if (!handle)
{
  // No valid submitted task is represented.
}

valid() checks that:

text
TaskId is valid
        +
Future owns shared state

It does not mean that the scheduler accepted or successfully executed the task.

A rejected submission can still return a valid handle whose Future contains a rejection result.

Use status(), result(), error(), or get() to inspect the outcome.

Wait for completion

Use wait() when the caller needs to wait without consuming the result:

cpp
auto handle = pool.handle([](){
  return 42;
});

handle.wait();

After wait() returns, the asynchronous result is ready.

The value can still be retrieved afterward:

cpp
handle.wait();

const int value = handle.get();

wait() does not consume the result.

Check readiness

Use:

cpp
if (handle.ready())
{
  // A value, exception, or threadpool error is available.
}

ready() means that the underlying Future has reached a ready state.

A ready handle may represent:

text
successful value
exception
cancellation
timeout
rejection

Readiness does not imply success.

Retrieve the result

Use get() to wait for and consume the result:

cpp
auto handle = pool.handle([](){
  return 42;
});

const int value = handle.get();

If the result is not ready, get() waits.

For successful non-void work, it returns the stored value.

For successful void work:

cpp
auto handle = pool.handle([](){
  perform_work();
});

handle.get();

it returns after completion.

get() consumes the result

The underlying Future uses single-consumer semantics.

Calling get() more than once throws std::future_error.

cpp
auto handle = pool.handle([](){
  return 42;
});

const int value = handle.get();

// Calling handle.get() again is invalid.

The same rule applies if the underlying future is consumed directly through future().

For example:

cpp
auto handle = pool.handle([](){
  return 42;
});

const int value = handle.future().get();

After this call, handle.get() cannot retrieve the value again.

Exceptions

Exceptions thrown by the callable are stored in the underlying asynchronous state.

cpp
auto handle = pool.handle([]() -> int {
  throw std::runtime_error("failure");
});

The exception is rethrown by get():

cpp
try
{
  const int value = handle.get();
}
catch (const std::runtime_error&)
{
  // Handle task failure.
}

The exception does not escape directly from the worker thread.

ThreadPool errors

A handle can also complete with a ThreadPool-specific error.

For example, cancellation or rejected submission can produce a ThreadPoolErrc in the underlying Future.

Calling get() in this case throws std::system_error.

cpp
try
{
  const int value = handle.get();
}
catch (const std::system_error&)
{
  // Handle threadpool error.
}

The error can be inspected without consuming the result:

cpp
const auto error = handle.error();

See Errors for the error model.

Request cancellation

Call:

cpp
handle.cancel();

to request cancellation through the source owned by the handle.

For example:

cpp
vix::threadpool::ThreadPool pool(1);

auto handle = pool.handle([](){
  return 42;
});

handle.cancel();

The operation is idempotent. Repeated cancellation requests keep the same cancellation state.

cpp
handle.cancel();
handle.cancel();

Cancellation is cooperative and does not forcibly terminate a running C++ function.

Cancellation before execution

The cancellation source owned by the handle is connected to the submitted task.

Before the user callable begins, the result wrapper checks that cancellation state.

Conceptually:

text
handle()

task queued

handle.cancel()

worker reaches task

cancellation requested?

   ├── yes → do not call user function

   └── no  → execute user function

This makes task handles useful for cancelling work that is still waiting to execute.

A typical example uses a busy single-worker pool:

cpp
#include <chrono>
#include <thread>
#include <vix/threadpool/all.hpp>

int main()
{
  vix::threadpool::ThreadPool pool(1);

  auto blocker = pool.submit([](){
    std::this_thread::sleep_for(std::chrono::milliseconds{100});
  });

  auto handle = pool.handle([](){
    return 42;
  });

  handle.cancel();

  blocker.get();

  return handle.cancelled() ? 0 : 1;
}

The second task remains queued while the first task occupies the only worker, giving the cancellation request time to become visible before its callable starts.

Cancellation after execution begins

cancel() does not stop arbitrary C++ code that has already begun executing.

The current handle execution path checks its cancellation source immediately before invoking the user callable.

Once the callable has started, requesting cancellation does not interrupt it.

For example:

text
task starts

user callable begins

handle.cancel()

cancellation request becomes true

callable continues running

The callable may still return normally and its Future may complete successfully.

This distinction is important:

text
handle.cancelled()

cancellation was requested

handle.status() == cancelled

the asynchronous result completed as cancelled

These are not the same condition.

See Cancellation for the complete cancellation model.

Check whether cancellation was requested

Use:

cpp
const bool requested = handle.cancelled();

This queries the cancellation source owned by the handle.

It answers:

text
Has cancellation been requested?

It does not answer:

text
Did the task finish as cancelled?

To inspect the terminal asynchronous result, use:

cpp
handle.status();
handle.result();
handle.error();

Task status

status() forwards to the underlying Future state:

cpp
const auto status = handle.status();

Possible terminal values include:

text
completed
failed
cancelled
timed_out
rejected

Before the asynchronous state becomes ready, the current Future state reports:

text
created

The handle's Future state does not currently mirror the low-level task's intermediate queued and running transitions.

Therefore, do not use handle.status() as a live worker execution-state monitor.

Use it primarily to inspect the final asynchronous outcome.

See Task Results and Status.

Task result

result() reports the result stored in the Future state:

cpp
const auto result = handle.result();

Possible values are:

text
none
success
failure
cancelled
timeout
rejected

Before a terminal result has been stored:

text
result = none

After successful completion:

text
status = completed
result = success
error  = ok

The status and result APIs allow code to inspect completion without consuming the stored value.

Error state

Use:

cpp
const auto error = handle.error();

For successful completion:

text
ThreadPoolErrc::ok

Possible ThreadPool-specific failures include values such as:

text
cancelled
timeout
rejected

A valid handle that has not completed yet also reports ok, because its asynchronous state has not stored an error.

Use ready() together with error() when the distinction matters:

cpp
if (handle.ready() && handle.error() != vix::threadpool::ThreadPoolErrc::ok)
{
  // A threadpool error completed the result.
}

Access the underlying Future

The handle exposes its Future directly:

cpp
auto& future = handle.future();

A const overload is also available:

cpp
const auto& future = std::as_const(handle).future();

This is useful when an API requires a Future<T>& or when the caller needs operations not duplicated by TaskHandle.

For example, TaskHandle does not expose wait_for() directly.

Use the Future:

cpp
const auto status = handle.future().wait_for(
        std::chrono::milliseconds{100}
);

The underlying Future remains owned by the handle.

Calling get() through either interface consumes the same asynchronous result.

Access the cancellation source

The cancellation source is also exposed:

cpp
auto& source = handle.cancellation_source();

Requesting cancellation through it is equivalent to calling:

cpp
handle.cancel();

For example:

cpp
handle.cancellation_source().request_cancel();

The const overload can be used to inspect the source:

cpp
const bool requested = handle.cancellation_source().cancelled();

For ordinary cancellation, prefer handle.cancel() because it expresses the intent directly.

Do not reset the cancellation source of a running handle

CancellationSource has a reset() operation that creates a new cancellation state.

The submitted task keeps observing the cancellation state created when handle() performed the submission.

Therefore, resetting the source exposed by a live handle separates future cancellation requests from the token already associated with that task.

Avoid:

cpp
handle.cancellation_source().reset();

while the task is still pending or executing.

Use the cancellation source as the control channel created for that handle.

Cancellation options passed to handle()

handle() creates its own CancellationSource so that TaskHandle::cancel() can control the submitted task.

As part of this process, the handle-owned cancellation token replaces any cancellation token already present in the supplied TaskOptions.

For example:

cpp
vix::threadpool::TaskOptions options;
options.set_priority(vix::threadpool::TaskPriority::high);

auto handle = pool.handle([](){
  return 42;
}, options);

priority and the other task options remain meaningful, but cancellation is controlled by the source owned by the returned handle.

If work must use an externally supplied CancellationToken, use the normal task-option path described in Cancellation.

Rejected handles

Unlike post(), handle() does not return a boolean acceptance result.

If the pool cannot accept the task, the returned handle contains a ready Future with a rejection error.

For example:

cpp
vix::threadpool::ThreadPool pool(1);

pool.shutdown();

auto handle = pool.handle([](){
  return 42;
});

The handle still contains a task ID and Future state.

The result is represented asynchronously:

text
ready   true
status  rejected
result  rejected
error   rejected

Calling:

cpp
handle.get();

throws std::system_error.

This is why handle.valid() should not be used as an acceptance check.

Pre-reserve a task ID

Some higher-level systems need the task identifier before they build the callable.

ThreadPool provides:

cpp
const auto id = pool.next_task_id();

The reserved ID can then be used with:

cpp
auto handle = pool.handle_with_id(id, [id](){
  return id;
});

The returned handle contains the supplied identifier:

cpp
if (handle.id() != id)
{
  return 1;
}

The normal pattern is:

text
next_task_id()

construct state that uses the ID

handle_with_id()

Most application code does not need this API. Use handle() when the identifier does not need to exist before submission.

handle_with_id() does not generate another ID

When using:

cpp
const auto id = pool.next_task_id();

auto handle = pool.handle_with_id(id, [](){
  return 42;
});

handle_with_id() uses the ID supplied by the caller.

It does not allocate an additional task ID.

Use IDs returned by next_task_id() rather than inventing arbitrary values when task identity is meant to follow the pool's normal ID sequence.

Move-only semantics

TaskHandle<T> is move-only because its Future<T> is move-only.

Copying is disabled:

text
copy construction   disabled
copy assignment     disabled

Moving is supported:

cpp
auto first = pool.handle([](){
  return 42;
});

auto second = std::move(first);

After the move, second owns the Future and cancellation source.

The moved-from handle should not be used as a valid task handle.

A handle does not keep the pool alive

TaskHandle stores:

text
TaskId
Future
CancellationSource

It does not store or own the ThreadPool.

Conceptually:

text
ThreadPool

submits task

TaskHandle

observes result and cancellation state

Pool lifetime and handle lifetime are separate.

A handle can retain its asynchronous state independently, but correct task completion still depends on the execution lifecycle of the pool that accepted the work.

Typical workflow

A normal task-handle workflow is:

cpp
#include <vix/threadpool/all.hpp>

int main()
{
  vix::threadpool::ThreadPool pool(4);

  auto handle = pool.handle([](){
    return 42;
  });

  if (!handle.valid())
  {
    return 1;
  }

  const auto id = handle.id();
  const int result = handle.get();

  return id != vix::threadpool::invalid_task_id && result == 42 ? 0 : 1;
}

When cancellation is required:

cpp
auto handle = pool.handle([](){
  return perform_work();
});

if (work_is_no_longer_needed())
{
  handle.cancel();
}

try
{
  auto result = handle.get();
}
catch (const std::system_error&)
{
  // Cancellation, rejection, or another threadpool error.
}

The important model is:

text
ThreadPool::handle()

    TaskHandle
    ├── identity
    ├── Future
    └── cancellation request

worker execution

terminal asynchronous result

Use Futures and Promises for the result model and Cancellation for the detailed cancellation semantics.

Released under the MIT License.