Specification for the Demo ========================== :Date: Summer 2026 :Author: Ichthyostega :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 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` 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