Post

Linux Kernel

From epoll to io_uring’s Multishot Receives — Why 2025 Is the Year We Finally Kill the Event Loop

This article traces the evolution of Linux asynchronous I/O from epoll to io_uring, highlighting how multishot receive operations streamline network event handling. It explains why multishot removes much of the boilerplate and syscall overhead of traditional event loops, and presents benchmarks showing io_uring’s throughput gains and tail latency reductions over epoll—making 2025 the year to rethink event-driven server design.
By Codemia • August 14, 2025

5 min read

Introduction

For decades, high-performance servers have relied on event loops to handle multiple connections, using APIs like select(), poll(), or epoll() to wait for I/O readiness on many file descriptors. This readiness-based (reactor) model served well through the “C10k problem” era, but it imposed inherent overheads and complexity. In 2019, Linux introduced io_uring, a revolutionary completion-based (proactor) asynchronous I/O interface, and it has rapidly evolved. By 2025, with features like multishot accept/receive, io_uring effectively combines the best of both worlds – allowing the kernel to handle repetitive I/O events with minimal user-space intervention. Many believe that this is the year we can finally move beyond the traditional user-space event loop.

In this post, we’ll walk through the history of Linux async I/O (from select to epoll to io_uring), explain how io_uring’s multishot receive works and why it’s a game changer, compare benchmarks (throughput and tail latencies) between epoll and io_uring, and discuss how these advances disrupt event-loop architectures. We’ll also provide code references for reproducing benchmarks and practical guidance on migrating from epoll to io_uring in production.


A Brief History of Linux Async I/O: From select() to io_uring

The Era of select() and poll(): Early Event Loops

The journey begins with the POSIX select() system call, introduced in 4.2BSD (1983). select() lets a program monitor multiple file descriptors (FDs) for readiness (e.g. readable or writable) by using fixed-size bit masks (FD sets). It was revolutionary for its time, but it has serious limitations. The interface requires specifying the number of FDs to check (nfds = max FD + 1), and internally the kernel loops over all FDs from 0 up to nfds-1 to check readiness. This means the time complexity is O(n) in the number of FDs (or the value of the largest FD). On each call, the kernel must scan potentially thousands of descriptors, making select() increasingly inefficient as connections scale. Additionally, select() uses a fixed-size bitmap (commonly 1024 FDs on Linux by default), so monitoring more descriptors requires manual compile-time tweaks or is simply impossible on some systems. If a program mistakenly tries to FD_SET an FD beyond the limit, memory corruption can occur. In short, select() does not scale to high FD counts and can even be unsafe when FD values exceed FD_SETSIZE.

To address some of these issues, System V Release 3 (1987) introduced poll() (Linux adopted it around 1997). The poll() API improves a few things: it takes a variable-length array of struct pollfd (fd + event mask) rather than a fixed bitmap, eliminating the 1024-FD hard limit. It also doesn’t destroy the input array, so you don’t need to rebuild the FD set each call (unlike select, which modifies the fd_set in-place). However, poll still suffers O(n) complexity – it must check each passed-in FD for events on every call. Like select, poll’s performance degrades linearly as the number of FDs grows. Both select and poll incur significant overhead when monitoring hundreds or thousands of sockets: “as soon as you go beyond perhaps a hundred file descriptors or so… waiting for activity and checking which FD was signaled takes significant time and becomes a bottleneck”. In summary, by the late 1990s, select/poll-based event loops were slow (scanning many FDs) and cumbersome (managing large FD sets, fixed limits, etc.), prompting the search for more scalable solutions.

epoll: Solving the c10k Problem with O(1) Scalability

Linux’s answer to scalable I/O notification was epoll, introduced in kernel 2.5.45 (2002). Unlike select/poll, epoll decouples registration of interest from waiting for events. A process first creates an epoll instance (epoll_create), then adds or removes FDs to watch via epoll_ctl(ADD/MOD/DEL) calls, specifying the events of interest (e.g. EPOLLIN for readable). The kernel maintains an internal interest list (often using a red-black tree) of all registered FDs. Then the process calls epoll_wait() to block until any of those FDs becomes ready, at which point epoll returns a list of only the ready events (the ready list). This design is much more efficient: epoll doesn’t have to linearly scan all FDs each time; instead, the kernel puts ready FDs into a queue and wakes the waiting call. The result is that epoll operates in O(1) time (constant time per ready event), in contrast to the O(n) of select/poll. In other words, whether you’re monitoring 10 FDs or 10,000 FDs, epoll’s wait cost depends mainly on the number of active events, not the total number of watched descriptors. This was a game-changer for handling large numbers of connections (the famous “C10k problem”).

Not only is epoll more scalable, it also provides flexibility with edge-triggered vs level-triggered modes and one-shot notifications, giving developers control to optimize event handling behavior. By the mid-2000s, epoll became the backbone of Linux high-concurrency servers (web servers, databases, proxies, etc.), often accessed via libraries like libevent or libev to abstract the details. Epoll essentially embodies the classic reactor pattern: register interest, then react to readiness events in a loop. However, despite its strengths, epoll + event loop isn’t a panacea – it still involves syscall overhead for each event notification and subsequent I/O operation (you typically get an epoll event, then must call read()/write()), and it doesn’t natively handle disk I/O or other non-socket sources (those often still needed separate threads or Linux AIO). Over time, as SSDs, NVMe, and 100Gb networks pushed I/O rates higher, the overhead of “syscall per event” and juggling user-space event loops became a limiting factor in ultra-low-latency systems.

Enter Linux AIO (posix AIO): Linux did have an asynchronous I/O API (Linux AIO via io_submit etc.), but it was limited and unpopular. It only worked for direct-access file I/O (O_DIRECT) and had no support for regular buffered I/O or sockets. Its “completion notification” mechanism was clunky (signals or polling an event FD), and many developers avoided it. In practice, by the 2010s, Linux lacked a unified, efficient async I/O for both files and sockets – epoll was used for sockets, and threads or hacky approaches for files. This is the gap that io_uring was designed to fill.

io_uring: A Modern Async I/O Framework

Introduced in Linux 5.1 (2019) by Jens Axboe, io_uring is a general-purpose asynchronous I/O interface that aims to supersede both epoll and Linux AIO. Io_uring provides a pair of ring buffers – a Submission Queue (SQ) and Completion Queue (CQ) – shared between user-space and kernel. Instead of making a syscall for each I/O operation, applications write requests into the SQ (one entry per operation, e.g. “read this file”, “accept on socket”, “send this packet”) and batch submit them with a single syscall (or even avoid syscalls entirely in certain polling modes). The kernel processes these asynchronously (using threads for files if needed) and posts results into the CQ, which the app can retrieve by reading the shared ring (again possibly batching many completions per syscall, or using pure memory reads). In essence, io_uring turns the kernel into an async I/O completion engine – you issue operations and later pick up completions, akin to Windows IOCP or BSD kqueue’s EVFILT_AIO. This flips the model from “tell me when I can do I/O” (epoll) to “please do this I/O and tell me when it’s done”. Completion-based I/O simplifies user logic (no need for manual re-check loops) and often reduces the number of context switches and syscalls dramatically.

Crucially, io_uring is fast. By using shared memory rings, it avoids unnecessary copy and kernel-user transitions for each event. Thousands of I/Os can be handled per io_uring_enter syscall (or via a dedicated poll thread) instead of one syscall per I/O. In benchmarking, io_uring shows massive throughput and latency gains: handling millions of IOPS with lower CPU usage and lower tail latencies than epoll/poll. One source notes databases using io_uring achieved 30% less CPU utilization and very high IOPS, and “compared to epoll, applications built on io_uring demonstrate lower p99 latency, especially under saturation conditions”. Another reports that network servers can handle 2–4× more concurrent connections with the same CPU when using io_uring. These improvements come from fewer syscalls, better batching, zero-copy buffers, and kernel-side polling that io_uring enables. In short, io_uring provides a unified, efficient API for files and sockets, potentially obviating the need for a traditional event loop at all in many cases.

However, early versions of io_uring had some caveats for networking. As some developers pointed out, using io_uring for sockets initially meant submitting a read operation each time you want to receive data, which felt “unnatural” compared to epoll’s persistent registration. For example, if you have 20k connections all doing tiny reads, repeatedly re-submitting 20k read requests could incur overhead. This is where multishot capabilities come in – a major evolution that arrived in Linux 5.19 and 6.0.


io_uring’s Multishot Receives: How They Work and Why They’re a Game Changer

One critique of the proactor (completion) model was the need to continuously re-issue operations for recurring events. In a typical io_uring echo server (pre-6.0), you would submit an accept SQE, get one connection, then have to submit another accept for the next connection. Similarly, for each socket you’d submit a recv request, get some data, then submit a new recv to get more. This loop of submit → complete → re-submit mimics what an event loop does anyway (just shifted to completion events). Multishot requests eliminate much of this loop by allowing one submission to yield many completions.

Multishot Accept

For accepting new connections, Linux 5.19 introduced multishot accept. Instead of calling io_uring_prep_accept() for one connection at a time, you can use io_uring_prep_multishot_accept(). With a multishot accept SQE, once a new client is accepted, the kernel will not remove the request; it stays armed to accept the next connection, and the next, posting a CQE each time a client connects. This continues until you explicitly cancel it or an error occurs (e.g. the listening socket is closed). The completion events for multishot operations have a flag IORING_CQE_F_MORE to indicate “more coming” — only when that flag is cleared (or an error) does it mean the multishot is finished and should be re-armed. The benefit is obvious: no more re-submitting accept after each client, which “reduces the housekeeping the application needs to do when handling new connections.”. This cuts down latency between back-to-back connections and saves CPU cycles that an event loop would spend re-adding the accept each time. It essentially turns the kernel into the accept-event loop for you.

Multishot Receive

Our main star is multishot receive, available since Linux 6.0. This feature targets the scenario of a busy connection (e.g. a persistent socket getting many messages in succession). Historically, a server using epoll would register the socket once and get notified whenever new data arrives – but still had to call recv() repeatedly. In io_uring without multishot, you’d similarly issue a new io_uring_prep_recv() after each completion. With io_uring_prep_recv_multishot(), you can submit one receive request and leave it active to grab data from the socket as it comes, over and over. For example, if you have a connection that sends 100 messages over time, a single multishot SQE can yield 100 CQEs (each containing one chunk of data) without the application ever re-submitting the read in between. This effectively kills the need for a manual read event loop on that socket – the kernel continuously feeds you completions whenever new data arrives.

How does the kernel manage continuous receives? It leverages another io_uring innovation: provided buffers. When using multishot receive, the application must set up a pool of receive buffers (and use the IOSQE_BUFFER_SELECT flag). Each time data is available, the kernel picks an available buffer from your pool, reads the data into it, and posts a CQE indicating which buffer was used and how much data was read. The CQE includes an io_uring_recvmsg_out structure that describes the buffer and message (for recvmsg, ancillary data, etc.). The application processes the data and then (optionally) returns the buffer to the pool for reuse. This design is extremely efficient: it avoids copying data into a fixed buffer or allocating on each recv, and it ties in with zero-copy mechanisms. Essentially, io_uring acts like an “infinite” readiness notification coupled with performing the recv() for you and giving you the data.

Why is this a big deal? A patch email introducing multishot recv summarized it well: “generally socket applications will be continually enqueueing a new recv() when the previous one completes. This can be improved by allowing a multishot receive, which will post completions as data is available… (benefits) subsequent receives are queued up straight away without requiring the application to finish a processing loop; if more data is in the socket (than the provided buffer size), then data is immediately returned, improving batching; poll is only armed once and reused, saving CPU cycles.” In other words, multishot mode combines the efficiency of edge-triggered notification (kernel doesn’t need to keep waking you up) with the convenience of automatic I/O. It closes the gap between reactor and proactor: you no longer pay a syscall per message, nor even a submission per message. The kernel effectively becomes a data event pump into your completion queue.

From an API standpoint, the multishot recv CQEs will keep coming with IORING_CQE_F_MORE flag set, until one comes without that flag (indicating the multishot is ending, e.g. socket closed or buffer pool empty). At that point, you can re-arm if needed. If the completion queue were to overflow (too many CQEs without being collected), the kernel will stop the multishot to avoid out-of-order data, but under normal conditions with proper CQE handling this shouldn’t happen often.

Multishot poll deserves a brief mention: even before accept/recv, io_uring added multishot support for poll requests (since Linux 5.13). A multishot poll lets you monitor an FD (similar to epoll) via an SQE that stays active and posts a CQE every time the specified event (readable, writable, etc.) occurs. This essentially can replace an epoll wait loop for an FD. However, if you’re going to handle the I/O via io_uring anyway, multishot recv and accept go a step further by doing the I/O and not just signaling it.

Why 2025 Signals the End of the Traditional Event Loop

With multishot receives and accepts, an io_uring-based server can initialize its I/O intents once and then simply process completions in a loop. The “event loop” becomes a completion-processing loop, which is at a higher abstraction: you no longer explicitly manage readiness or re-submission for each event, the kernel does it. This greatly simplifies application logic and reduces user-space overhead. As one Stack Overflow answer put it, “the proactor pattern (io_uring, IOCP) is superior to the reactor (epoll, kqueue) because it mimics natural control flow: you ‘call’ async functions and wait for results... [whereas] with epoll you have to do every individual step along the way yourself”. Multishot ops reinforce this advantage by removing even more of the “re-arm” steps from the developer’s plate.

In practical terms, this means we can finally stop writing the boilerplate event loops that have been at the heart of network servers. No more while(true) { epoll_wait(); for each event { ...; epoll_ctl(...); } } – instead, you submit a handful of multishot requests up front (one for new connections, one per socket for incoming data, etc.) and then your loop only consumes completions and issues new work (like send responses), possibly in batch. The kernel is doing the polling and re-arming efficiently under the hood.

It’s important to note that “killing the event loop” doesn’t mean we stop looping entirely – rather, the loop becomes simpler and has far fewer syscalls per iteration. You might still call io_uring_wait_cqe() in a loop to get events, but with many events per syscall, and you rarely need to call back into the kernel to set up the next operation (except for writes or business logic). In fact, io_uring even provides batching where you can handle completions and submit new SQEs together before the next kernel entry. This blurs the line between event handling and dispatching new work, enabling extremely efficient pipelines. One Red Hat article showed an io_uring echo server handling 5,000 socket ops with just 23 syscalls, by batching submissions and completions (over 60 I/O operations per system call on average). That’s an order of magnitude fewer syscalls than a traditional epoll loop handling the same load.

In summary, io_uring in 2025 has reached a point where the traditional event loop is no longer the star of the show – it’s been relegated to a supporting role, with the kernel taking over much of the event management. This is why many are proclaiming that it’s time to “kill” (or retire) the user-space event loop as we know it, in favor of leaner, io_uring-driven designs.


Benchmarking epoll vs io_uring (Multishot) – Throughput and Latency

To quantify the benefits of io_uring’s approach, let’s examine some benchmarks comparing epoll and io_uring (with multishot where applicable). Key metrics are throughput (requests handled per second) and latency, especially tail latency (p99, p99.9).

Throughput (QPS): The io_uring multishot receive patch author, Dylan Yudaken, reported a 6–8% QPS improvement in a small network benchmark by using multishot recvs (versus re-submitting single-shot recvs). This was at moderate load; the gain can be higher at scale. Another test by the OpenAnolis community measured single-thread echo server performance: at 1000 concurrent connections, io_uring delivered about 10% higher throughput than epoll (with CPU mitigations on). This was attributed to batching reducing the impact of costly context switches. It’s notable that at lower concurrency, epoll and io_uring can be closer – in that study, if only 1 connection was active sending a steady stream, vanilla epoll slightly outperformed io_uring in raw throughput for small messages (likely because io_uring had overhead per byte and wasn’t benefitting from batching). But as soon as multiple connections or larger batches come into play, io_uring catches up and overtakes epoll. In ping-pong request/response scenarios, io_uring tends to shine, often exceeding epoll throughput by a comfortable margin. The bottom line: at scale, io_uring can handle more requests per second due to fewer syscalls and more work per syscall. Epoll still holds its own for certain workloads (especially if not issuing many I/O ops per event), but multishot and batching features are eroding those cases.

Latency (especially tail latency): Reducing tail latency (p99, p99.9) is a key aim for modern async I/O. By cutting down context switches and syscalls, io_uring often provides a smoother latency distribution under load. Empirical data from real systems and benchmarks indicate that under heavy concurrency, epoll’s p99 latency can degrade as the event loop struggles to keep up, whereas io_uring’s approach maintains lower tail times. For example, GoCodeo notes lower p99 latency for io_uring vs epoll under saturation. This is consistent with anecdotal reports from applications that saw jitter reduction by switching to io_uring.

Below is an illustrative comparison of tail latencies in a hypothetical high-concurrency echo server scenario (lower is better):

Tail latency at high concurrency: in heavy load scenarios, io_uring shows significantly lower p99 and p99.9 latencies compared to traditional epoll (fewer outlier delays), thanks to more efficient batching and fewer context switches.

In the above example, io_uring might reduce p99 latency by ~25% and p99.9 by ~30% versus epoll (actual numbers vary by workload, but this trend has been observed). Fewer syscalls mean fewer occasions where requests queue up waiting for kernel entry, which in turn means latency spikes (caused by, say, a context switch at an inopportune time or an epoll wakeup delay) are minimized. Additionally, io_uring can offload work to kernel threads (for file I/O) or handle multiple events per wakeup, which flattens the latency curve for the vast majority of operations.

CPU utilization is another aspect – io_uring often uses less CPU to achieve the same throughput as epoll. For instance, in database benchmarks, 30% CPU savings were noted. In a Reddit thread on production use, one user did report higher CPU using io_uring for their particular case along with higher P99 (possibly due to mis-tuning), underscoring that proper usage (e.g. using provided buffers to avoid memory overhead, tuning SQ/CQ ring sizes, etc.) is important to get the best results. Generally, well-tuned io_uring should lower CPU per I/O by reducing the “busy-work” in user space and kernel (epoll wait lists, context switches, etc.).

It’s worth mentioning that if an application doesn’t batch at all with io_uring (submits one SQE, one CQE, like an epoll replacement without using other features), it might not see a benefit – or could even perform worse in some microbenchmarks – because it’s not leveraging what io_uring offers. The true power comes with features like multishot, batching, and registered buffers. As Jens Axboe notes, simply swapping epoll for an io_uring-based notifier without redesigning the event loop won’t unlock the full potential: “it’ll reduce syscalls compared to epoll, but will not be able to take advantage of some of the other features… to do that, a change to the IO event loop must be done.” In our context, that change is embracing multishot receives, etc., which we have done.

Summary of benchmark findings: io_uring with multishot can modestly improve throughput (several percentage points to potentially much more under heavy load or with many connections) and significantly improve tail latency in saturated conditions. Epoll remains extremely fast for what it does – in lightly loaded scenarios or certain streaming patterns, you might not see huge differences. But as concurrency and event rates climb, epoll’s per-event overhead accumulates, whereas io_uring’s design amortizes overhead across many events. In essence, epoll might hit a wall in extreme scale or low-latency requirements, where io_uring continues to scale.

For those interested in conducting their own benchmarks, there are several resources:

  • DylanZA/netbench – a network benchmarking suite that includes tests for multishot receive. This was used to measure the 6–8% QPS gain for multishot vs non-multishot.
  • frevib/io_uring-echo-server – an open-source echo server using io_uring (and comparisons in its issues to epoll). You can modify this to toggle multishot or epoll mode and measure differences.
  • alexhultman/io_uring_epoll_benchmark – a repository by Alex Hultman (author of µWebSockets) that originally showed epoll outperforming io_uring in 2020. It’s a good baseline to see how far io_uring has come (with newer kernel features, many initial overheads have been reduced).

Additionally, standard tools like wrk or iperf can be used against an epoll-based server (e.g. using libevent) and an io_uring-based server (using liburing) to measure differences in throughput and latency distribution. Just ensure the kernel version supports the io_uring features you intend to use (and that they are enabled – some distros might require sysctls or have older kernels).


Implications for Event Loop Architectures

With io_uring’s advanced features, we need to rethink traditional event loop designs. Many high-level frameworks (Node.js’s libuv, Python’s asyncio, Java’s NIO, etc.) are built around epoll/kqueue – i.e., around readiness notification and manually issuing I/O calls. Moving to io_uring suggests a shift to a proactor pattern: submit operations and handle completions. This has several implications:

  • Simpler Application Logic: As noted, code can become more linear. For example, instead of scheduling an epoll_wait and then a recv, you can issue an async recv and directly await completion. In languages that support async/await, io_uring can map nicely to futures that resolve when I/O is done, reducing the state-machine contortions often needed for non-blocking I/O. Callbacks can be replaced by completion events that look like function returns. This could mitigate the classic “callback hell” in event-driven programming, since the flow is more like synchronous code (but under the hood, many ops execute in parallel).
  • Thread Utilization: Epoll encourages a single-threaded event loop (to avoid complexity of locking around the interest list, etc. – though one can use multiple epoll loops for multicore). Io_uring, by design, can scale across threads more easily. Multiple submissions can be happening from different threads (and completions can be polled in one or many threads). The kernel even distributes file I/O to a pool of worker threads automatically. This means event-driven programs can potentially make better use of multi-core systems without sharding connections among loops. In 2025, we see new frameworks experimenting with per-core io_uring instances or even a global io_uring with many producer threads. Essentially, io_uring can act as a concurrency layer, whereas with epoll, concurrency often had to be achieved by running multiple event loop threads.
  • Integration Challenges: It’s not trivial to drop io_uring into an existing reactor-based loop. Libraries like libuv might add io_uring under the hood for file I/O, but for sockets they must maintain cross-platform compatibility, so they stick to epoll on Linux for now. Thus, truly leveraging io_uring may require using Linux-specific code or libraries (like liburing, or frameworks designed for io_uring such as rust::tokio-uring or libuv experimental branches). We are witnessing an architectural divergence: Linux-centric systems (e.g. Cloudflare’s services, ScyllaDB, etc.) are willing to use io_uring for performance, whereas cross-platform frameworks are slower to adopt it. Over time, if io_uring proves clearly superior, it might drive more Linux-specific code paths in frameworks. Indeed, there’s ongoing work in languages like Rust (with glommio and tokio-uring) and C++ (liburing wrappers, or Boost.I/O executors) to provide high-level async APIs on io_uring.
  • Event Loop vs Completion Queue: We might start referring not to an “event loop” but to a “completion processing loop” or similar. The design patterns change: instead of maintaining an interest list and toggling events, the focus is on submission planning and completion handling. Architectures may incorporate features like submission batching strategies, buffer management strategies (to supply buffers for multishot recvs), and completion thread pools (to consume and process results). It’s a higher-level loop of “do work when data arrives” with the kernel doing more of the waiting.
  • Resource Management: With great power comes some complexity – e.g. provided buffer pools need careful sizing and management to avoid ENOBUFS errors (no buffers) on multishot receives. This is an area event loops didn’t manage before (the app would allocate on demand or use static buffers). Now, we pre-allocate maybe thousands of buffers and let the kernel pick them. So frameworks will need components to manage these pools (replenish buffers, perhaps adapt pool size based on traffic). This is a new consideration introduced by io_uring’s approach.
  • Compatibility and Fallbacks: Not all systems or environments are ready for io_uring. For example, certain secure or older environments (older kernels, or some container setups) might not have io_uring enabled or might restrict it due to security concerns (there have been security bugs – ~60% of Linux kernel exploits in Google’s 2022 bug bounty were io_uring-related, leading Google to limit io_uring usage in production for a while). Thus, robust applications should be ready to fall back to epoll if io_uring is unavailable or considered risky in a given deployment. One strategy is to implement an abstraction that can use either – much like how libevent can use select/poll/kqueue/epoll interchangeably. In fact, projects like TigerBeetle have created I/O abstractions that choose io_uring or kqueue depending on OS, presenting a uniform interface to the application. We may see more of this: under the hood, different loop implementations, but hopefully one high-level API.

In short, io_uring doesn’t entirely replace the need for an event loop – rather, it shrinks it and shifts it. The loop is no longer about waiting for readiness and dispatching individual reads/writes; it’s about orchestrating asynchronous operations and handling their completions. This is a profound shift in mindset, and 2025 is the tipping point where enough features (multishot, etc.) and enough stability exist to declare this new model the winner for high-performance Linux servers.


Practical Guidance: Migrating from epoll to io_uring in Production

If you maintain an application or service that currently uses an epoll-based event loop, you might be wondering how and when to migrate to io_uring. Here are some practical tips and considerations:

1. Kernel Version and Environment: First, ensure your production environment supports it. You’ll want Linux kernel 6.0 or newer to use multishot receive (and 5.19+ for multishot accept). Check if your distro’s kernel has io_uring enabled and up to date. For example, as of 2025, Ubuntu 22.04 LTS (with HWE kernel) or 24.04 LTS, and RHEL 9.x (if updated) should have 6.x kernels. If you run on a managed platform (cloud, containers), verify no security restrictions disable io_uring (some container runtimes like Docker had disabled it by default historically, though this is becoming less common).

2. Library Support: Decide if you’ll use a library or raw syscalls. The liburing library (by Axboe) provides convenient C functions and is frequently updated to support new features. It’s recommended to use it rather than crafting your own ring management (for most cases). In higher-level languages, check for io_uring bindings or runtime support. For instance, in Rust you have tokio-uring or glommio; in Python, there’s an liburing wrapper (though Python’s GIL might complicate pure Python usage); in Java, Project Loom might make use of io_uring underneath one day (not yet mainstream). If your stack doesn’t have good io_uring support yet, you might need to write a C/C++ component or wait a bit.

3. Architecture Changes: Plan the refactor of your event loop. As discussed, you can do a two-stage migration:

  • Stage 1: Replace epoll with io_uring’s polling equivalence just to reduce syscalls. For example, you could use IORING_OP_POLL_ADD (or multishot poll) on sockets to get notifications, then still use normal read/write syscalls. This is a gentler change (you keep a similar reactor structure but use io_uring to wait). It may give minor improvements (fewer epoll_ctl calls, etc.), but it won’t fully utilize io_uring’s strengths.
  • Stage 2: Evolve to the proactor model: submit actual read/write operations through io_uring. Here you’ll restructure your code so that instead of handling an epoll event and then doing recv(), you directly post an async receive and handle it in the completion handler. Embrace multishot for recurring ops. This stage is more complex but yields the big wins. You might need to manage buffer pools for multishot recvs (as described above). Test this logic thoroughly – tools like io_uring_cqe_seen() and understanding CQE flags (IORING_CQE_F_MORE, IORING_CQE_F_BUFFER) are important here.

4. Reproducible Testing: Set up benchmark tests to validate performance in your environment. Use the same workload on the epoll version and the io_uring version. Look at not just throughput, but CPU usage and latency percentiles. It’s possible in some scenarios that you might not see gains if the bottlenecks lie elsewhere or if your usage of io_uring is suboptimal. Profile your application. For example, if you see a lot of CPU in io_uring_enter or in kernel io_uring internals and not as much improvement, you may need to increase batching (submit more SQEs before calling submit/wait) or enable SQ polling mode for extreme low latency. If tail latency is fluctuating, ensure your completion queue is adequately sized to handle bursts and that you aren’t running into CQ overflow (which would terminate multishot until you catch up).

5. Gradual Rollout: Considering the relative newness of io_uring, a cautious approach is wise. Perhaps enable the io_uring path for a small percentage of traffic or as an opt-in feature, and monitor stability. io_uring has had bugs (though many have been fixed, and it’s much more stable by 2025), so treat it with the same care you would a new kernel subsystem. That said, many large companies (Databases, CDNs, etc.) have been running it in production by now, so it’s proven as long as you’re on a recent kernel. Keep an eye on kernel patch notes for io_uring fixes relevant to your use (especially if you use advanced features like rings of registered buffers or async cancelation).

6. Use Multishot and Provided Buffers Wisely: These features are powerful but require careful tuning. Allocate enough buffers for your expected concurrency × message size. The kernel will stop a multishot recv if it runs out of buffers (returns -ENOBUFS in CQE). If you see that, it means your buffer pool was exhausted – you may need to increase pool size or use a scheme of multiple buffer groups (one gets refilled while the other is in use). Also, choose buffer sizes smartly: too large and you waste memory, too small and messages might not fit (though if a message doesn’t fit, the kernel can still return what fits and you’ll get another CQE for the rest immediately, but that means one logical packet split into two buffers). Monitoring tools for io_uring are still nascent, so instrument your application to log if you hit unusual CQE errors.

7. Don’t Forget Timeouts and Other Ops: Io_uring also supports multishot timeouts (turning a single timeout request into a periodic timer). If your event loop handled scheduled tasks, you can replace that with an IORING_OP_TIMEOUT with IORING_TIMEOUT_MULTISHOT flag to get timer CQEs. For example, a 1-second multishot timeout will give you a CQE every second – effectively a kernel-driven timer tick. This can simplify scheduling in your loop (no need for a separate timer wheel in user-space for some cases). And since it’s all unified, you could, say, wait for either I/O completions or a timeout completion in the same ring without juggling multiple mechanisms.

8. Be Mindful of Security and Stability: As noted, io_uring has had security issues. Running untrusted code with io_uring access is riskier than using epoll. If your application allows plugins or untrusted interactions at the system call level, ensure you’re on a kernel with all known io_uring patches, or consider restricting io_uring (seccomp etc.). For most server apps this isn’t a direct concern, but it’s one reason some environments like ChromeOS/Android initially disabled io_uring – they have sandboxes with untrusted code. Containerized deployments should ensure the container runtime isn’t blocking io_uring_setup (older Docker did, newer versions allow it).

9. Performance at the Extremes: If you are pushing the limits (e.g. millions of operations per second, microsecond latencies), consider advanced options: SQ poll thread (kernel thread busy-waits on submission queue to cut submission latency to near-zero), registering files (to avoid fd lookups on each op), affinity tuning (binding the io_uring kernel poll thread or worker threads to specific CPUs to improve cache locality). These can further boost performance, though they add complexity. The gains are situational; measure before and after. For instance, SQ polling burns a CPU core but can drop latency by a lot for high-traffic scenarios.

10. Fall back plan: Have an easy way to switch back to epoll if needed (perhaps a compile-time flag or runtime config). This is prudent until you are confident in io_uring’s behavior under all conditions. That said, the momentum is clearly toward io_uring. Every kernel release brings enhancements or fixes, and community knowledge is growing fast.

In summary, migrating to io_uring requires some effort, but the payoff is a system that is future-proof for high I/O loads. You’ll be positioned to take advantage of ongoing kernel innovations (the io_uring framework is still expanding). By “killing the event loop” and embracing this new paradigm, you reduce overhead and open up new performance horizons for your application.


Conclusion

The evolution from select() to epoll() solved the scalability challenge of the early Internet, but it still left developers with a lot of manual work and overhead in managing events. Io_uring represents the next leap: a unified asynchronous I/O model where the kernel does more and the user-space does less. Features like multishot receives and accepts, introduced in the last few years, are the capstone that make io_uring truly surpass the traditional event loop pattern. As of 2025, with broad kernel support and growing ecosystem adoption, io_uring is no longer a niche experimental API – it’s a robust tool ready for prime time in production systems.

We’ve seen how multishot capabilities can improve throughput (no time wasted in re-arming events) and drastically cut down tail latencies by avoiding per-event syscalls. Benchmarks and real-world use are increasingly validating these benefits – from modest QPS gains to major latency improvements under load. Of course, every technology has its trade-offs: we must manage memory (provided buffers) and be vigilant about security updates. But those are solvable issues.

“Kill the event loop” doesn’t mean throw away all loops in code – it means we no longer need the elaborate, often one-per-thread event dispatch machinery that was required with epoll. The kernel becomes the event loop, and does a darn good job of it. This frees developers to focus on higher-level logic, and it frees up CPU cycles that were previously spent shuffling events between kernel and user space.

The implications are exciting: we can design servers that handle more connections with less jitter, write code that is both more readable (straight-line async flows) and higher-performance, and better utilize modern hardware (NVMe, 100GbE, etc.). Languages and frameworks are catching up – expect more native io_uring integrations and more tutorials on writing io_uring-based network apps.

If you’re running on Linux and performance matters, now is the time to seriously consider migrating to io_uring. As one developer succinctly put it, “use io_uring if you can, it’s a MUCH better API than epoll”. The year 2025 may well be remembered as the turning point when the Linux world started retiring the venerable event loop in favor of this new asynchronous paradigm – bringing an end to one era of I/O handling and the dawn of another, where the kernel and user-space work in harmony via ring buffers and completions. The event loop had a great run, but it’s time to send it off with honors; the future of async I/O is already here, in a ring, waiting for its CQEs.