Cover image for io_uring

io_uring

Words 6.7k
Views
Visitors

Timeline

Timeline

2026-01-26

init

This article introduces the development history of Linux asynchronous I/O, explores the principles and functions of the high-performance asynchronous I/O framework io_uring, summarizes its core advantages such as unifying the asynchronous framework, achieving true asynchronous operation, and high scalability, and provides relevant program examples and performance benchmark results.

io_uringwas 2019 Linux 5.1 high-performance first introduced into the kernel asynchronous I/O framework, which can significantly accelerate the performance of I/O-intensive applications. But if your applicationis already using traditional Linux AIO,and uses it properly,, thenio_uringit will not bring much performance improvement, according to the original test, even with advanced features enabled, it is only 5%. Unless you really need this extra 5% performance, switchingtoio_uringmight also be quite costly, because you have to rewrite the applicationto adapt toio_uring(or let the dependent platform or framework adapt, in short, code changes are required).

Since the performance is similar to traditional AIO, why is it still calledio_uringa revolutionary technology?

  1. Its first and greatest contribution lies in:unifying the Linux asynchronous I/O framework
    • Linux AIO only supports direct I/O modestorage files (storage files), and is mainly used inthe niche field of databases
    • io_uringsupports storage files and network files (network sockets), and also supports more asynchronous system calls (accept/openat/stat/...), rather than being limited toread/writesystem calls.
  2. Init is truly asynchronous I/O by design, in contrast, although Linux AIO is also asynchronous, it can still block, and its behavior in some cases is unpredictable;
  3. Flexibility and scalabilityExcellent, even capable of being based onio_uringrewrite all system calls, whereas Linux AIO was not designed with scalability in mind.

eBPF can also be considered an asynchronous framework (event-driven), but it has no essential connection withio_uringThey belong to different subsystems, and there is an essential difference in their models:

  1. eBPF is transparent to users, requiring only a kernel upgrade (to a suitable version),and applications require no modifications
  2. io_uringprovidesnew system calls and userspace APIs, thereforerequires modifications to applications

This article introduces the development history of Linux asynchronous I/O,io_uringits principles and functions, and provides someprogram examplesandperformance benchmarksresults.

Evolution of Linux I/O System Calls

fd-based blocking I/O:read()/write()

As the most familiar read/write method, the Linux kernel providesfile descriptor-based system calls, which may point tostorage files(storage files), or they could be network sockets

1
2
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

Both are referred to asblocking system calls(blocking system calls), because when a program calls these functions, it enters a sleep state and is then scheduled out (yielding the processor) until the I/O operation is complete:

  • If the data is in a file, and the file contentis already cached in the page cachethe call willreturns immediately
  • If the data is on another machine, it needs to be fetched over the network (e.g., TCP), which will block for a while;
  • If the data is on a hard drive, it will also block for a while.

But it is easy to imagine that as storagedevices get faster and programs get more complex, the simplest approach, blocking, is no longer applicable.

Non-blocking I/O:select()/poll()/epoll()

After blocking, some new, non-blocking system calls emerged, such asselect()poll()and the newerepoll(). When an application calls these functions to read and write, it does not block, but insteadreturns immediatelyreturning a list of ready file descriptors

epoll
epoll

select()

  • Cross-platform(supported by Linux, Windows, macOS)
  • Maximum number of fds is limited: usuallyFD_SETSIZE = 1024
  • Uses a bitmap (fd_set) to represent the set of fds to listen to
  • Each call requires:
    • Copy the fd_set from user space to kernel space;
    • The kernel traverses all fds to check their status;
    • Upon return, copy the entire fd_set back to user space;
    • The user needs to traverse all fds to determine which are ready.
  • Only supports Level Triggered (LT)
  • Disadvantages
    • Low upper limit on the number of fds;
    • Each call requires full copy + full polling → O(n) time complexity;
    • Cumbersome programming (requires maintaining three fd_sets: read, write, exception)
1
2
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);

poll()

  • Linux/Unix supportedWindows not supported
  • Usestruct pollfdArray replaces bitmap, breaking the 1024 limit (only limited by system ulimit)
  • Still requires passing the entire array to the kernel each time, the kernel traverses and checks, and the user still needs to traverse to find ready fds after returning
  • Still an O(n) polling model
  • Only supports LT mode
1
2
3
4
5
6
7
struct pollfd {
int fd; // file descriptor
short events; // Events of interest (POLLIN, POLLOUT)
short revents; // Actual events that occurred (filled in by the kernel)
};

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

✅ Advantage: No 1024 limit
❌ Disadvantage: Performance is still worse than epoll (under large-scale connections)

epoll()

  • Linux specific, not cross-platform

  • Based on event-driven + kernel callback mechanism

  • Three core functions

    1
    2
    3
    int epoll_create(int size);          // Create epoll instance (epoll_create1 is more commonly used now)
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // Register/modify/delete fd
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); // Wait for events
  • Kernel data structures

    • Red-black tree: Stores all registered fds (insertion, deletion, modification O(log n))
    • Ready doubly linked list: When an fd is ready, the kernel automatically adds it to the list
  • epoll_wait()Directly returns the list of ready events, no need to traverse all fds → O(1) to get ready events

  • Supports two trigger modes

    • LT(Level Triggered): Default mode, continuously notifies as long as there is data in the buffer (similar to poll)
    • ET(Edge Triggered): Notifies only once on state change (e.g., from unreadable to readable), requires non-blocking I/O, read all data at once

✅ Advantages:

  • Efficiently handles Tens of thousands or even millions of concurrent connections(e.g., Nginx, Redis, Kafka)
  • No hard limit on the number of fds
  • Less memory copying (passed only once at registration, only returns ready items when an event occurs)

❌ Disadvantages:

  • Linux only
  • ET mode programming is complex (easy to miss reading data)

epollMonitoring a regular file fd Makes no sense——It will continuously trigger events, but cannot achieve the purpose of “asynchronously notifying the arrival of data”.

But this method has a fatal flaw:Only supports network sockets and pipes —— epoll() doesn’t even support storage files.

Thread pool approach

For storage I/O, the classic solution is thread pool: The main thread distributes I/O to worker threads, which perform blocking reads and writes on behalf of the main thread, so the main thread is not blocked.

thread pool
thread pool

The problem with this approach isThread context switching overhead can be very high, as we will see in the performance benchmarks later.

Direct I/O (database software): bypassing the page cache

Then came a more flexible and powerful approach:Database software(database software) sometimes does not want to use the OS page cache, but instead wants to, after opening a file,read and write this file directly from the device(direct access to the device). This approach is calledDirect Access(direct access) orDirect I/O(direct I/O),

  • requires specifying O_DIRECT flag;
  • RequiresThe application manages its own cache —— which is exactly what database software wants;
  • is zero-copy I/O, because the application’s buffered data is sent directly to the device, or read directly from the device.

Asynchronous I/O (AIO)

As mentioned earlier, as storage devices get faster, the proportion of context switching overhead between the main thread and worker threads gets higher. Some devices on the market now, such as Intel Optane,have latencies as low as context switching(microsecondsus). To put it another way, which makes us feel this overhead more: Every time a context switch occurs, we lose one opportunity to dispatch I/O

dispatch I/O refers tosubmitting (initiating) an I/O requestto a storage device.

In high-performance systems (such as databases, KV stores), the goal isto concurrently dispatch as many I/Os as possible, keeping the device fully utilized (because the device supports high queue depths, e.g., NVMe supports thousands of concurrent commands).

Therefore, the Linux 2.6 kernel introduced an asynchronous I/O interface, which for convenience is abbreviated in this article aslinux-aio。AIO Principleis very simple:

  • The user, throughio_submit()submits I/O requests,
  • and after a while, callsio_getevents()to check which events are ready.
  • This allows programmersto write completely asynchronous code

Recently, Linux AIO even supportsepoll(): that is, it can submit not only storage I/O requests but also network I/O requests. Going down this path, linux-aio seems like it could become a king. But due to its poor evolutionary path, this wish is almost impossible to realize. We can get a glimpse of this from Linus’s signature fiery rhetoric

Reply to: to support opening files asynchronously

So I think this is ridiculously ugly.

AIO is a horrible ad-hoc design, with the main excuse being “other, less gifted people, made that design, and we are implementing it for compatibility because database people — who seldom have any shred of taste — actually use it”.

— Linus Torvalds (on lwn.net)

Linux AIO is indeed plagued with problems:

  1. only supportsO_DIRECTFile, thereforefor regular non-database applications (normal, non-database applications)it is almost useless
  2. The interface wasnot designed with extensibility in mind. Although it can be extended — and indeed it was — adding each new feature is quite complex;
  3. While technically,**the interface is non-blocking,**there are actually many possible reasons that can cause it to block, and in unpredictable ways.

Summary

The above clearly shows the evolution of Linux I/O:

  • Initially, there were synchronous (blocking) system calls;
  • Then, with**actual needs and specific scenarios,**new asynchronous interfaces were continuously added, while maintaining compatibility and interoperability with the old interfaces.

It can also be seen that, regarding the issue of non-blocking reads and writes,no unified solution has been formed.

  1. Network socket domain: add an asynchronous interface, and then poll to see if the request is complete (readiness);
  2. Storage I/O domain:only targeting a specific niche(database) needs at a specific period, a customized asynchronous interface was added.

This is the evolution history of Linux I/O — only focusing on the present, introducing a design whenever a problem arises, without much foresight — untilio_uringits emergence.

io_uring

io_uring came from the idea of senior kernel developer Jens Axboe, who has deep expertise in the Linux I/O stack. From the earliestpatch aio: support for IO pollingit can be seen that this work started with a very simple observation: as devices get faster and faster, the interrupt-driven mode is already less efficient than the polling modepolling for completions) — which is also one of the most common themes in the high-performance field.

  • io_uringofThe basic logic is similar to linux-aio: It provides two interfaces, one for submitting I/O requests to the kernel, and one for receiving completion events from the kernel.
  • But as development deepened, it gradually became a completely different interface: the designers started thinking from the source how to support fully asynchronous operations

Differences from Linux AIO

io_uringandlinux-aioThere is an essential difference:

  1. It is truly asynchronous in design(truly asynchronous). As long as the appropriate flag is set, itin the system call context just puts the request into the queue, and does nothing else,ensuring the application never blocks

  2. Supports any type of I/O: cached files, direct-access files, and even blocking sockets.

    Due to its async-by-design nature,there is no need for poll+read/write to handle sockets. Just submit a blocking read, and after the request is completed, it will appear in the completion ring.

  3. Flexible and scalable: Based onio_uringit can even re-implement every Linux system call.

Principle and core data structures: SQ/CQ/SQE/CQE

Each io_uring instance hastwo ring buffers(ring), shared between the kernel and the application:

  • Submission Queue (SQ):submission queue (SQ)
  • Completion Queue (CQ):completion queue (CQ)

io_uring
io_uring

These two queues:

  • are bothsingle-producer, single-consumer, with a size that is a power of two;
  • Provideslock-free interface(lock-less access interface), internally using memory barriersfor synchronization (coordinated with memory barriers).

Usage

  • request
    • The application creates SQ entries (SQE) and updates the SQ tail;
    • The kernel consumes SQE and updates the SQ head.
  • Completion
    • The kernel creates CQ entries (CQE) for one or more completed requests and updates the CQ tail;
    • The application consumes CQE and updates the CQ head.
    • Completion events may arrive in any order, but are always associated with a specific SQE.
    • The process of consuming CQE does not require switching to kernel mode.

Benefits

io_uringAnother benefit of this request method is that what originally required multiple system calls (read or write) is now batched into a single submission.io_uringThis batching capability brings to system calls beyond storage I/O other system calls, including:

  • read
  • write
  • send
  • recv
  • accept
  • openat
  • stat
  • dedicated system calls, such asfallocate

Furthermore,io_uringmakes the use cases of asynchronous I/O no longer limited to database applications,ordinary non-database applications can also use it. This point is worth repeating:

Whileio_uringandaiothere are some similarities, itsscalability and architecture are revolutionary: Itbrings the power of asynchronous operations to all applications(and their developers), no longer limited to the niche field of database applications

Avi Kivity at the Core C++ 2019 event gave a talk on async. The key points include:In terms of latency,

  1. modern multi-core, multi-CPU devices are essentially a basic network internally;
  2. between CPUsis another network;
  3. between the CPU and disk I/Ois yet another network.

Therefore, it is wise to adopt asynchronous programming for network programming, and now developing your own applications should also consider asynchrony. Thisfundamentally changes the way Linux applications are designed.

  • Previously, it was a sequential code flow, executing system calls only when needed,
  • now one needs to think about whether a file is ready, thus naturally introducing an event-loop, continuously submitting requests and receiving results through shared buffers.

Three operating modes

io_uring instances can operate in three modes:

  1. Interrupt-driven mode(interrupt driven)

    Default mode. Via io_uring_enter() submits an I/O request, and then directly checks the CQ status to determine if it is complete.

  2. Polling mode(polled)

    Busy-waiting for an I/O completion, instead of receiving notifications via asynchronous IRQ (Interrupt Request).

    This mode requires the file system (if any) and block device to support the polling feature. Compared to the interrupt-driven approach, this method has lower latency (even saving system calls), but may consume more CPU resources.

    Currently, only file descriptors opened with the specifiedO_DIRECTflag can use this mode. After a read or write request is submitted to the polled context, the application must callio_uring_enter()to poll the CQ queue to determine if the request has completed.

    For an io_uring instance,mixed use of polling and non-polling modes is not supported

  3. Kernel polling mode(kernel polled)

    In this mode, a kernel thread is created(kernel thread) to perform the SQ polling work.

    For an io_uring instance using this mode, the application does not need to enter kernel mode to issue I/O operations. By submitting SQEs through the SQ and monitoring the completion status of the CQ, the application can submit and reap I/Os without any system calls.

    If the kernel thread’s idle time exceeds the user-configured value, it will notify the application and then enter the idle state. In this case, the application must callio_uring_enter()to wake up the kernel thread. If the I/O is consistently busy, the kernel thread will not sleep.

io_uring system call API

There are three:

  • io_uring_setup(2)
  • io_uring_register(2)
  • io_uring_enter(2)

They are introduced in detail below. For complete documentation, see manpage

io_uring_setup()

Executing asynchronous I/O requires firstsetting up the context

1
int io_uring_setup(u32 entries, struct io_uring_params *p);

This system call

  • creates an SQ and a CQ
  • queue size at leastentrieselements,
  • returns a file descriptor, which is subsequently used to perform operations on this io_uring instance.

SQ and CQ are shared between the application and the kernel, avoiding data copying when initiating and completing I/O.

For the parameter p:

  • For the application, it is used to configure io_uring,
  • and the SQ/CQ configuration information returned by the kernel is also brought back through it.

io_uring_setup()On success, it returns a file descriptor (fd). The application can subsequently pass this fd to the mmap(2) system call to map the submission and completion queues, or pass it toio_uring_register()orio_uring_enter()system calls.

io_uring_register()

Register for asynchronous I/Ofiles or user buffers(files or user buffers):

1
int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args);

Register files or user buffers, allowing the kernel tohold references to the internal kernel data structures associated with the files for a long time(internal kernel data structures associated with the files), or createlong-term mappings of application memory(long-term mappings of application memory associated with the buffers). This operation is performed only once at registration time, rather than being processed for every I/O request, thereby reducing per-I/O overhead.

Properties of registered buffers

  • Registered buffers willbe locked in memory(be locked in memory), andcounted against the user’s RLIMIT_MEMLOCK resource limit.
  • Additionally, each buffer has a 1GB size limit
  • Currently, buffers must beanonymous, non-file-backed memory(anonymous, non-file-backed memory), for example, memory returned by malloc(3) or mmap(2) with the MAP_ANONYMOUS flag set.
  • Huge pages are also supported. The entire huge page will be pinned to the kernel, even if only a portion of it is used.
  • Registered buffers cannot be resized. To resize, you must first unregister and then register a new one.

Through theeventfd()Subscribe to completion events

can useeventfd(2)Subscribe to completion events of an io_uring instance. Just register the eventfd descriptor through this system call.

The credentials of the running application can be registered with io_uring which returns an id associated with those credentials. Applications wishing to share a ring between separate users/processes can pass in this credential id in the SQE personality field. If set, that particular SQE will be issued with these credentials.

io_uring_enter()

1
int io_uring_enter(unsigned int fd, unsigned int to_submit, unsigned int min_complete, unsigned int flags, sigset_t *sig);

This system call is used to initiate and complete I/O, using shared SQ and CQ. A single call simultaneously performs:

  1. Submit new I/O requests
  2. Wait for I/O completion

Parameters:

  1. fdisio_uring_setup()The returned file descriptor;
  2. to_submitSpecifies the number of I/Os submitted in the SQ;
  3. Depending on the mode:
    • Default mode, if specifiedmin_complete, it will wait for this number of I/O events to complete before returning;
    • If io_uring is in polling mode, this parameter indicates:
      • 0: Request the kernel to return all currently completed events without blocking;
      • Non-zero: If events are completed, the kernel still returns immediately; if no events are completed, the kernel will poll, waiting for the specified number of completions or until the process’s time slice runs out.

Note: For interrupt-driven I/O,Applications can check CQ event completions without entering the kernel

io_uring_enter()Supports many operations, including:

  • Open, close, and stat files
  • Read and write into multiple buffers or pre-mapped buffers
  • Socket I/O operations
  • Synchronize file state
  • Asynchronously monitor a set of file descriptors
  • Create a timeout linked to a specific operation in the ring
  • Attempt to cancel an operation that is currently in flight
  • Create I/O chains
  • Ordered execution within a chain
  • Parallel execution of multiple chains

When this system call returns, it indicates that a certain number of SQEs have been consumed and submitted, and it is now safe to reuse the SQEs in the queue. At this point, the I/O submission may still be in an asynchronous context, meaning the SQE might not actually have been submitted yet — however, users do not need to worry about these details — by the time the kernel subsequently needs to use a specific SQE, it has already made a copy.

Advanced features

io_uringProvides some advanced features for special scenarios:

  1. File registration(File registration): Every time an operation on a specified file descriptor is initiated, the kernel needs tospend some clock cycles(cycles)map the file descriptor to an internal representation. For thoseperforming repeated operations on the same filescenarios,io_uringSupportsregistering these files in advance, and then just look them up later.
  2. Buffer registration(Buffer registration): Similar to file registration, in direct I/O scenarios, the kernel needs to map/unmap memory areas.io_uringSupports registering these buffers in advance.
  3. Poll ring(Polling ring buffer): For very fast devices, the overhead of handling interrupts is relatively large.io_uringAllows users to disable interrupts and use polling mode. The previous “Three Working Modes” section also introduced this point.
  4. Linked operations(Linked operations): Allows users to send linked requests. These two requests are submitted simultaneously, but the latter will wait for the former to finish processing before starting execution.

Userspace libraryliburing

liburing provides a simple high-level API that can be used for some basic scenarios, allowing applications to avoid directly using lower-level system calls. In addition, this API also avoids some repetitive code, such as setting up io_uring instances.

For example, in theio_uring_setup()manpage description, after calling this system call to obtain a ring file descriptor, the application must callmmap()such logic requires a slightly longer piece of code, whereas usingliburing, the following function has already encapsulated the above process:

1
int io_uring_queue_init(unsigned entries, struct io_uring *ring, unsigned flags);

The next section will look at two examples based on liburing.

Example application based on liburing

Compile:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ git clone https://github.com/axboe/liburing.git
$ git checkout -b liburing-2.0 tags/liburing-2.0

$ cd liburing
$ ls examples/
io_uring-cp io_uring-cp.c io_uring-test io_uring-test.c link-cp link-cp.c Makefile ucontext-cp ucontext-cp.c

$ make -j4

$ ./examples/io_uring-test <file>
Submitted=4, completed=4, bytes=16384

$ ./examples/link-cp <in-file> <out-file>

io_uring-test

This program uses 4 SQEs to read from the input fileup to 16KB of data

Source code and comments

To make it easier to see the main logic, some error handling code has been omitted. For the complete code, see io_uring-test.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/* SPDX-License-Identifier: MIT */
/*
* Simple app that demonstrates how to setup an io_uring interface,
* submit and complete IO against it, and then tear it down.
*
* gcc -Wall -O2 -D_GNU_SOURCE -o io_uring-test io_uring-test.c -luring
*/
#include "liburing.h"

#define QD 4 // io_uring queue length

int main(int argc, char *argv[]) {
int i, fd, pending, done;
void *buf;

// 1. Initialize an io_uring instance
struct io_uring ring;
ret = io_uring_queue_init(QD, // Queue length
&ring, // io_uring instance
0); // flags, 0 means default configuration, e.g., using interrupt-driven mode

// 2. Open the input file, note that the O_DIRECT flag is specified here, which is required for kernel polling mode, as introduced earlier
fd = open(argv[1], O_RDONLY | O_DIRECT);
struct stat sb;
fstat(fd, &sb); // Get file information, such as file length, which will be used later

// 3. Initialize 4 read buffers
ssize_t fsize = 0; // Maximum read length of the program
struct iovec *iovecs = calloc(QD, sizeof(struct iovec));
for (i = 0; i < QD; i++) {
if (posix_memalign(&buf, 4096, 4096))
return 1;
iovecs[i].iov_base = buf; // Start address
iovecs[i].iov_len = 4096; // Buffer size
fsize += 4096;
}

// 4. Prepare 4 SQE read requests in sequence, specifying that the subsequently read data will be written to iovecs
struct io_uring_sqe *sqe;
offset = 0;
i = 0;
do {
sqe = io_uring_get_sqe(&ring); // Get an available SQE
io_uring_prep_readv(sqe, // Use this SQE to prepare a read operation to be submitted
fd, // Read data from the file opened by fd
&iovecs[i], // iovec address, the read data is written to the iovec buffer
1, // Number of iovecs
offset); // Starting address offset of the read operation
offset += iovecs[i].iov_len; // Update the offset for next use
i++;

if (offset > sb.st_size) // If it exceeds the file size, stop preparing subsequent SQEs
break;
} while (1);

// 5. Submit SQE read requests
ret = io_uring_submit(&ring); // Submit 4 SQEs at once, return the number of successfully submitted SQEs
if (ret < 0) {
fprintf(stderr, "io_uring_submit: %s\n", strerror(-ret));
return 1;
} else if (ret != i) {
fprintf(stderr, "io_uring_submit submitted less %d\n", ret);
return 1;
}

// 6. Wait for the read request to complete (CQE)
struct io_uring_cqe *cqe;
done = 0;
pending = ret;
fsize = 0;
for (i = 0; i < pending; i++) {
io_uring_wait_cqe(&ring, &cqe); // Wait for the system to return a read completion event
done++;

if (cqe->res != 4096 && cqe->res + fsize != sb.st_size) {
fprintf(stderr, "ret=%d, wanted 4096\n", cqe->res);
}

fsize += cqe->res;
io_uring_cqe_seen(&ring, cqe); // Update the completion queue of the io_uring instance
}

// 7. Print statistics
printf("Submitted=%d, completed=%d, bytes=%lu\n", pending, done, (unsigned long) fsize);

// 8. Cleanup
close(fd);
io_uring_queue_exit(&ring);
return 0;
}

Other notes

Comments have been added to the code, here are a few more explanations:

  • Each SQE executes an allocated buffer, the latter is usediovecStructural description;
  • Step 3 & 4: Initialize all SQEs for the upcomingIORING_OP_READVoperations, which providesreadv(2)an asynchronous interface for system calls.
  • After the operation completes, the SQE iovec buffer stores the relevantreadvresult of the operation;
  • Next, callio_uring_wait_cqe()to reap the CQE, and via thecqe->resfield, verify the number of bytes read;
  • io_uring_cqe_seen()notify the kernel that this CQE has been consumed.

link-cp uses the advanced io_uring feature SQE chaining to copy files.

I/O chain

io_uring supports creating I/O chains. I/Os within a chain are executed sequentially, while multiple I/O chains can be executed in parallel.

io_uring_enter()In the manpage, theIOSQE_IO_LINKhas Detailed explanation

When this flag is specified, it forms a link with the next SQE in the submission ring. That next SQE will not be started before this one completes. This, in effect, forms a chain of SQEs, which can be arbitrarily long. The tail of the chain is denoted by the first SQE that does not have this flag set. This flag has no effect on previous SQE submissions, nor does it impact SQEs that are outside of the chain tail. This means that multiple chains can be executing in parallel, or chains and individual SQEs. Only members inside the chain are serialized. A chain of SQEs will be broken, if any request in that chain ends in error. io_uring considers any unexpected result an error. This means that, eg, a short read will also terminate the remainder of the chain. If a chain of SQE links is broken, the remaining unstarted part of the chain will be terminated and completed with -ECANCELED as the error code. Available since 5.3.

To implement the file copying feature, link-cp creates an SQE chain of length 2.

  • The first SQE is a read request, reading data from the input file into the buffer;
  • the second request, linked to the first, is a write request, writing data from the buffer to the output file.

Source code and comments

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
/* SPDX-License-Identifier: MIT */
/*
* Very basic proof-of-concept for doing a copy with linked SQEs. Needs a
* bit of error handling and short read love.
*/
#include "liburing.h"

#define QD 64 // io_uring queue length
#define BS (32*1024)

struct io_data {
size_t offset;
int index;
struct iovec iov;
};

static int infd, outfd;
static unsigned inflight;

// Create a read->write SQE chain
static void queue_rw_pair(struct io_uring *ring, off_t size, off_t offset) {
struct io_uring_sqe *sqe;
struct io_data *data;
void *ptr;

ptr = malloc(size + sizeof(*data));
data = ptr + size;
data->index = 0;
data->offset = offset;
data->iov.iov_base = ptr;
data->iov.iov_len = size;

sqe = io_uring_get_sqe(ring); // Get an available SQE
io_uring_prep_readv(sqe, infd, &data->iov, 1, offset); // Prepare read request
sqe->flags |= IOSQE_IO_LINK; // Set to LINK mode
io_uring_sqe_set_data(sqe, data); // Set data

sqe = io_uring_get_sqe(ring); // Get another available SQE
io_uring_prep_writev(sqe, outfd, &data->iov, 1, offset); // Prepare write request
io_uring_sqe_set_data(sqe, data); // Set data
}

// Handle completion event: release SQE memory buffer, notify kernel that CQE has been consumed.
static int handle_cqe(struct io_uring *ring, struct io_uring_cqe *cqe) {
struct io_data *data = io_uring_cqe_get_data(cqe); // Get CQE
data->index++;

if (cqe->res < 0) {
if (cqe->res == -ECANCELED) {
queue_rw_pair(ring, BS, data->offset);
inflight += 2;
} else {
printf("cqe error: %s\n", strerror(cqe->res));
ret = 1;
}
}

if (data->index == 2) { // read->write chain completed, release buffer memory
void *ptr = (void *) data - data->iov.iov_len;
free(ptr);
}

io_uring_cqe_seen(ring, cqe); // Notify kernel that CQE event has been consumed
return ret;
}

static int copy_file(struct io_uring *ring, off_t insize) {
struct io_uring_cqe *cqe;
size_t this_size;
off_t offset;

offset = 0;
while (insize) { // Data not yet fully processed
int has_inflight = inflight; // Current number of in-flight SQEs
int depth; // SQE threshold; when the current number of in-flight SQEs exceeds this value, block and wait for CQE completion

while (insize && inflight < QD) { // Data not yet fully processed, io_uring queue not yet exhausted
this_size = BS;
if (this_size > insize) // Last segment of data is smaller than BS size
this_size = insize;

queue_rw_pair(ring, this_size, offset); // Create a read->write chain, occupying two SQEs
offset += this_size;
insize -= this_size;
inflight += 2; // Number of in-flight SQEs +2
}

if (has_inflight != inflight) // If there are newly created SQEs,
io_uring_submit(ring); // submit them to the kernel

if (insize) // If there is still data waiting to be processed,
depth = QD; // Threshold set to SQ queue length, meaning block and wait for CQE only when the SQ queue is exhausted;
else // All data processing has been submitted,
depth = 1; // Threshold set to 1, meaning block and wait for CQE as long as there are unfinished SQEs

// The following while loop will only be executed after the SQ queue is exhausted or all data has been submitted
while (inflight >= depth) { // If all SQEs have been used up, or all data read->write requests have been submitted
io_uring_wait_cqe(ring, &cqe);// Wait for kernel completion event
handle_cqe(ring, cqe); // Handle completion event: release SQE memory buffer, notify kernel that CQE has been consumed
inflight--; // Number of in-flight SQEs -1
}
}

return 0;
}

static int setup_context(unsigned entries, struct io_uring *ring) {
io_uring_queue_init(entries, ring, 0);
return 0;
}

static int get_file_size(int fd, off_t *size) {
struct stat st;

if (fstat(fd, &st) < 0)
return -1;
if (S_ISREG(st.st_mode)) {
*size = st.st_size;
return 0;
} else if (S_ISBLK(st.st_mode)) {
unsigned long long bytes;

if (ioctl(fd, BLKGETSIZE64, &bytes) != 0)
return -1;

*size = bytes;
return 0;
}

return -1;
}

int main(int argc, char *argv[]) {
struct io_uring ring;
off_t insize;
int ret;

infd = open(argv[1], O_RDONLY);
outfd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);

if (setup_context(QD, &ring))
return 1;
if (get_file_size(infd, &insize))
return 1;

ret = copy_file(&ring, insize);

close(infd);
close(outfd);
io_uring_queue_exit(&ring);
return ret;
}

Other notes

Three functions are implemented in the code:

  1. copy_file(): high-level copy loop logic; it will callqueue_rw_pair(ring, this_size, offset)to construct the SQE pair; and through a singleio_uring_submit()call, submit all constructed SQE pairs.

    This function maintains a maximum DQ number of inflight SQEs as long as the data copy is still in progress; otherwise, once all data has been read, it starts waiting and reaping all CQEs.

  2. queue_rw_pair()Construct a read-write SQE pair.

    The read SQE’sIOSQE_IO_LINKflag indicates the start of a chain; the write SQE does not need to set this flag, marking the end of this chain. The user data field is set to the same data descriptor, which will be used in the subsequent completion processing.

  3. handle_cqe()Extract the data descriptor previously saved by thequeue_rw_pair()from the CQE, and record the processing progress (index) in the descriptor.

    If the previous request was canceled, it will also resubmit the read-write pair.

    After both members of a CQE pair have been processed (index==2), the shared data descriptor is released. Finally, it notifies the kernel that this CQE has been consumed.

io_uring performance benchmark (based on fio)

For applications already using linux-aio, such as ScyllaDB, do not expect a significant performance improvement after switching to io_uring, this is because:io_uringThe underlying performance-related mechanisms are not fundamentally different fromlinux-aio(both are asynchronous submission and polling for results).

Here, the author also hopes to make the reader understand:io_uringThe first and most important contributionlies in: bringing all the excellent features of linux-aio to the general public(rather than being limited to niche fields like databases).

Test environment

This section usesfioTest 4 modes:

  1. synchronous reads
  2. posix-aio(implemented as a thread pool)
  3. linux-aio
  4. io_uring

Hardware:

  • NVMe storage device, physical limit can reach 3.5M IOPS
  • 8-core processor

Scenario 1: direct I/O1KBRandom read (bypassing page cache)

In the first set of tests, we want all read requests tohit the storage device(all reads to hit the storage),completely bypass the OS page cache(page cache)。

Test configuration:

  • 8 CPUs executing 72fiojob,
  • Each job randomly reads 4 files,
  • iodepth=8(number of I/O units to keep in flight against the file.)。

This configurationensures the CPU is in a saturated state, making it easy to observe I/O performance. If there are enough CPUs, each test set might saturate the device bandwidth, rendering the I/O stress test results meaningless.

Table 1. Direct I/O (bypassing system page cache): I/O performance under 100% CPU with 1KB random reads

backendIOPScontext switchesIOPS ±% vs io_uring
sync814,00027,625,004-42.6%
posix-aio (thread pool)433,00064,112,335-69.4%
linux-aio1,322,00010,114,149-6.7%
io_uring (basic)1,417,00011,309,574
io_uring (enhanced)1,486,00011,483,4684.9%

benchmark1
benchmark1

A few points of analysis:

  1. io_uringcompared tolinux-aiothere is indeed some improvement, but it’s not revolutionary.
  2. Enabling advanced features like buffer & file registration further improves performance — but it’s still not enough to rewrite the entire application just for this performance, unless you are doing database R&D and want to squeeze the last bit of performance out of the hardware.
  3. io_uringandlinux-aioare both 2x faster than the synchronous read interface, which in turn is 2x faster than posix-aio — seems a bit different at first glance. But looking at thenumber of context switches, it’s easy to understand why posix-aio is so slow.
    • The poor performance of synchronous read is because: in this situation without page cache, every read system call will block, thus involving a context switch
    • posix-aioThe performance is even worse because: not only is there frequent context switching between the kernel and the application, but the thread pool’smultiple threads are also switching frequently

Scenario 2: buffered I/O1KBRandom read (data preloaded into memory, 100% hot cache)

Second set of tests for buffered I/O:

  1. Preload the file data into memory, and then test random reads.
    • SinceAll data is in the page cache, thereforesynchronous read will never block
    • In this scenario, we expectthe performance gap between synchronous read and io_uring is small (both are the best)
  2. Other test conditions remain unchanged.

Table 2. Buffered I/O (all data from page cache, 100% hot cache): I/O performance of 1KB random reads at 100% CPU

BackendIOPScontext switchesIOPS ±% vs io_uring
sync4,906,000105,797-2.3%
posix-aio (thread pool)1,070,000114,791,187-78.7%
linux-aio4,127,000105,052-17.9%
io_uring5,024,000106,683

benchmark2
benchmark2

Result analysis:

  1. Synchronous read andio_uringthe performance gap is indeed very small, and both are the best.

    But note that,real-world applicationscannot perform IO operations 100% of the time, therefore the performance of real-world applications based on synchronous readsis still worse than that based on io_uring, because io_uring batches multiple system calls.

  2. posix-aioThe worst performance, the direct reason istoo many context switches, this is also scenario-dependent: in this CPU-saturated scenario, its thread pool becomes a liability, completely dragging down performance.

  3. linux-aioandnot designed for buffered I/O, in scenarios where the page cache returns directly, itsasynchronous interface actually causes a performance penalty — splitting the operation into dispatch and consume steps not only provides no performance benefit but incurs additional overhead.

Performance Test Summary

As a final reminder, this section tests extreme applications/scenarios (100% CPU + 100% cache miss/hit); the behavior of real applications typically falls between synchronous and asynchronous reads: sometimes blocking operations, sometimes non-blocking operations. But regardless, after adopting io_uring, users no longer need to worry about the ratio of synchronous to asynchronous operations, because itperforms well in any scenario

  1. If the operation is non-blocking,io_uringthere is no additional overhead;
  2. if the operation is blocking, that’s fine too,io_uringit is fully asynchronous, and does not rely on thread pools or expensive context switching to achieve this asynchronicity;

This article tests random reads, butother types of operationsio_uringalso perform very well. For example:

  1. Opening/closing files
  2. Setting the timer
  3. Transferring data over network sockets

Andit uses the same set of io_uring interfaces

ScyllaDB and io_uring

Scylla relies heavily on direct I/O, usinglinux-aiofrom the very beginning. In our transition toio_uringIn the process, initial tests showed that for certain workloads, a performance improvement of over 50% could be achieved. Butafter in-depth research, it was found that, this is because wewere not utilizing linux-aio well enough previously. This also reveals aoften overlooked fact: achieving high performance is not that hard (provided you get it right). After comparingio_uringandlinux-aioapplications, wequickly released an improved version, and the performance gap between the two disappeared. But frankly, solving this problemrequires some effort, because modifying an interface that has been used for many years, which is based onlinux-aio. Whereas forio_uringapplications, making similar changes is effortless.

The above is just one scenario,io_uringcompared tolinux-aioofAdvantagescan be applied to scenarios beyond file I/O. Furthermore, it comes with specialized high-performance interfaces, such as buffer registration, file registration, polling mode, etc.

Enablingio_uringthe advanced features, we saw a definite performance improvement: on Intel Optane devices, with a single CPU reading 512 bytes, a 5% performance improvement was observed. This aligns with Tables 1 & 2. Although a 5% improvement may not seem huge, it is still very valuable for databases looking to squeeze every bit of performance out of the hardware.

MetricsLinux AIOio_uring (with buffer/file registration + poll)
Throughput330 MB/s346 MB/s
Average Latency (Avg Latency)1549 μs1470 μs
P50 Latency (Median)1547 μs1468 μs
P95 Latency1694 μs1558 μs
P99 Latency1703 μs1613 μs
P99.9 Latency1950 μs1674 μs
Maximum Latency (Max Latency)2177 μs1829 μs

Read 512 bytes from an Intel Optane device using 1 CPU. 1000 concurrent requests. linux-aio and io_uring basic interface performance difference is small. But enabling io_uring advanced features, there is a 5% performance gap.

References