Design#
pw_multibuf: A buffer API optimized for zero-copy messaging
The MultiBuf type is designed to allow data to be read or written across multiple buffers with minimal to zero copying. This allows device I/O such as RPC, transfer, and sockets, to move data with reduced memory, CPU and latency.
Why are there two versions?#
A legacy version of the MultiBuf type encapsulated memory allocation for all buffers, did not provide support for layered views, and generally had higher overhead. We are in the process of migrating users to the newer version.
The remainder of this document refers to the newer, “v2” version.
Sequences of multiple buffers#
A MultiBuf instance manages a sequence of memory buffers using a pw::DynamicDeque of Entries. This deque stores metadata about each memory region, or “chunk” rather than the data itself. For each chunk, a series of entries tracks the memory address and different views into it.
The ownership of each memory buffer is tracked within its entry.
New memory Chunks can be added to the
sequence. The PushBack
methods append data to the end, while the Insert
methods allow data to be added at any position, including at the beginning or
even in the middle of an existing chunk. Data can be removed using the
Remove
methods, which extract a range of bytes into a new MultiBuf instance,
or the Discard
method, which simply frees the memory.
When inserting into a chunk or removing a portion of a chunk, the MultiBuf
instance splits the existing entry into two, with some restrictions. For
example, an owned chunk cannot be split by a Remove
operation that would
move part of it to a different MultiBuf instance, as this would create
ambiguous ownership.
These operations seamlessly integrate different memory management strategies by
handling various data types, including MultiBuf, pw::UniquePtr,
pw::SharedPtr, and ByteSpan
.
Virtual spans#
A MultiBuf instance presents a single, contiguous view of what may be multiple,
non-contiguous memory Chunks. This virtual
span can be traversed byte-by-byte using a ByteIterator
object, which
conforms to the std::random_access_iterator
concept. These iterators
automatically handle transitions between underlying memory chunks, making it
appear as a single sequence of bytes.
However, this abstraction has limitations. Byte-by-byte iteration across chunk
boundaries incurs a performance overhead, and the underlying memory is not
guaranteed to be contiguous, As a result, using iterators with standard
algorithms like std::copy
may be inefficient. Even worse, taking the address
of a dereferenced iterator to define a range can lead memory corruption!
For efficient bulk data transfer, the MultiBuf type provides CopyTo
and
CopyFrom
methods, which are optimized to handle the scatter-gather nature of
the buffer. The range of chunks can also be accessed using the Chunks
and
ConstChunks
methods. Each of these can provide ChunkIterator
objects
that can be used to iterate over the contiguous spans of memory.
For accessing smaller regions of data, such as protocol headers, the Get
and Visit
methods are preferred. These methods intelligently avoid data
copies. If the requested range of bytes happens to be contiguous in an
underlying chunk, they return a direct ByteSpan
view into that
memory. Only if the range spans multiple, non-contiguous chunks will the data be
copied into a user-provided buffer. This allows for efficient, zero-copy access
in the common case while still correctly handling fragmented data.
Infallible operation#
When adding Entries, a MultiBuf instance may need to increase the capacity of its internal deque, which in turn allocates memory. To avoid unpredictable failures in the middle of complex operations, the MultiBuf type separates the fallible allocation from the infallible modification.
This is achieved through a pattern of TryReserve...
methods. For example,
before adding a series of Chunks with the
PushBack
or Insert
methods, a caller can use the
TryReserveForPushBack
or TryReserveForInsert
methods. These attempt to
allocate the memory needed for a corrspnding call to the PushBack
or
Insert
methods. They return true
on success and false
on allocation
failure, without modifying the logical state of the MultiBuf instance. If they
succeed, the calls to PushBack
or Insert
are guaranteed to succeed.
Error handling may be skipped almost altogether if the maximum number of chunks
and Layers is known when creating a MultiBuf
instance. The TryReserveChunks
and TryReserveLayers
methods allow a
MultiBuf to pre-allocate all memory needed for its internal state, and then
simply use methods like Insert
and PushBack
infallibly.
Properties#
The BasicMultiBuf class template uses MultiBufProperty template parameters to define the capabilities of a MultiBuf interface. This creates a compile-time system for specifying behavior. The core properties are:
kConst
: The data within the buffer is read-only.kLayerable
: The buffer supports adding and removing hierarchical Layers of the data.kObservable
: The buffer can notify a registered pw::MultiBufObserver of changes.
GenericMultiBuf privately
inherits from all valid combinations of BasicMultiBuf<...kProperties>
. This
design allows any BasicMultiBuf
reference to be safely static_cast
to a
GenericMultiBuf
reference, which holds the actual state (the deque,
observer, etc.). This GenericMultiBuf
can in turn be cast to any other
compatible BasicMultiBuf
interface.
To create a concrete objects, use an
Instance templated on one of the
aliases of a specific BasicMultiBuf
specialization (e.g.,
pw::TrackedMultiBuf). The Instance
class wraps a
GenericMultiBuf
member.
A key feature of this design is seamless and safe convertibility. An
Instance
object or a BasicMultiBuf
reference can be implicitly or
explicitly converted to another BasicMultiBuf
type, as long as the
conversion is valid.
kConst#
The kConst property signifies that the
underlying byte data held by the MultiBuf type is immutable. When this property
is present, methods that would modify the data, such as the CopyFrom
or the
non-const operator[]
methods, are disabled at compile time.
It is important to distinguish this from an immutable structure. A
pw::ConstMultiBuf can still be structurally modified. Operations
like the Insert
, Remove
, PushBack
, or AddLayer
methods are still
permitted, as they only change the metadata that defines the sequence and view
of the Chunks, not the content of the memory
chunks themselves.
This property provides a guarantee of data integrity similar to
const
-correctness in C++. Any MultiBuf type that is not kConst
can be
safely and implicitly converted to its kConst
equivalent (e.g.,
pw::MultiBuf to pw::ConstMultiBuf). This allows
functions that only need to read data to accept a kConst
version, preventing
accidental modification, while callers can freely pass mutable buffers to them.
The reverse conversion, from kConst
to mutable, is disallowed.
kLayerable#
The kLayerable property enables a MultiBuf type to manage a stack of views, or Layers. Each layer represents a subspan of the layer beneath it, effectively creating a narrower, more specific view of the underlying memory without any data copying.
For example, a MultiBuf instance might initially represent a full Ethernet
frame. An Ethernet handler can process the header, then use the AddLayer
method with a given header size and payload size to create a new top layer that
exposes only the IP packet within the frame. This new view can then be passed to
an IP handler. The IP handler can, in turn, process its header and add another
layer to expose the TCP segment to the TCP handler.
This process is reversible. After the TCP handler is finished, the TCP layer
can be removed with the PopLayer
method, restoring the view to the IP
packet. This allows each protocol handler in a stack to operate on its relevant
payload in isolation, cleanly managing the boundaries between protocol data
without the overhead and complexity of copying data between intermediate
buffers.
kObservable#
A MultiBuf with the kObservable property can
have a pw::MultiBufObserver registered via the set_observer
method. This observer will be notified of structural changes to the buffer.
Whenever bytes or Layers are added or removed
(e.g., through the Insert
, Remove
, AddLayer
, PopLayer
, or
Clear
methods), the MultiBuf instance invokes the observer’s Notify
method, passing an event with a type like kBytesRemoved
and a corresponding
size.
This mechanism is useful for implementing asynchronous workflows and flow
control. For example, consider a system sending a large message contained in an
observable MultiBuf instance. The buffer could be passed to a transport layer
that sends the data in the background. The original sender can register an
observer that waits to be notified with a kBytesRemoved
event of the entire
message size. This notification would be triggered when the transport layer is
done sending the data and calls the Clear
or Discard
methods on the
buffer. This signals to the sender that the transmission is complete and the
associated memory has been freed.
This can be used to implement backpressure. A sender can be notified when memory is freed, indicating that the receiver has consumed the data and there is now capacity to send more, preventing the sender from overwhelming the receiver.