Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Futures

Executor submission returns an affine Future[T] with one result consumer. Futures can move and live in collections, classes, and enums, but cannot be copied, compared, or passed into worker code. There is no shared-result handle, result cloning, async/await, or event loop.

OperationResultOwnership and waiting
future.result()Result[T, FutureError]Consume the future and wait for its result
future.done()boolObserve completion or cancellation
future.cancel()boolPrevent pending work from starting
future.wait_timeout_ms(timeout_ms: u64)boolObserve completion/cancellation within a millisecond budget

Methods other than result observe their receiver without needing mut. Cancellation succeeds only before execution begins. Repeated cancellation of a cancelled job returns True; running or completed jobs return False. Successful cancellation immediately makes done() true and result() return Err(FutureError.Cancelled) without waiting. It does not immediately reclaim queue space: captures drop after both the queue and public future release them.

Dropping a future neither cancels nor waits. Its job still finishes; the worker drops an unclaimed result. A future surviving executor shutdown retains the completed result until retrieval or drop. Futures do not retain the executor and cannot prevent shutdown. Mutable results transfer exactly once.

An application Result remains nested: a worker returning Result[T, E] yields Future[Result[T, E]]. The first ? on result() handles cancellation; the next handles application failure. Retrieval, cancellation, status, and error transport allocate nothing. Timed observation does not cancel or consume work; see timeout rules.

Scoped Task.join() returns T directly because scoped tasks cannot be cancelled. Future.result() needs its outer Result to report cancellation; for an application Result, (future.result()?)? deliberately handles two different failure layers.

Result waits and live receivers

result() cannot wait for a locally submitted job holding Sender[T] while the calling function still holds Receiver[T]. A full queue could keep the job and the result wait blocked forever. End the receiver’s own guard before retrieving the result:

with ThreadPoolExecutor(1, 1)? as pool:
    future = pool.submit(produce, sender).drop_job()?
    with receiver as inbox:
        ...
    result = future.result()

A guard alongside the pool, with ThreadPoolExecutor(1, 1)? as pool, receiver as inbox:, disconnects only at pool shutdown, which is too late for a result wait inside the block. A drop(receiver) on every path to the wait also satisfies the rule. See the recommended patterns.

The diagnostic points to the wait, receiver holders, and submission. Tracking follows local future moves, ?, unwrap(), drop_job(), extraction from submission results, and calls that return a future. Separate futures with the same result type keep their own job origins. Receivers dropped on every continuing path are accepted. done(), cancel(), and wait_timeout_ms() are unaffected, and result retrieval still consumes the future and preserves both cancellation and application errors.

The same wait is rejected while the function holds a lock on a Mutex[T] and the job can reach a Mutex[T] of the same T, for example through a Shared[Mutex[T]] share: the job could be waiting for that lock. End the lock’s with block before waiting. Mutexes are matched by type.

This is a conservative check, not a proof of deadlock freedom: endpoints are matched by message type, not channel identity. Completion, cancellation, queue capacity, and earlier receives do not discharge the check. Assignments to the same local are conservatively combined.

A call that receives the future, by value or by reference, is checked as a wait at the call when a function it reaches, directly or through further calls, calls result() on a Future[T] of the same T. The diagnostic names the call, and finished(future) below is rejected:

def finished(future: Future[()]) -> bool:
    future.result().is_ok()

def main() -> Result[(), AllocError | PoolError | SubmitError[OnceClosure[[], ()]]]:
    sender, receiver = channel[i64](1)?
    with ThreadPoolExecutor(1, 1)? as pool:
        future = pool.submit(produce, sender)?
        ok = finished(future)   # rejected: `receiver` is still live
        drop(receiver)
    Ok(())

Matching by type means a callee that waits for a different Future[T] also counts. A call through a Callable, closure, or boxed callable value, or to a function that makes one, is assumed to wait.

A future returned from a call keeps its jobs. When the call’s result can hold a Future[T], directly or in a Result, Option, or other aggregate, it is assumed to hold every future of the same T that its arguments carry, and every job with a T result that the callee, or a function it reaches, submits. Both waits below are rejected like waits for a local submission:

def submitted[T, F](result: Result[Future[T], SubmitError[F]]) -> Result[Future[T], str]:
    match result:
        case Ok(future):
            Ok(future)
        case Err(_):
            Err("submission failed")

def start(pool: &ThreadPoolExecutor, sender: Sender[i64]) -> Result[Future[()], str]:
    submitted(pool.submit(produce, sender))

def main() -> Result[(), AllocError | PoolError | str]:
    with ThreadPoolExecutor(1, 1)? as pool:
        sender, receiver = channel[i64](1)?
        future = submitted(pool.submit(produce, sender))?
        ok = future.result().is_ok()   # rejected: `receiver` is still live
        other_sender, other = channel[i64](1)?
        later = start(&pool, other_sender)?
        done = later.result().is_ok()  # rejected: `other` is still live
        drop(receiver)
        drop(other)
    Ok(())

The result is assumed to hold those futures even when the callee drops or waits for one of them. A call through a value keeps only its arguments’ jobs: jobs submitted by a callable, closure, or boxed callable value, or by a function compiled elsewhere, are not seen. Provenance is not tracked through collections, class fields, or multi-field aggregate storage; indirect submissions and blocking mapping callbacks retain the executor limits.