HandoffBuffer class
Shared memory for a value where only the newest copy matters - a rendered frame - handed between one writing isolate and one reading isolate with neither ever blocking the other.
Why not a RingBuffer, and why not a TripleBuffer
A RingBuffer keeps every record in order. That is right for commands,
where dropping the third of five is a bug. It is wrong for frames: the
instant a newer frame exists the older one is garbage, so a queue
faithfully preserves exactly what nobody wants - and being bounded, it
forces the reader to drain every tick whether it intends to draw or not,
or the writer starts failing.
A TripleBuffer is closer, but its writer cycles slots blindly:
0, 1, 2, 0, ... with no idea where the reader is. That gives the reader
two publishes of grace and then reuses its slot underneath it. Safe only
while the reader is reliably faster than two of the writer's frames, which
is an assumption about scheduling rather than a guarantee - and when it
breaks, it breaks silently, as wrong pixels.
The handoff
Two shared words remove the guessing. Each is written by exactly one side and read by the other, so neither needs more than an aligned word store:
- ready - "this slot is complete, you may read it". Written by the writer, read by the reader.
- reading - "I am holding this slot right now". Written by the reader, read by the writer.
The writer takes any slot that is neither ready nor reading. That is
the whole rule, and both halves matter: not reading, or it would scribble
under the reader; not ready, or it would overwrite the frame the reader
is entitled to pick up at any moment.
Why the reader says what it holds, not what it has released
An earlier version had the reader publish free - "you may write here" -
which sounds equivalent and is not. free only advances when the reader
reads, so the writer produced exactly one frame per read and then idled.
The frame waiting was therefore the one produced just after the previous
read, and by the time the reader came back it was a whole read-interval
stale. That is the opposite of what a renderer wants, and it defeated the
point of moving off the ring.
Saying what it holds instead lets the writer keep going: it always has a slot that is neither being read nor waiting to be read, so it replaces its own unread frame with a fresher one and the reader always collects the newest.
Three slots, and why two will not do
The reader holds one and the newest complete one is another, so the writer
needs a third to work in. With two, "neither ready nor reading" can be
the empty set and the writer has nowhere to go - which is exactly the
stalling behaviour above. Three is the minimum that lets the writer run
freely while a reader holds a slot for as long as it likes.
Claim, then confirm
The reader writes reading and then re-reads ready. The writer only ever
enters a slot after publishing a different one, so if ready has not
moved since the claim, the writer cannot be inside the slot just claimed.
Without that second read there is a window: the reader picks up ready,
the writer publishes elsewhere and then steps into the slot the reader was
about to claim.
Memory ordering
Every control word is a naturally-aligned 32-bit slot in one block, so each
store and load is atomic on every architecture Dart targets, and no
compare-and-swap is needed (stable dart:ffi exposes none anyway). What is
not here is an explicit fence: publish writes the payload, then the
used length, then ready last, and correctness relies on those retiring in
order as seen from the other isolate. That holds on the strongly-ordered
targets this engine runs on (x64/ARM64). Flagged rather than assumed away -
revisit with a real release-store if a weak-memory target ever matters.
Constructors
- HandoffBuffer(int slotBytes)
-
HandoffBuffer.fromAddresses({required int slotBytes, required int controlAddress, required List<
int> slotAddresses}) -
Rebuilds a view over a buffer the other isolate allocated. The
reconstructing side gets the same native memory at the same addresses -
see
Game's notes on what survivesIsolate.spawn.
Properties
- controlAddress → int
-
no setter
- hashCode → int
-
The hash code for this object.
no setterinherited
- hasPublished → bool
-
Whether anything has ever been published - for a reader that wants to
distinguish "no frame yet" from "no new frame".
no setter
- readUsedBytes → int
-
How many bytes of the slot from the last beginRead are real.
no setter
- runtimeType → Type
-
A representation of the runtime type of the object.
no setterinherited
-
slotAddresses
→ List<
int> -
no setter
- slotBytes → int
-
Capacity of one slot. A frame that would exceed it is the caller's
problem to bound - see
GameRenderer2D.maxSpritesPerTick.final
Methods
-
beginRead(
) → Pointer< Uint8> ? - Takes the newest complete slot and holds it, or null when nothing new has been published since the last call.
-
beginWrite(
) → Pointer< Uint8> ? - The slot to fill: any that is neither the newest complete one nor the one a reader is holding.
-
dispose(
) → void -
noSuchMethod(
Invocation invocation) → dynamic -
Invoked when a nonexistent method or property is accessed.
inherited
-
publish(
int usedBytes) → void - Marks the slot from the last beginWrite complete and readable.
-
toString(
) → String -
A string representation of this object.
inherited
Operators
-
operator ==(
Object other) → bool -
The equality operator.
inherited