Timeline
Timeline
2026-01-26
init
This article introduces io_uring, a high-performance asynchronous I/O framework introduced in the Linux 5.1 kernel, analyzes its performance difference from traditional Linux AIO (only about 5% improvement), and points out that its revolutionary nature lies in unifying the Linux asynchronous I/O framework, supporting storage files and network sockets, achieving true asynchrony in design, and having good flexibility and scalability. The article also reviews the evolution of Linux I/O system calls, from blocking I/O, non-blocking I/O (select, poll, epoll) to thread pools and Direct I/O, and provides program examples and performance stress test results.
io_uringin 2019 Linux 5.1 the high-performance first introduced by 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 using 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 that extra 5% performance, otherwise switchingtoio_uringthe cost may also be quite high, because you need to rewrite the applicationto adaptio_uring(or let the dependent platform or framework adapt; in short, you need to change code).
Since the performance is similar to traditional AIO, why is it still calledio_uringa revolutionary technology?
- Its first and greatest contribution is:unifying the Linux asynchronous I/O framework,
- Linux AIO only supports direct I/O modestorage files (storage file), and is mainly used inthe database niche;
io_uringsupports storage files and network files (network sockets), and also supports more asynchronous system calls (accept/openat/stat/...), rather than onlyread/writesystem calls.
- BeforeIt is truly asynchronous I/O by design, in contrast, although Linux AIO is also asynchronous, it may still block, and its behavior in some cases is unpredictable;
- Flexibility and scalabilityVery good, even can be based on
io_uringrewrite all system calls, while Linux AIO was not designed with extensibility in mind.
eBPF is also an asynchronous framework (event-driven), but withio_uringthere is no essential connection; the two belong to different subsystems, and there is an essential difference in the model:
- eBPF is transparent to users, just upgrade the kernel (to an appropriate version),applications do not need any modification;
io_uringprovidednew system calls and user-space APIs, soapplications need to be modified。
This article introduces the development history of Linux asynchronous I/O,io_uringits principles and functions, and provides someprogram examplesandperformance stress testsresults.
Evolution of Linux I/O system calls
Blocking I/O based on fd:read()/write()
As the most familiar read/write method, the Linux kernel providessystem calls based on file descriptors, which may point tostorage files(storage file), or possibly network sockets:
12 | ssize_t read(int fd, void *buf, size_t count);ssize_t write(int fd, const void *buf, size_t count); |
the two are calledblocking 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 completes:
- If the data is in a file, and the file contentis already cached in the page cache, the call willreturn immediately;
- If the data is on another machine, it needs to be obtained over the network (e.g., TCP), which will block for a while;
- If the data is on the hard disk, it will also block for a while.
But it is easy to see that as storagedevices become faster and programs become more complex, the blocking approach, the simplest method, is no longer applicable.
Non-blocking I/O:select()/poll()/epoll()
After the blocking style, some new non-blocking system calls appeared, such asselect()、poll()and the newerepoll(). When applications call these functions to read or write, they do not block, but insteadreturn immediately, and what is returned is a list of file descriptors that are already ready。

select()
- Cross-platform(supported by Linux, Windows, and macOS)
- Maximum number of fds is limited: usually
FD_SETSIZE = 1024 - Uses a bitmap (fd_set) to represent the set of fds to monitor
- Each call requires:
- Copying the user-space fd_set to the kernel;
- The kernel iterates over all fds to check their status;
- On return, the entire fd_set is copied back to user space;
- The user must iterate over all fds to determine which are ready.
- Only supports level-triggered (LT) mode
- Disadvantages:
- Low fd count limit;
- Each call requires full copy + full polling → O(n) time complexity;
- Cumbersome programming (need to maintain three fd_sets: read, write, exception)
12 | int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); |
poll()
- Supported on Linux/Unix,Not supported on Windows
- use
struct pollfdArray replaces bitmap, breaking the 1024 limit (only limited by system ulimit) - Still need to pass the entire array to the kernel each time; the kernel iterates to check, and after return the user still needs to iterate to find ready fds
- Still an O(n) polling model
- Only supports LT mode
1234567 | struct pollfd { int fd; // file descriptor to be operated on short events; // Events of interest (POLLIN, POLLOUT) short revents; // Events that actually occurred (filled in by the kernel)};int poll(struct pollfd *fds, nfds_t nfds, int timeout); |
✅ Advantage: no 1024 limit
❌ Disadvantage: performance still worse than epoll (under large-scale connections)
epoll()
Linux-specific, not cross-platform
Based on event-driven + kernel callback mechanism
Three core functions:
123
int epoll_create(int size); // Create an epoll instance (now often use epoll_create1)int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); // Register/modify/delete fdint 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 (add/delete/modify O(log n))
- Ready doubly linked list: When an fd is ready, the kernel automatically adds it to the linked list
epoll_wait()Directly returns the list of ready events, no need to traverse all fds → O(1) retrieval of ready eventsSupports two trigger modes:
- LT(Level Triggered): Default mode, continuously notifies as long as the buffer has data (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 (only passed once during registration, only ready items are returned when events occur)
❌ Disadvantages:
- Linux only
- ET mode programming is complex (prone to missing data reads)
epollMonitoring a regular file fd is meaningless— it will keep triggering events, but cannot achieve the purpose of ‘asynchronously notifying data arrival’.
But this approach 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 dispatches I/O to worker threads, which perform blocking reads and writes on behalf of the main thread, so the main thread does not block.

The problem with this approach isthread context-switch overhead can be very large, as will be seen in the performance tests later.
Direct I/O (database software): bypassing the page cache
Later, a more flexible and powerful approach emerged:Database software(database software) sometimes does not want to use the operating system’s page cache, but instead, after opening a file,directly read and write the file from/to the device(direct access to the device). This approach is calledDirect Access(direct access) orDirect I/O(direct I/O),
- Need to specify
O_DIRECTflag; - NeedThe application manages its own cache — this is exactly what database software wants;
- Yes 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 become faster, the proportion of context-switch overhead between the main thread and worker threads becomes higher. Some devices on the market today, such as Intel Optane,have latency already on the same order of magnitude as a context switch(microsecondsus). To put it another way, it makes this overhead more tangible: Every time a context switch occurs, we lose one opportunity to dispatch I/O.。
dispatch I/O refers toSubmit (initiate) an I/O requestAction to a storage device.
In high-performance systems (such as databases, KV stores), the goal isto dispatch I/O concurrently as much as possible, keeping the device fully loaded (because devices support high queue depths, e.g., NVMe supports thousands of concurrent commands).
Therefore, Linux 2.6 kernel introduced an asynchronous I/O interface, which for convenience is abbreviated aslinux-aio。AIO Principleis very simple:
- Users, through
io_submit()submit I/O requests, - and later call
io_getevents()to check which events are already ready. - enables programmersto write fully asynchronous code。
Recently, Linux AIO even supportedepoll(): that is, it can submit not only storage I/O requests but also network I/O requests. If this trend continues, linux-aio seems capable of becoming a king. But due to its poor evolution path, this wish is almost impossible to realize. We, from Linus’s characteristic fierce remarks, can get a glimpse.:
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:
- only supports
O_DIRECTfiles, sofor regular non-database applications (normal, non-database applications)is almost useless; - The interface, in**Extensibility was not considered in the design.**Although it can be extended — and indeed it was — every addition is quite complex;
- Althoughtechnically the interface is non-blocking, but in practice there are many possible reasons that can cause it to block, and in unpredictable ways.
Summary
From the above, we can clearly see the evolution of Linux I/O:
- At first, it was synchronous (blocking) system calls;
- Then, withactual needs and specific scenarios, new asynchronous interfaces were continuously added, while also maintaining compatibility and coordination with old interfaces.
We also see that on the issue of non-blocking read/write,no unified solution was formed.:
- In the network socket domain: add an asynchronous interface, then poll for request completion (readiness);
- In the storage I/O domain:only targeting a specific niche(databases) at a particular time, 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 arose, without much foresight — untilio_uringthe emergence of.
io_uring
io_uring came from the idea of senior kernel developer Jens Axboe, who has done considerable research in the Linux I/O stack field. From the earliestpatch aio: support for IO pollingit can be seen that this work started from a very simple observation: as devices become faster and faster, the interrupt-driven mode has become less efficient than polling mode (polling for completions) — this is also one of the most common themes in the high-performance field.
io_uringofThe basic logic is similar to linux-aio.: Provides two interfaces: one for submitting I/O requests to the kernel, and one for receiving completion events from the kernel.- But as development went deeper, it gradually became a completely different interface: the designers began to think from the ground up. how to support fully asynchronous operations。
Differences from Linux AIO
io_uringandlinux-aioIt is fundamentally different:
It is truly asynchronous in design(truly asynchronous). As long as the appropriate flag is set, itin the system call context, it just places the request into a queue, and does nothing else extra,ensuring that the application never blocks。
Supports any type of I/O: cached files, direct-access files, and even blocking sockets.
Because it is asynchronous by design (async-by-design nature),there is no need for poll+read/write to handle sockets. Just submit a blocking read, and after the request completes, it will appear in the completion ring.
Flexible and extensible: based on
io_uringit can even re-implement every Linux system call.
Principle and core data structures: SQ/CQ/SQE/CQE
Each io_uring instance hastwo ring queues(ring), shared between the kernel and the application:
- submission queue:submission queue (SQ)
- completion queue:completion queue (CQ)

These two queues:
- are allsingle-producer, single-consumer, size is a power of 2;
- providelock-less 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 SQEs 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 CQEs and updates the CQ head.
- Completion events may arrive in any order, but are always associated with a specific SQE.
- Consuming CQEs does not require switching to kernel mode.
Benefits
io_uringAnother benefit of this request method is that operations that originally required multiple system calls (read or write) can now be submitted as a single batch.io_uringbrings this batching capability to outside of storage I/O system calls some other system calls, including:
readwritesendrecvacceptopenatstat- some dedicated system calls, such as
fallocate
In addition,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:
Although
io_uringandaiothere are some similarities, but itsscalability and architecture are revolutionary: itbrings the power of asynchronous operations to all applications(and their developers), while no longer limited to the niche of database applications。
Avi Kivity at the Core C++ 2019 event gave a talk about async. The key points include:In terms of latency,,
- Modern multi-core, multi-CPU devices are themselves a basic network internally;
- Between CPUsis another network;
- Between CPU and disk I/Ois yet another network.
Therefore, it is wise to use async in network programming, and now when developing your own applications, you should also consider async. Thisfundamentally changes the way Linux applications are designed.:
- Previously, it was a sequential code flow, executing system calls only when needed,
- Now you need to consider whether a file is ready, thus naturally introducing an event-loop, continuously submitting requests and receiving results through a shared buffer.
Three operating modes
An io_uring instance can work in three modes:
Interrupt-driven mode(interrupt driven)
Default mode. You can use io_uring_enter() to submit I/O requests, then directly check the CQ status to determine whether it is complete.
Polling mode(polled)
Busy-waiting for an I/O completion, rather than receiving notification via an asynchronous IRQ (Interrupt Request).
This mode requires the file system (if any) and the block device to support polling. Compared with interrupt-driven mode, this mode has lower latency (even saving system calls), but may consume more CPU resources.
Currently, only file descriptors opened with the
O_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 whether the request has completed.For an io_uring instance,mixing polling and non-polling modes is not supported.。
Kernel polling mode(kernel polled)
In this mode, it will create a kernel threadto 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 call
io_uring_enter()to wake up the kernel thread. If I/O remains 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)
Detailed below. For complete documentation, see manpage。
io_uring_setup()
To perform asynchronous I/O, you need to firstset up the context.:
1 | int io_uring_setup(u32 entries, struct io_uring_params *p); |
This system call
- creates an SQ and a CQ,
- with a queue size of at least
entriesan element, - returns a file descriptor, which is subsequently used to perform operations on this io_uring instance.
The SQ and CQ are shared between the application and the kernel, avoiding copying data when initiating and completing I/O.
For parameter p:
- For the application, it is used to configure io_uring,
- The SQ/CQ configuration information returned by the kernel is also brought back through it.
io_uring_setup()On success, a file descriptor (fd) is returned. The application can then 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 so that the kernel canhold long-term references to the internal kernel data structures of the file(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 for each I/O request, thus reducing per-I/O overhead.
Properties of registered buffers
- Registered buffers willbe locked in memory(be locked in memory), andcount toward the user’s RLIMIT_MEMLOCK resource limit.
- Additionally, each buffer has a size limit of 1GB。
- Currently, buffers must beanonymous, non-file-backed memory(anonymous, non-file-backed memory), such as 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 part of it is used.
- A registered buffer cannot be resized. To resize, you must first unregister and then register a new one.
Through theeventfd()Subscribe to completion events
you can useeventfd(2)Subscribe to completion events of an io_uring instance. Simply register the eventfd descriptor via 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 performs both:
- Submit new I/O requests
- Wait for I/O completion
Parameters:
fdYesio_uring_setup()The returned file descriptor;to_submitSpecifies the number of I/Os submitted in the SQ;- Depending on the mode:
- Default mode, if specified
min_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: Ask the kernel to return all current and completed events, without blocking;
- Non-zero: If there are completed events, the kernel still returns immediately; if there are no completed events, the kernel will poll, waiting for the specified number of completions, or until this process’s time slice is used up.
- Default mode, if specified
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 means a certain number of SQEs have been consumed and submitted, and it is safe to reuse the SQEs in the queue. At this point, the IO submission may still be in an asynchronous context, meaning the SQEs may not actually have been submitted yet — however, the user does not need to worry about these details — when the kernel later needs to use a specific SQE, it has already made a copy.
Advanced features
io_uringProvides some advanced features for special scenarios:
- File registration(File registration): Each 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 thoseFor repeated operations on the same filescenario,
io_uringSupportsregister these files in advance, and then just look them up later. - Buffer registration(Buffer registration): Similar to file registration, in direct I/O scenarios, the kernel needs to map/unmap memory areas.
io_uringIt supports registering these buffers in advance. - Poll ring(Polling ring buffer): For very fast devices, the overhead of handling interrupts is relatively large.
io_uringIt allows users to disable interrupts and use polling mode. This was also mentioned in the earlier “Three Working Modes” subsection. - Linked operations(Linked operations): Allows users to send chained requests. The two requests are submitted at the same time, but the latter will wait until the former finishes processing before starting execution.
User-space libraryliburing
liburing It 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 an io_uring instance.
For example, inio_uring_setup()the manpage description, after calling this system call to obtain a ring file descriptor, the application must callmmap()to implement such logic requires a somewhat long piece of code, and usingliburingthen, the following function has already encapsulated the above process:
1 | int io_uring_queue_init(unsigned entries, struct io_uring *ring, unsigned flags); |
Next, let’s look at two examples based on liburing.
Example Applications Based on liburing
Compile:
12345678910111213 | $ 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
For clarity of the main logic, some error handling code is omitted. See the complete code in io_uring-test.c。
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990 | /* 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 */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; kernel polling mode requires this flag, as described 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; // The 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 turn, 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], // The iovec address; the read data is written to the iovec buffer. 1, // Number of iovecs. offset); // The 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 the SQE read requests. ret = io_uring_submit(&ring); // Submit 4 SQEs at once; returns the number of SQEs successfully submitted. 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 requests 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 work. close(fd); io_uring_queue_exit(&ring); return 0;} |
Additional notes.
Comments have already been added to the code; here are a few more explanations:
- Each SQE executes an allocated buffer, which is described using
iovecthe structure; - Steps 3 & 4: Initialize all SQEs for the upcoming
IORING_OP_READVoperations, which providereadv(2)the asynchronous interface for system calls. - After the operation completes, the SQE iovec buffer contains the relevant
readvoperation results; - Next, call
io_uring_wait_cqe()to reap the CQE, and throughcqe->resfield verify the number of bytes read; io_uring_cqe_seen()Notify the kernel that this CQE has been consumed.
link-cp
link-cp uses the io_uring advanced feature SQE chaining to copy files.
I/O chain
io_uring supports creating I/O chains. I/O within a chain is executed sequentially, and multiple I/O chains can be executed in parallel.
io_uring_enter()In the manpage,IOSQE_IO_LINKhave 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 copy functionality, link-cp creates an SQE chain of length 2.
- The first SQE is a read request that reads data from the input file into the buffer;
- The second request, linked with the first request, is a write request that writes data from the buffer to the output file.
Source code and comments
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152 | /* 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. */struct io_data { size_t offset; int index; struct iovec iov;};static int infd, outfd;static unsigned inflight;// Create a read->write SQE chainstatic 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 the 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 the write request io_uring_sqe_set_data(sqe, data); // Set data}// Handle completion event: release the SQE memory buffer, notify the kernel that the 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 complete, release buffer memory void *ptr = (void *) data - data->iov.iov_len; free(ptr); } io_uring_cqe_seen(ring, cqe); // Notify the kernel that the 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 has not been fully processed yet int has_inflight = inflight; // Current number of in-flight SQEs int depth; // SQE threshold: when the number of in-flight SQEs exceeds this value, block and wait for CQE completion while (insize && inflight < QD) { // Data has not been fully processed, and the io_uring queue is not full yet this_size = BS; if (this_size > insize) // The last data segment 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; // In-flight SQE count +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; // Set the threshold to the SQ queue length, i.e., only block and wait for CQE when the SQ queue is full; else // All data processing has been submitted, depth = 1; // Set the threshold to 1, i.e., as long as there are unfinished SQEs, block and wait for CQE // The following while loop will only be executed when the SQ queue is full 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--; // In-flight SQE count -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;} |
Additional notes.
The code implements three functions:
copy_file(): high-level copy loop logic; it callsqueue_rw_pair(ring, this_size, offset)to construct an SQE pair; and through a singleio_uring_submit()call, submit all constructed SQE pairs.This function maintains inflight SQEs up to the maximum QD count as long as the data copy is still in progress; otherwise, once all data has been read, it begins waiting for and reaping all CQEs.
queue_rw_pair()Construct a read-write SQE pair.The read SQE’s
IOSQE_IO_LINKflag indicates the start of a chain; the write SQE does not need to set this flag, marking the end of the chain. The user data field is set to the same data descriptor, which will be used in subsequent completion processing.handle_cqe()Extract from the CQE the previouslyqueue_rw_pair()saved data descriptor, and record the processing progress (index) in the descriptor.If the previous request was cancelled, it will also resubmit the read-write pair.
After both members of a CQE pair have been processed (
index==2), release the shared data descriptor. Finally, notify the kernel that this CQE has been consumed.
io_uring performance benchmarking (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 arelinux-aionot essentially different (both are asynchronous submission and polling for results).
Here, this article 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 such as databases).
Test environment
This section usesfioTest 4 modes:
synchronous readsposix-aio(implemented as a thread pool)linux-aioio_uring
Hardware:
- NVMe storage device, whose physical limit can hit 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 operating system’s page cache(page cache)。
Test configuration:
- 8 CPUs execute 72
fiojob, - 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 saturated, which makes it easier to observe I/O performance. If there are enough CPUs, each test group may saturate the device bandwidth, making the results meaningless for I/O stress testing.
Table 1. Direct I/O (bypassing system page cache): 1KB random read, I/O performance at 100% CPU
| backend | IOPS | context switches | IOPS ±% vs io_uring |
|---|---|---|---|
| sync | 814,000 | 27,625,004 | -42.6% |
| posix-aio (thread pool) | 433,000 | 64,112,335 | -69.4% |
| linux-aio | 1,322,000 | 10,114,149 | -6.7% |
| io_uring (basic) | 1,417,000 | 11,309,574 | — |
| io_uring (enhanced) | 1,486,000 | 11,483,468 | 4.9% |

Some analysis:
io_uringIn comparison,linux-aiothere is indeed some improvement, but it is not revolutionary.- Enabling advanced features such as buffer & file registration further improves performance — but it is still not to the point of rewriting the entire application for these performance gains, unless you are doing database development and want to squeeze the last bit of performance out of the hardware.
io_uringandlinux-aioare all 2x faster than the synchronous read interface, which in turn is 2x faster than posix-aio — at first glance there is some difference. But look atthe number of context switches, and it is not hard to understand why posix-aio is so slow.- The poor performance of synchronous read is because: in this situation without page cache, Each read system call blocks, so it involves a context switch.。
posix-aioThe performance is worse because: not only are there frequent context switches 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)
The second set of tests: buffered I/O:
- Load the file data into memory in advance, then test random read.
- SinceAll data is in the page cache, sosynchronous read never blocks。
- In this scenario, we expectthe performance gap between synchronous read and io_uring is small (both are the best).。
- Other test conditions remain unchanged.
Table 2. Buffered I/O (all data from page cache, 100% hot cache): 1KB random read, I/O performance at 100% CPU
| Backend | IOPS | context switches | IOPS ±% vs io_uring |
|---|---|---|---|
| sync | 4,906,000 | 105,797 | -2.3% |
| posix-aio (thread pool) | 1,070,000 | 114,791,187 | -78.7% |
| linux-aio | 4,127,000 | 105,052 | -17.9% |
| io_uring | 5,024,000 | 106,683 | — |

Result analysis:
Synchronous read and
io_uringthe performance gap is indeed very small; both are the best.But note,real-world applicationscannot be executing I/O operations 100% of the time, so the real application performance based on synchronous readis still worse than that based on io_uring, because io_uring batches multiple system calls.
posix-aioThe worst performance is directly due totoo many context switches, which is also related to the scenario: in this kind of When the CPU is saturated, its thread pool becomes a burden and completely slows down performance.linux-aioandis not designed for buffered I/O, in this scenario where the page cache returns directly, itsasynchronous interface instead causes a performance penalty. — splitting the operation into dispatch and consume steps not only brings no performance benefit, but also introduces extra overhead.
Performance test summary
Finally, let me remind you again that this section is an extreme application/scenario (100% CPU + 100% cache miss/hit) test. Real-world application behavior usually lies between synchronous and asynchronous reads: sometimes some blocking operations, sometimes some non-blocking operations. But in any case, after using io_uring, users no longer need to worry about the proportion of synchronous and asynchronous, because itperforms well in any scenario.。
- If the operation is non-blocking,
io_uringthere is no extra overhead; - If the operation is blocking, that’s also fine,
io_uringit is completely asynchronous, and does not rely on thread pools or expensive context switches to achieve this asynchronous capability;
The tests in this article are all random reads, but forother types of operations,io_uringthe performance is also very good. For example:
- opening/closing files
- Setting the timer
- transferring data over network sockets
andthey use the same set of io_uring interfaces.。
ScyllaDB and io_uring
Scylla heavily relies on direct I/O and has used io_uring from the very beginninglinux-aio. In the process of switching toio_uringio_uring, initial tests showed performance improvements of over 50% for some workloads. ButAfter in-depth research, we found that, this is because wethe previous linux-aio was not used well enough. This also reveals aoften-overlooked fact: achieving high performance is not that difficult (provided you get it right). In comparingio_uringandlinux-aioapplications, wequickly improved a version, and the performance gap between the two disappeared. But frankly speaking, solving this problemrequires some effort, because it requires modifying a long-used based-onlinux-aiointerface. In contrast, forio_uringapplications, making similar changes is easy.
The above is just one scenario,io_uringIn comparison,linux-aioofAdvantagesit can be applied to scenarios beyond file I/O. In addition, it also comes with special high-performance interfaces, such as buffer registration, file registration, polling mode, etc.
Enablingio_uringadvanced features, we saw performance indeed improve: on Intel Optane devices, with a single CPU reading 512 bytes, we observed a 5% performance improvement. This matches Tables 1 & 2. Although a 5% improvement may not seem significant, it is very valuable for databases that want to squeeze out all the performance of the hardware.
| Metric | Linux AIO | io_uring (with buffer/file registration + poll) |
|---|---|---|
| Throughput | 330 MB/s | 346 MB/s |
| Average Latency | 1549 μs | 1470 μs |
| P50 Latency (Median) | 1547 μs | 1468 μs |
| P95 Latency | 1694 μs | 1558 μs |
| P99 Latency | 1703 μs | 1613 μs |
| P99.9 Latency | 1950 μs | 1674 μs |
| Max Latency | 2177 μs | 1829 μs |
Using 1 CPU to read 512 bytes from an Intel Optane device. 1000 concurrent requests. linux-aio and io_The performance difference of the uring basic interface is very small. But enabling io_After uring advanced features, there is a 5% performance gap.
