''Review supported by Claude Code'' Control flow logic was scrutinised, and a race identified in the solution to pass on the display action to the GUI thread. Reconsidering the design, cross-thread synchronisation is now hinged rather on a single atomic buffer pointer, where the GUI thread notifies back completion of display work by setting that pointer to NULL. Overall this simplifies the control logic ---- As an aside, I setup an outline of topics for documentation in `DemoSpec.txt` and then instructed Claude to fill in the paragraphs, based on insight gained as part of the code validation / review. Furthermore, I requested to generate some diagrams that could be useful later when preparting the presentation
202 lines
11 KiB
Text
202 lines
11 KiB
Text
Specification for the Demo
|
|
==========================
|
|
:Date: Summer 2026
|
|
:Author: Ichthyostega <Ichthyostega@web.de>
|
|
:toc:
|
|
:toclevels: 3
|
|
|
|
This specification is the result of our research and provides a short summary of the relevant
|
|
aspects of io_uring. Furthermore, the specific design of the demo code is explained and motivated.
|
|
|
|
|
|
|
|
IO_URING kernel interface
|
|
-------------------------
|
|
`io_uring` is a Linux kernel interface for asynchronous I/O (available since kernel 5.1). It is
|
|
built around two ring buffers, shared and memory-mapped between user space and kernel: a
|
|
_Submission Queue_ (SQ) and a _Completion Queue_ (CQ). The application fills a _Submission Queue
|
|
Entry_ (SQE) describing an operation — read, write, timeout, accept, … — and notifies the kernel;
|
|
once the operation has run to completion, the kernel deposits a _Completion Queue Entry_ (CQE)
|
|
carrying the result. Submission and completion are thus decoupled: many operations can be in
|
|
flight concurrently, and — unlike the classic `read(2)`/`write(2)` model — no thread has to block
|
|
while an operation is pending, and no syscall is required per individual operation (a single
|
|
`io_uring_submit` hands over a whole batch, and with a polled queue even that can be elided).
|
|
|
|
For a media application this is attractive: playback and editing require moving large volumes of
|
|
data with predictable latency, ideally without dedicating a blocked thread to each stream. The
|
|
uniform, batchable async model of io_uring fits this need directly.
|
|
|
|
Liburing
|
|
~~~~~~~~
|
|
This helper library provides a thin wrapper to encapsulate most of the minute technical details
|
|
related to the ringbuffers. The kernel interface as such is defined in a rather indirect way,
|
|
so that most data definitions are exposed dynamically, at runtime in the form of relative field
|
|
offsets. Also the structure of the ringbuffers is not defined directly as a data type; rather
|
|
the well-known low-level C implementation of a index based ringbuffer is assumed like a coding
|
|
template. Working with such a structure requires solid knowledge in system programming, including
|
|
the proper usage of memory barriers. The Liburing handles all those tricky details behind a function
|
|
based interface, that should be much more familiar to most programmers.
|
|
|
|
To avoid a performance penalty due to this indirection and encapsulation, Liburing is defined
|
|
to a large extent in the form of _static inline functions_. A variant called `liburing-ffi` is provided
|
|
as alternative for cross-language bindings and for usage with older compilers, providing an definition
|
|
and binary symbol for each function; obviously using these explicit calls adds a noticeable penalty.
|
|
|
|
|
|
Reading of raw video as example of using IO_URING
|
|
-------------------------------------------------
|
|
Playing a raw video file is a minimal yet realistic streaming workload — a steady, latency-bound
|
|
sequence of large block reads that has to keep pace with a fixed frame beat — and therefore serves
|
|
well to demonstrate the decoupling of submission and completion.
|
|
|
|
The »Proactor« pattern
|
|
~~~~~~~~~~~~~~~~~~~~~~
|
|
It is instructive to contrast two patterns for event-driven I/O. In the *Reactor* pattern the
|
|
program waits for a resource to become _ready_ and then performs the operation itself, synchronously,
|
|
from within the event handler. In the *Proactor* pattern the operation is _initiated_ up front, the
|
|
operating system carries it out, and a handler is invoked on _completion_ — with the result already
|
|
available. io_uring is inherently proactor-shaped: we submit a read, the kernel performs it, and we
|
|
later receive a completion that carries the outcome.
|
|
|
|
The demo realises this with a single dedicated *»Proactor« thread* (`UringVideoPlayer::playVideo()`).
|
|
Its whole life is one loop around `io_uring_wait_cqe()`: block until the next completion arrives,
|
|
dispatch it to a handler based on a tag carried in the CQE `user_data`, then re-arm further work and
|
|
submit. Two kinds of completion are multiplexed onto the same ring and told apart by that tag:
|
|
|
|
* a periodic *frame-beat timer* (a multishot timeout, tagged with the sentinel `TICK_TAG`)
|
|
* *read completions* for individual video frames (tagged with the running frame number)
|
|
|
|
Overall design of the demo code
|
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
The code is deliberately split so that the io_uring logic stands apart from the display machinery:
|
|
|
|
`UringVideoPlayer` (demo-uring.cpp)::
|
|
The subject of this write-up. Owns the ring, the buffers and their state, and the Proactor thread.
|
|
|
|
`EglDisplay` (egl-display.hpp)::
|
|
A self-contained OpenGL/EGL display component, best read as a _black box_. It is reached only
|
|
through the small `PixelFeed` interface — `retrieveFrame()` to obtain the current frame's pixel
|
|
data and `completedFrame()` to signal that the data is no longer needed.
|
|
|
|
`GtkUringApp` (gtk-uring-app.hpp)::
|
|
A minimal GTK-3 host application providing the window, the start button and the shutdown hook.
|
|
|
|
Two threads are involved: the *GTK/GUI main thread* and the *Proactor thread*. The player is
|
|
constructed on the GUI thread and — following a RAII spirit — immediately launches the Proactor
|
|
thread from its constructor. Note that `proactorThread_` is declared _last_ among the members, so
|
|
by C++'s declaration-order construction rule all buffers, state and the display connection are fully
|
|
initialised before the thread can observe them. Data flows in one direction: the Proactor reads file
|
|
blocks into buffers and publishes a ready frame; the GUI thread uploads that frame into a GPU texture
|
|
and draws it.
|
|
|
|
.Overall data flow across the two threads, with the atomic baton `toDisplay_` between them
|
|
image::diagrams/thread-handoff.svg[Cross-thread data flow and visibility handoff,align="center"]
|
|
|
|
The problem of data visibility synchronisation
|
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
The two threads share the buffer array, which raises two questions of _cross-thread visibility_:
|
|
|
|
. once the Proactor announces a frame, the GUI thread must observe the buffer's contents _completely_
|
|
written, not a partially filled buffer;
|
|
. the Proactor must know when the GUI is _done_ with a buffer, so the slot may be reused safely.
|
|
|
|
Rather than making the entire state array atomic, or sharing cursor indices across the boundary, the
|
|
design funnels the whole cross-thread contract through a *single* atomic — `std::atomic<Buffer*>
|
|
toDisplay_` — which _is_ the payload rather than a key to look it up:
|
|
|
|
[cols="1,3"]
|
|
|===
|
|
| `toDisplay_ != nullptr` | a frame is currently lent to the GUI thread
|
|
| `toDisplay_ == nullptr` | the Proactor owns the slot again and may reuse it
|
|
|===
|
|
|
|
The Proactor publishes with a *release*-store; the GUI picks the pointer up with an *acquire*-load
|
|
and thereby is guaranteed to see the buffer contents written before the store. When finished, the
|
|
GUI writes `nullptr` with a *release*-store, which the Proactor observes with an *acquire*-load
|
|
before reclaiming the slot. This is the classic single-slot, single-producer/single-consumer
|
|
_mailbox_ handoff: the two release/acquire pairs establish the _happens-before_ relations that carry
|
|
buffer-content visibility in both directions. Everything else — the `BuffState` array and the frame
|
|
cursors — is confined to the Proactor thread alone and therefore needs no synchronisation at all.
|
|
|
|
Buffer data structure
|
|
~~~~~~~~~~~~~~~~~~~~~
|
|
A fixed set of `RING_SIZ` frame-sized buffers is held in a `unique_ptr<Buffer[]>` and used as a
|
|
_ring_: the slot for a frame is simply `frameNr % RING_SIZ`. Frame numbers themselves are monotonic,
|
|
unbounded counters — `nextRead_` for the production side and `nextShow_` for the consumption side —
|
|
so the modulo maps an ever-growing frame index onto the small cyclic set of buffers. The same running
|
|
frame number does triple duty: as the SQE `user_data` tag that later identifies the completed read, as
|
|
the key of the per-slot IO timer, and (via modulo) as the slot index. The io_uring queues are sized
|
|
`RING_SIZ+1`, the one extra entry accommodating the always-present timer event.
|
|
|
|
.Snapshot of the ring: the two cursors chase each other, the gap holds the in-flight frames
|
|
image::diagrams/ring-buffer.svg[Ring-buffer snapshot with production and consumption cursors,align="center"]
|
|
|
|
Tracking state of a buffer slot
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
Each slot carries a `BuffState` describing where in its life-cycle it currently is:
|
|
|
|
FREE -> READ -> FEED -> SHOW -> FREE (with STOP as terminal state)
|
|
|
|
[cols="1,4"]
|
|
|===
|
|
| `FREE` | available; `addNextReadSubmissions()` may submit a read into it
|
|
| `READ` | a read has been submitted and is in flight
|
|
| `FEED` | the read completed successfully; frame data is ready to be shown
|
|
| `SHOW` | the frame has been handed to the GUI thread (the single slot currently lent out)
|
|
| `STOP` | the read fell short (end-of-file); signals playback to stop
|
|
|===
|
|
|
|
Because this array is written and read _only_ from the Proactor thread, it is a plain, non-atomic
|
|
array. The `SHOW` marker records _which_ slot is presently on loan to the GUI, complementing the
|
|
`toDisplay_` baton which records _whether_ it has come back yet.
|
|
|
|
.The life-cycle of a single buffer slot (all transitions driven by the Proactor thread)
|
|
image::diagrams/state-engine.svg[Buffer-slot state engine,align="center"]
|
|
|
|
Control flow and backpressure
|
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
Production and consumption advance independently, each with its own cursor. Production
|
|
(`addNextReadSubmissions()`) greedily fills _every_ `FREE` slot ahead with read submissions, tagging
|
|
each with its frame number. Consumption happens once per timer tick in `maybeDisplayFrame()`: first
|
|
a completed frame is _reclaimed_, then the next frame is _published_ if its data has arrived.
|
|
|
|
The essential back-pressure is that the single show-cursor does not advance while a frame is still out
|
|
with the GUI (`toDisplay_` non-null). No new frame is published until the previous one has been
|
|
displayed and released, and — since reads only ever refill `FREE` slots — a lagging consumer naturally
|
|
throttles production. A frame whose read has not completed in time, or whose predecessor the GUI has
|
|
not yet released, simply _slips_ to the next tick.
|
|
|
|
[source,cpp]
|
|
--------------------------------------------------------------------------
|
|
void
|
|
UringVideoPlayer::maybeDisplayFrame()
|
|
{
|
|
// reclaim previously displayed buffer
|
|
if (SHOW == buffState_[nextShow_ % RING_SIZ] and
|
|
nullptr == toDisplay_.load (memory_order_acquire)) // <1>
|
|
{
|
|
buffState_[nextShow_ % RING_SIZ] = FREE;
|
|
++nextShow_; // <2>
|
|
}
|
|
|
|
switch (buffState_[nextShow_ % RING_SIZ])
|
|
{
|
|
case FEED: // <3>
|
|
buffState_[nextShow_ % RING_SIZ] = SHOW;
|
|
toDisplay_.store (& buffer_[nextShow_ % RING_SIZ]
|
|
, memory_order_release); // <4>
|
|
dispatchFrame_();
|
|
break;
|
|
case STOP:
|
|
playing_ = false;
|
|
break;
|
|
default: // <5>
|
|
std::cout << "WARNING: Frame slipped" << std::endl;
|
|
}
|
|
}
|
|
--------------------------------------------------------------------------
|
|
<1> reclaim only the slot lent to the GUI, and only once it cleared the baton
|
|
<2> advance the single show-cursor — this is where the back-pressure lives
|
|
<3> the frame data has arrived (`FEED`): hand it over
|
|
<4> the release-store publishes the buffer and makes its contents visible to the GUI
|
|
<5> `READ` (IO still pending) or `SHOW` (GUI not yet caught up) ⇒ the frame slips to the next tick
|