|
Uni20
Asynchronous tensor and runtime library.
|
This document is the practical reference for ReadBuffer<T> and WriteBuffer<T>.
Use this when implementing kernels or reviewing coroutine correctness.
Async<T> has only two ordinary access capabilities:
ReadBuffer<T>: shared read access to one epochWriteBuffer<T>: exclusive mutable access to one epochWrite names the buffer's exclusive producer role in the epoch protocol; it does not mean write-only memory access. Once its epoch is active, a WriteBuffer<T> may inspect and mutate the existing value, construct an absent value, replace the value, or move the value out. An in-place operation therefore uses one WriteBuffer<T>, not a ReadBuffer<T> followed by a WriteBuffer<T> for the same timeline.
Types such as ReadMaybeAwaiter, OwningWriteAwaiter, and TakeAwaiter are temporary await-path adaptors. They change what an await returns or who retains the epoch handle across suspension. They are not additional buffer classes and do not create additional epochs.
The API has three layers:
| Layer | Examples | Role |
|---|---|---|
| access capability | ReadBuffer<T>, WriteBuffer<T> | selects an epoch and owns its reader/writer participation |
| await path | plain co_await, maybe(), or_cancel(), storage(), take() | chooses readiness, cancellation, ownership, or consumption behavior |
| access result | T const&, WriteAccessProxy<T>, owning proxies | exposes the value or storage after the epoch becomes ready |
The writer handle and its exception-sink registration belong to one selected epoch. Value access, storage access, and consuming the value are different ways to await that same handle, not different scheduling capabilities. Keeping one WriteBuffer<T> makes that identity explicit and avoids duplicating lifetime and exception-routing logic across several buffer types.
The write side can be read as this tree:
The awaiter classes are the short-lived objects between the expression on the left and the result on the right. Most application and kernel code should not name their concrete types.
For one EpochQueue, an ordinary operation should acquire either:
ReadBuffer<T> capabilities and no writer, orWriteBuffer<T> capability and no separate reader.Multiple reads are compatible. A writer is exclusive and also supplies the read access needed for mutation. A separate read and write on the same queue represent two different epochs; they are not a way to obtain ordinary read/write access simultaneously. Some low-level code deliberately performs such a two-epoch transition by copying what it needs and releasing the reader before awaiting the writer, but tensor kernels should not use that as their default mutation pattern.
ReadBuffer<T> owns reader participation in one selected epoch. The modifiers below choose different await paths through that same capability.
transfer() is the explicit named-buffer path to owning access| Form | Return type | Notes |
|---|---|---|
co_await reader | T const& | non-owning read access |
co_await reader.transfer() | OwningReadAccessProxy<T> | owning read access (get(), get_release(), release()) |
| Modifier | Return behavior | Typical use |
|---|---|---|
co_await reader.maybe() | T const* (nullable) | probe optional/cancelled paths without exception |
co_await reader.transfer().maybe() | std::optional<OwningReadAccessProxy<T>> | ownership-preserving optional read |
co_await reader.or_cancel() | T const& or throws task_cancelled | cancellation-aware borrowed read |
co_await reader.transfer().or_cancel() | OwningReadAccessProxy<T> or throws task_cancelled | cancellation-aware owning read |
reader.release() (or owning proxy release()) releases the reader gate before object destruction.
Use it to shorten a read lifetime after copying everything the coroutine needs. It is mandatory only for advanced code that deliberately transitions from a read epoch to a later write epoch on the same queue.
WriteBuffer<T> owns exclusive mutable participation in one write epoch. It does not imply that the stored T already exists: the write epoch may either mutate a constructed value or construct/replace the value.
co_await returns a non-owning write proxyco_await returns an owning write proxytransfer() is the explicit named-buffer path to owning accessFor an existing value, await the writer and use the returned proxy as exclusive read/write access:
Do not acquire a separate ReadBuffer for the same Async<T> merely to inspect the old value. For operations involving tensor aliases, either derive a safe view while holding the one writer, materialize an input, or reject the aliasing case according to that operation's contract.
| Form | Return type | Notes |
|---|---|---|
co_await writer | WriteAccessProxy<T> | non-owning; mutable value or read-only alias descriptor |
co_await writer.transfer() | OwningWriteAccessProxy<T> | owning; mutable value or read-only alias descriptor |
co_await writer.storage() | shared_storage<T>& | explicit storage control; values only |
co_await writer.transfer().storage() | OwningStorageAccessProxy<T> | owning storage access; values only |
co_await writer.take() | T | move out + destroy stored value; values only |
co_await writer.transfer().take() | T | move out + destroy stored value, then release writer; values only |
co_await writer.take_release() | T | move out + destroy stored value, then release writer; values only |
co_await writer.transfer().take_release() | T | owning shorthand for take + release; values only |
The named awaiter classes implementing these rows are transport objects. For example, writer.storage() creates a StorageAwaiter, while writer.transfer().storage() creates an OwningStorageAwaiter. User code normally names the returned storage proxy, not the awaiter itself.
The lvalue forms borrow the writer held by WriteBuffer<T>. The transfer() forms move that writer through the awaiter into an owning result, making the result independent of the original buffer object's lifetime.
For default Async<T>, first write is safe through proxy assignment:
This calls proxy operator=, which constructs the value when storage is empty.
When a value already exists, the same expression evaluates stored_value = source rather than destroying and reconstructing the object. The source must support both construction and that assignment expression, so the operation is valid in either storage state.
For more specific requirements, use an explicit operation:
proxy.emplace(args...) always reconstructs inside the current storage and queueproxy.get() = rhs requires an existing value and uses its assignment operatorDirect assignment to an independent Async<T> handle creates a fresh timeline. For an async alias, assignment is available only when ADL finds a matching assign_through operation and always retains the descriptor, owner, and queue.
WriteBuffer<T> is the exclusive epoch capability in both cases. Its proxy is capability-aware: value payloads expose mutable access, construction, and move-out operations; alias payloads expose the descriptor read-only and permit only supported write-through assignment.
Uni20 intentionally allows write-proxy += and -= to initialize unconstructed storage. This differs from normal C++ value semantics, but it is useful for async dataflow accumulation:
Think of this as initialize-or-accumulate semantics for write buffers, scoped to async epoch writes.
Independent-value write proxies support:
take(): move out and destroy current stored valuetake_release(): take() plus immediate writer releaserelease(): explicit early release when done writingemplace(...): explicit construction or reconstruction in the current timelineThis is available for both:
WriteAccessProxy<T> from co_await writerOwningWriteAccessProxy<T> from co_await writer.transfer()These are the write-side analogue of read-proxy get_release().
Alias write proxies support release(), read-only descriptor inspection, and assignment when ADL finds assign_through. They do not expose emplace, mutable descriptor access, storage access, arithmetic mutation, or move-out.
Bad first-write pattern:
Good first-write pattern:
In C++, unary co_await binds tighter than assignment, so:
means:
This is a convenient shorthand for:
In current Uni20 semantics this is also valid for first write to default-constructed Async<T>. The invalid pattern is binding a mutable reference before construction:
Use proxy assignment or proxy emplace(...) instead.
Repeated co_await on the same buffer object is supported and tested for:
maybe()or_cancel()storage()take()take_release()These repeated awaits all use the same epoch capability. They do not create new readers, writers, or queue entries. After transfer() or release(), the original buffer no longer owns an active capability and must not be reused.
Buffers can register exception sinks with the current coroutine promise.
WriteBuffer coroutine parameters are auto-registered as sinkspropagate_exceptions_to(...)This is how unhandled exceptions are forwarded to downstream epochs.
| Helper | Purpose |
|---|---|
all(a, b, ...) | await all inputs and return tuple of results |
try_await(x) | non-blocking readiness probe |
write_to(writer.transfer(), value) | concise deferred write helper |
try_await(...) never suspends. It probes the child awaiter's readiness once: an unavailable child produces an empty optional, while an available child has its await_resume() result wrapped in a populated optional. Exceptions from either readiness probing or result extraction propagate normally. A child passed by reference resumes as an lvalue; a child moved into try_await(...) resumes as an rvalue, and the returned optional owns value or rvalue-reference results.
all(...) is a join over registration-style awaiters. Each non-ready child must consume one parent ownership claim through await_suspend(BasicTask) and return void without throwing; its await_resume() must produce a value or reference for the result tuple. Children passed by reference resume as lvalues; children moved into the join resume as rvalues. The result tuple preserves lvalue references but owns value and rvalue-reference results, so it never returns references to temporary result objects. Concrete AsyncTask/CudaTask objects, transfer-returning awaiters, and nested all(...) groups are not operands. Waiting for multiple child tasks would require a separate task-completion join rather than the shared-parent ownership used here.
For different input and output timelines:
This shape is a good default: acquire inputs, compute, optionally release readers early, then write a distinct output timeline.
For mutation of one timeline, use only its writer:
An operation that receives both a reader and writer for the same queue needs a specific two-epoch algorithm and release point. Do not infer that pattern from the fact that ReadBuffer and WriteBuffer are both available.
examples/async/async_buffer_await_paths_example.cppruntime_model.mdexceptions_and_cancellation.mdquick_reference.md