wegen/FrOSCon26/research/DemoSpec.txt

203 lines
11 KiB
Text
Raw Normal View History

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