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 Talk at FrOSCon 2026. The following text gives a summary and motivates the specific design of the demo code.
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.
[
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.
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.
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.
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)
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.
[
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 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:
|
a frame is currently lent to the GUI thread |
|
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.
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 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.
Each slot carries a BuffState describing where in its life-cycle it currently is:
|
available; |
|
a read has been submitted and is in flight |
|
the read completed successfully; frame data is ready to be shown |
|
the frame has been handed to the GUI thread (the single slot currently lent out) |
|
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.
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.
void UringVideoPlayer::maybeDisplayFrame() { // reclaim previously displayed buffer if (SHOW == buffState_[nextShow_ % RING_SIZ] and nullptr == toDisplay_.load (memory_order_acquire)) //{ buffState_[nextShow_ % RING_SIZ] = FREE; ++nextShow_; //
} switch (buffState_[nextShow_ % RING_SIZ]) { case FEED: //
buffState_[nextShow_ % RING_SIZ] = SHOW; toDisplay_.store (& buffer_[nextShow_ % RING_SIZ] , memory_order_release); //
dispatchFrame_(); break; case STOP: playing_ = false; break; default: //
std::cout << "WARNING: Frame slipped" << std::endl; } }
![]() | reclaim only the slot lent to the GUI, and only once it cleared the baton |
![]() | advance the single show-cursor — this is where the back-pressure lives |
![]() |
the frame data has arrived (FEED): hand it over
|
![]() | the release-store publishes the buffer and makes its contents visible to the GUI |
![]() |
READ (IO still pending) or SHOW (GUI not yet caught up) → the frame slips to the next tick
|