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. A simple video player was developed as demo code and explained in a https://programm.froscon.org/froscon2026/talk/d1635d41-1fac-4a43-909b-b8f07672c541/[Talk at FrOSCon 2026]. The following text gives a summary and motivates the specific design of the demo code. 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) * a _Completion Queue_ (CQ) Instead of invoking the Kernel directly to instruct I/O operation, the application rather fills one or several _Submission Queue Entries_ (SQE) that describe I/O operations -- read, write, timeout, accept,... -- and then notifies the kernel; once the operation has run to completion, the kernel deposits a _Completion Queue Entry_ (CQE) into the other queue, 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 or process has to _block_ while an operation is pending, and no syscall is required per individual operation. One single `io_uring_submit` hands over a whole batch.footnote:[ As an extension, the kernel can even _poll_ on the submission queue, so that any syscall can elided. This setup lends itself well for high-performance computing with lots of tiny I/O operation, but has the obvious downside that now the _kernel_ has to run a dedicated polling thread -- which in the typical use case means to dedicate one core entirely for the dispatch of operations. ] 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. Another aspect demonstrated in here is the need for _flow control,_ so that the consumer (in this case, the GUI display) is not flooded with the data being read. 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. While io_uring in itself is an abstraction and hides the actual I/O handling in the kernel, the form of the interface fits well into the Proactor pattern: a read operation is submitted, the kernel performs it, and a completion can later be received to follow-up with processing of the retrieved data. The demo realises this pattern 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-supply further work items 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_.footnote:[ In fact we re-used the demo code from last year's talk at FrOSCon(2025) ] , where various technologies were explored to display raw video frames, using the GPU for acceleration. ] 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 was handed over to the GPU (using OpenGL) and the buffer can thus be repurposed for the next frame. `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 pattern -- 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 the buffers, state and the display connection machinery are fully initialised before the thread can observe them. Data flows in one direction: the Proactor reads blocks of raw video data from the file into buffers and publishes a ready frame; the GUI thread uploads that frame into a GPU texture and draws it. 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 can be accessed by taking `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 double duty: as the SQE `user_data` tag that later identifies the completed read, and (via modulo) as the slot index. A similar addressing scheme is also employed to access a _state flag_, that is associated with each buffer slot. Notably the io_uring kernel queues are sized as `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::demo/ring-buffer.svg[Ring-buffer snapshot with production and co]nsumption cursors,align="center"] Tracking state of a buffer slot ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Each slot carries a `BuffState` describing where in its life-cycle it currently is: .Buffer-slot state engine (transitions driven by the Proactor thread) image::demo/state-engine.svg[State-engine for buffer slots,align="center"] [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 marks _whether_ the GUI has finished transferring the pixel data into the GPU for display. 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