This document is the detailed design doc for the task runtime.
Runtime is the main API the caller uses.
Its job is to:
- accept a callable and its arguments
- create the state shared between the task and the future
- create the
Future - wrap the work into a
Task - hand that
Taskoff to theThreadPool
So Runtime is basically the coordinator. It does not run tasks itself. It prepares them and sends
them to the pool.
Runtime::submit() is thread-safe and may be called concurrently from multiple threads while the
Runtime is alive.
The ThreadPool owns:
- the worker threads
- the queue of pending tasks
- the mutex and condition variable protecting that queue
- the
drain_flag used during shutdown
The pool is responsible for waking workers when work arrives and making sure shutdown drains already submitted work instead of cancelling it.
Task is a small move-only type-erased wrapper.
It stores one callable and later exposes task.run().
The important design choice here is that Task itself only stores a nullary void callable. That
keeps the thread pool simple. It does not need to know about return types, exceptions, or user
arguments.
Runtime::submit() adapts user work into that shape before the task ever reaches the queue.
Future<T> is a simple handle returned to the caller.
- waits for the task to finish
- hands back either the result or the captured exception
Important properties:
- it is move-only
await()is single-use- a given
Futurehas one logical consumer - dropping the future does not cancel the task
If await() is called twice, it throws FutureAlreadyAwaited.
If await() is called on a moved-from future, it throws FutureMissingState.
SharedState<T> is the shared source of truth for task completion.
Both the queued task and the returned future hold a shared_ptr to it.
It stores:
- the task result as
std::expected<T, std::exception_ptr> - whether the task is done
- whether the result has already been consumed
- the mutex and condition variable used for waiting
This is the piece that lets the task and the future live on different threads while still agreeing on task state.
When the caller does:
auto future = runtime.submit(func, arg1, arg2);the runtime does a few things in order:
- figure out the return type
- allocate a
SharedState<ReturnType> - create a
Future<ReturnType>pointing at that shared state - build a
Taskthat owns the callable, the arguments, and the shared state - submit that task to the thread pool
The caller only sees the Future.
Tasks run later, possibly after the submission site has already returned. Because of that, the runtime needs to own the callable and its arguments.
The storage rules are:
- lvalue callables and arguments are copied into task-owned storage
- rvalue callables and arguments are moved into task-owned storage
That gives the runtime ownership of everything it needs when the worker thread eventually runs the task.
More specifically, submission copies copyable lvalue callables and arguments once into task-owned storage and moves rvalue callables and arguments into task-owned storage without copies.
When the task later runs, the stored callable is invoked with the value category it requires:
&-qualified callables run as lvalues, and &&-qualified callables run as rvalues.
One subtle but important detail: storage uses decay types. So plain references are not preserved unless the caller explicitly wraps them.
The worker thread does not run the original callable directly.
Instead, Runtime::submit() creates a lambda that captures:
- the callable
- the arguments
- the shared state
That lambda is what gets stored inside Task.
When it runs, it:
- calls
std::invoke(...)on the stored callable and arguments - if the task returns a value, moves that value into
SharedState::set_done(...) - if the task returns
void, just marks the shared state done - if user code throws, catches the exception and stores
std::current_exception()
So the task wrapper is doing two jobs:
- run user work
- publish the outcome back to the future
Each worker thread sits in worker_thread_loop().
The loop is:
- take the queue lock
- wait until either there is work in the queue or shutdown has started
- if shutdown has started and the queue is empty, exit
- otherwise move one task out of the queue
- release the lock
- run the task
The important part is that the lock is released before running user code.
That matters because holding the queue lock during task execution would be bad:
- submissions would block behind running tasks
- workers would block each other from taking more work
- one slow task could effectively serialize the whole pool
So the lock only protects the queue. It is not held while user code runs.
Future::await() just forwards to SharedState::await().
Inside SharedState::await():
- the future takes the shared-state lock
- it checks whether the result was already consumed
- if it was, it throws
FutureAlreadyAwaited - otherwise it marks the result as consumed
- then it waits until the task is done
- finally it moves the stored
std::expectedback to the caller
The result is marked consumed before waiting, not after.
That is intentional. It keeps await() single-use even if two threads race to call it on the same
future object.
User exceptions do not escape out of the worker thread.
Instead:
- the task catches them
- stores
std::current_exception() - and marks the task complete with an error result
So the caller sees failure as:
std::expected<T, std::exception_ptr>instead of as an exception thrown directly from the worker thread.
That keeps the runtime simpler and makes task failure part of the returned result channel.
Destroying Runtime destroys its ThreadPool.
When the pool starts shutting down, it:
- takes the queue lock
- sets
drain_ = true - releases the lock
- notifies all workers
After that:
- new submissions are rejected
- already queued tasks still run
- workers keep going until the queue is empty
- only then do workers exit
So shutdown is draining, not cancelling.
That is why runtime destruction waits for submitted work to finish.
If someone tries to submit after draining has started, ThreadPool::submit() throws
SubmittedToDrainingThreadpool.
This is currently treated as user error. The runtime does not detect it or work around it.