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?
- 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.
- 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;
- Flexibility and scalabilityExcellent, even capable of being based on
io_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:
- eBPF is transparent to users, requiring only a kernel upgrade (to a suitable version),and applications require no modifications;
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 | ssize_t read(int fd, 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。

select()
- Cross-platform(supported by Linux, Windows, macOS)
- Maximum number of fds is limited: usually
FD_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 | int select(int nfds, fd_set *readfds, fd_set *writefds, |
poll()
- Linux/Unix supported,Windows not supported
- Use
struct 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 | struct pollfd { |
✅ 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
3int 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 eventsKernel 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 eventsSupports 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.

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_DIRECTflag; - 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, through
io_submit()submits I/O requests, - and after a while, calls
io_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:
- only supports
O_DIRECTFile, thereforefor regular non-database applications (normal, non-database applications)it is almost useless; - The interface wasnot designed with extensibility in mind. Although it can be extended — and indeed it was — adding each new feature is quite complex;
- 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.:
- Network socket domain: add an asynchronous interface, and then poll to see if the request is complete (readiness);
- 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 mode (polling 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:
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。
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.
Flexible and scalable: 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 buffers(ring), shared between the kernel and the application:
- Submission Queue (SQ):submission queue (SQ)
- Completion Queue (CQ):completion queue (CQ)

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:
readwritesendrecvacceptopenatstat- dedicated system calls, such as
fallocate
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:
While
io_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,,
- modern multi-core, multi-CPU devices are essentially a basic network internally;
- between CPUsis another network;
- 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:
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.
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 specified
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 if the request has completed.For an io_uring instance,mixed use of polling and non-polling modes is not supported。
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 call
io_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 least
entrieselements, - 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:
- Submit new I/O requests
- Wait for I/O completion
Parameters:
fdisio_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: 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.
- 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 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:
- 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. - 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. - 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. - 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 | $ git clone https://github.com/axboe/liburing.git |
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 | /* SPDX-License-Identifier: MIT */ |
Other notes
Comments have been added to the code, here are a few more explanations:
- Each SQE executes an allocated buffer, the latter is used
iovecStructural description; - Step 3 & 4: Initialize all SQEs for the upcoming
IORING_OP_READVoperations, which providesreadv(2)an asynchronous interface for system calls. - After the operation completes, the SQE iovec buffer stores the relevant
readvresult of the operation; - Next, call
io_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
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 | /* SPDX-License-Identifier: MIT */ |
Other notes
Three functions are implemented in the code:
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.
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 this chain. The user data field is set to the same data descriptor, which will be used in the subsequent completion processing.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:
synchronous readsposix-aio(implemented as a thread pool)linux-aioio_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 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 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
| 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% |

A few points of analysis:
io_uringcompared tolinux-aiothere is indeed some improvement, but it’s not revolutionary.- 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.
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:
- 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)。
- 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
| 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, 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.
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.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。
- If the operation is non-blocking,
io_uringthere is no additional overhead; - 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 operations,io_uringalso perform very well. For example:
- Opening/closing files
- Setting the timer
- 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.
| Metrics | Linux AIO | io_uring (with buffer/file registration + poll) |
|---|---|---|
| Throughput | 330 MB/s | 346 MB/s |
| Average Latency (Avg 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 |
| Maximum Latency (Max Latency) | 2177 μs | 1829 μ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.
