How io_uring Submission Queue Polling Actually Works
Discover how Linux io_uring submission queue polling (SQPOLL) eliminates syscall overhead using in-kernel polling threads, shared rings, and memory barriers.

Introduction
In modern high-throughput Linux network servers and storage engines, the system call interface remains a primary performance bottleneck. Historically, standard synchronous I/O operations and event notification mechanisms like epoll necessitated crossing the user-kernel boundary via privileged hardware traps (sysenter or syscall). Each trap incurs translation lookaside buffer (TLB) flushes, kernel page table isolation (KPTI) overhead, and CPU register state preservation. Linux's io_uring subsystem fundamentally alters this datapath through shared ring buffers. At the apex of this design is io_uring submission queue polling (SQPOLL), an execution mode that delegates asynchronous request harvesting to a dedicated in-kernel kthread, completely eliminating system calls from the steady-state submission datapath.
When configured with the IORING_SETUP_SQPOLL flag during ring initialization, the kernel spawns a background thread—named io_sq_thread—that actively polls the shared submission ring. Instead of invoking io_uring_enter(2) to notify the kernel of newly enqueued work, the user-space thread updates an atomic tail index in shared memory. The kernel thread picks up the submission queue entries (SQEs), dispatches them directly to the underlying block layer or network device driver, and posts completion queue entries (CQEs) to the shared completion ring.
Understanding how SQPOLL operates beneath the abstraction requires examining memory mappings, single-producer single-consumer ring buffer mechanics, processor memory ordering fences, and CPU thread affinity. While SQPOLL offers ultra-low submission latencies and millions of IOPS per core, it radically changes resource allocation by dedicating hardware execution units entirely to kernel polling loops.
Memory Layout and Shared Ring Buffer Primitives
The core architectural innovation of io_uring is its use of double-mapped circular ring buffers between user space and kernel space. A standard application interacts with two distinct rings: the Submission Queue (SQ) and the Completion Queue (CQ). When SQPOLL is enabled, the memory layout remains conceptually identical, but the concurrency model transforms: the kernel thread acts as the consumer of the SQ and the producer of the CQ, while the application acts as the producer of the SQ and the consumer of the CQ.
+-----------------------------------------------------------------------------+
| USER SPACE |
| |
| +--------------------------+ +----------------------------+ |
| | Submission Queue (SQ) | | Completion Queue (CQ) | |
| | head (RO) tail (RW) | | head (RW) tail (RO) | |
| +-------------+------------+ +--------------^-------------+ |
| | | |
+-----------------|-----------------------------------------|-----------------+
| | Shared Memory Mappings (mmap) | |
+-----------------|-----------------------------------------|-----------------+
| v | |
| +--------------------------+ +--------------+-------------+ |
| | sq_ring->head (RW) | | cq_ring->tail (RW) | |
| | sq_ring->tail (RO) | | cq_ring->head (RO) | |
| +-------------+------------+ +----------------------------+ |
| | |
| v |
| [ io_sq_thread ] (Kernel Polling Thread) |
| | |
| +--- Submits to VFS / Block Layer / Drivers |
| |
| KERNEL SPACE |
+-----------------------------------------------------------------------------+
The user application calls io_uring_setup(2) with the struct io_uring_params initialized. The kernel allocates the submission ring, completion ring, and an array of struct io_uring_sqe entries. The application then uses mmap(2) on the returned ring file descriptor with specific memory offsets:
IORING_OFF_SQ_RING: Maps the submission ring state, which contains metadata includinghead,tail,ring_mask,ring_entries, andflags.IORING_OFF_CQ_RING: Maps the completion ring state containinghead,tail, and the array ofstruct io_uring_cqe.IORING_OFF_SQES: Maps the actual contiguous array ofstruct io_uring_sqeelements.
The SQ ring does not hold the full submission descriptors directly. Instead, it contains an array of 32-bit integer indices pointing into the separate io_uring_sqe buffer. This indirect indexing permits out-of-order array population while enforcing strict sequential tail increments.
Synchronizing these pointers without system calls requires strict adherence to memory-ordering primitives. As explored in Designing Lock-Free Shared-Memory Ring Buffers: Cache-Coherence, Memory Barriers, and Kernel-Bypass IPC, the producer writes data, issues a memory release barrier, and then updates the tail. The consumer loads the tail with acquire semantics, reads the data, and updates the head with release semantics. In standard io_uring, the application calls io_uring_enter(2) to commit entries; under SQPOLL, writing to the tail is immediately visible to the concurrently running io_sq_thread.
Anatomy of io_uring Submission Queue Polling
When the IORING_SETUP_SQPOLL flag is passed to the kernel, io_uring_setup(2) creates an associated kernel thread (io_sq_thread) managed inside fs/io_uring.c (or io_uring/sqpoll.c in Linux 5.10+ and modern 6.x kernels). This thread runs an execution loop modeled as an adaptive polling engine.
The thread alternates between three distinct behavioral states: active harvesting, idle spinning, and sleeping.
+-------------------------------------------------------+
| |
v |
+--------------+ Work Found +----------------+ |
| Harvest | ----------------------> | Dispatch | |
| SQEs | <---------------------- | Direct / WQ | |
+--------------+ Batch Complete +----------------+ |
| |
| No New SQEs |
v |
+--------------+ |
| Spin / Idle | |
| (Timeout?) | |
+--------------+ |
| |
| Elapsed > sq_thread_idle |
v |
+--------------+ IORING_ENTER_SQ_WAKEUP +--------+ |
| Sleep | ------------------------------> | Wakeup | ---+
| (Blocked) | +--------+
+--------------+
1. Active Harvesting and Dispatch
The io_sq_thread repeatedly reads ctx->rings->sq.tail using smp_load_acquire() and compares it against ctx->rings->sq.head. If tail != head, new entries are present. The thread computes the batch size:
$$\text{to_submit} = \text{tail} - \text{head}$$
The kernel clamps this value to the maximum ring capacity to protect against corrupted user indices. For each entry, it loads the index from sq_array[head & ring_mask], retrieves the io_uring_sqe, and processes the operation.
Depending on the operation type, requests take one of two paths:
- Non-blocking inline execution: Operations on sockets supporting non-blocking states, or file systems with cached pages, are executed directly in the context of
io_sq_thread. - Asynchronous worker thread offloading (
io-wq): If an operation blocks on synchronous disk I/O, storage controller queues, or unbuffered page faults,io_sq_threaddelegates execution to the kernel's asynchronous workqueue system (io-wq). This ensures that the primary polling thread does not stall, maintaining continuous submission harvesting for subsequent requests.
2. Idle Spinning and Timeout
When the thread observes head == tail, no entries are available. Entering an immediate sleep state would introduce scheduler overhead, yielding unacceptable latency spikes if the application produces an SQE microseconds later.
To resolve this, the kernel implements an idle backoff timeout specified by the sq_thread_idle field in struct io_uring_params (configured in milliseconds). The thread loops, continually checking the submission ring tail while invoking cond_resched() to avoid starving other processes if the core is not strictly isolated. If no work appears before sq_thread_idle expires, the thread prepares to sleep.
3. Sleep and Wakeup Mechanics
Before yielding the CPU, io_sq_thread sets the IORING_SQ_NEED_WAKEUP bit flag in ctx->rings->sq_flags:
/* Kernel-side transition to sleep */
spin_lock(&ctx->completion_lock);
if (sq_ring_needs_idle(ctx)) {
ctx->rings->sq_flags |= IORING_SQ_NEED_WAKEUP;
smp_mb(); /* Ensure flag visibility before re-checking tail */
if (!io_sqring_entries(ctx)) {
set_current_state(TASK_INTERRUPTIBLE);
schedule();
}
}
spin_unlock(&ctx->completion_lock);
When the user-space application prepares to submit new work, it inspects the flags field in the mapped SQ ring. If IORING_SQ_NEED_WAKEUP is cleared, it writes the SQE, updates sq->tail, and proceeds immediately without making a syscall.
If IORING_SQ_NEED_WAKEUP is set, the application knows the kernel thread has gone to sleep. It must invoke:
io_uring_enter(ring_fd, to_submit, 0, IORING_ENTER_SQ_WAKEUP, NULL);
This wake-up syscall transitions io_sq_thread from TASK_INTERRUPTIBLE back to TASK_RUNNING, clears the IORING_SQ_NEED_WAKEUP flag, and restarts the polling loop.
Kernel Implementation and Memory Barriers
Building high-performance event loops on top of SQPOLL requires rigorous synchronization. High-level abstractions and runtime models, such as those found in High-Throughput Deterministic Actor Runtimes: Cache-Conscious Architectures, Lock-Free Rings, and Kernel-Bypass I/O, rely heavily on fine-grained control over compiler and CPU memory ordering.
The following user-space implementation demonstrates raw SQPOLL submission without third-party wrapper dependencies, highlighting the synchronization requirements:
#define _GNU_SOURCE
#include
#include
#include
#include
#include
#include
#include
#include
#include
struct app_sq_ring {
unsigned *head;
unsigned *tail;
unsigned *ring_mask;
unsigned *ring_entries;
unsigned *flags;
unsigned *array;
};
struct app_cq_ring {
unsigned *head;
unsigned *tail;
unsigned *ring_mask;
unsigned *ring_entries;
struct io_uring_cqe *cqes;
};
struct app_io_context {
int ring_fd;
struct app_sq_ring sq_ring;
struct app_cq_ring cq_ring;
struct io_uring_sqe *sqes;
};
int init_sqpoll_ring(struct app_io_context *ctx, unsigned entries, unsigned idle_ms, unsigned cpu_affinity) {
struct io_uring_params p;
memset(&p, 0, sizeof(p));
p.flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF;
p.sq_thread_idle = idle_ms;
p.sq_thread_cpu = cpu_affinity;
ctx->ring_fd = syscall(__NR_io_uring_setup, entries, &p);
if (ctx->ring_fd < 0) {
perror("io_uring_setup");
return -1;
}
size_t sq_ring_size = p.sq_off.array + p.sq_entries * sizeof(unsigned);
size_t cq_ring_size = p.cq_off.cqes + p.cq_entries * sizeof(struct io_uring_cqe);
void *sq_ptr = mmap(0, sq_ring_size, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, ctx->ring_fd, IORING_OFF_SQ_RING);
void *cq_ptr = mmap(0, cq_ring_size, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_POPULATE, ctx->ring_fd, IORING_OFF_CQ_RING);
ctx->sqes = mmap(0, p.sq_entries * sizeof(struct io_uring_sqe),
PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,
ctx->ring_fd, IORING_OFF_SQES);
ctx->sq_ring.head = (unsigned *)((char *)sq_ptr + p.sq_off.head);
ctx->sq_ring.tail = (unsigned *)((char *)sq_ptr + p.sq_off.tail);
ctx->sq_ring.ring_mask = (unsigned *)((char *)sq_ptr + p.sq_off.ring_mask);
ctx->sq_ring.ring_entries = (unsigned *)((char *)sq_ptr + p.sq_off.ring_entries);
ctx->sq_ring.flags = (unsigned *)((char *)sq_ptr + p.sq_off.flags);
ctx->sq_ring.array = (unsigned *)((char *)sq_ptr + p.sq_off.array);
ctx->cq_ring.head = (unsigned *)((char *)cq_ptr + p.cq_off.head);
ctx->cq_ring.tail = (unsigned *)((char *)cq_ptr + p.cq_off.tail);
ctx->cq_ring.ring_mask = (unsigned *)((char *)cq_ptr + p.cq_off.ring_mask);
ctx->cq_ring.cqes = (struct io_uring_cqe *)((char *)cq_ptr + p.cq_off.cqes);
return 0;
}
void submit_write_sqpoll(struct app_io_context *ctx, int fd, const void *buf, unsigned len, off_t offset) {
struct app_sq_ring *sq = &ctx->sq_ring;
unsigned tail = atomic_load_explicit((_Atomic unsigned *)sq->tail, memory_order_relaxed);
unsigned head = atomic_load_explicit((_Atomic unsigned *)sq->head, memory_order_acquire);
unsigned mask = *sq->ring_mask;
if (tail - head >= *sq->ring_entries) {
// Queue full, handling strategy omitted for brevity
return;
}
unsigned index = tail & mask;
struct io_uring_sqe *sqe = &ctx->sqes[index];
memset(sqe, 0, sizeof(*sqe));
sqe->opcode = IORING_OP_WRITE;
sqe->fd = fd;
sqe->addr = (unsigned long)buf;
sqe->len = len;
sqe->off = offset;
sqe->user_data = 0x42ULL;
sq->array[index] = index;
// Release barrier: ensure all writes to the SQE are globally visible
// before the tail index increment becomes visible to io_sq_thread.
atomic_store_explicit((_Atomic unsigned *)sq->tail, tail + 1, memory_order_release);
// Acquire barrier on flags: read whether the kernel thread is asleep
atomic_thread_fence(memory_order_seq_cst);
unsigned flags = atomic_load_explicit((_Atomic unsigned *)sq->flags, memory_order_relaxed);
if (flags & IORING_SQ_NEED_WAKEUP) {
// io_sq_thread is sleeping, execute system call to trigger wake-up
syscall(__NR_io_uring_enter, ctx->ring_fd, 0, 0, IORING_ENTER_SQ_WAKEUP, NULL, 0);
}
}
Eliminating Internal Kernel Indirection: Fixed Files and Buffers
SQPOLL alone removes the context switch of io_uring_enter(2). However, when processing millions of operations per second, secondary overheads within the kernel dispatch path become prominent:
- File Table Reference Bumping: Standard POSIX operations require the kernel to look up the file descriptor in the process's file descriptor table, atomically incrementing the
struct filereference count viafget()and decrementing it viafput()upon completion. Under multithreaded contention, this causes heavy cache line bouncing on the file reference atomic counter. - Virtual Address Translation: For every buffer pointer submitted in
sqe->addr, the kernel must pin physical pages, look up the virtual memory area (VMA), construct page tables, and establish memory mappings.
To fully exploit SQPOLL, applications combine it with fixed files and pre-registered buffers:
- Registered Files (
IORING_REGISTER_FILES): The application passes an array of file descriptors to the kernel ahead of time. The kernel stores them directly within an array in theio_ring_ctxstructure. In SQE submissions, the application sets the flagIOSQE_FIXED_FILE, andsqe->fdis interpreted as an index into this pre-validated kernel array. This bypassesfget()andfput()entirely, avoiding atomic locks on file descriptors. - Registered Buffers (
IORING_REGISTER_BUFFERS): The application registers a set of user-space buffers usingio_uring_register(2). The kernel pins these memory pages viapin_user_pages()during setup. Subsequent operations useIORING_OP_READ_FIXEDorIORING_OP_WRITE_FIXED. Physical address translation occurs once during initialization, completely removing translation overhead from the execution loop.
Integrating SQPOLL with registered buffers and files forms the foundation of modern high-performance event loop implementations, as examined in Architecting Zero-Copy Event Loops: Cache-Line-Aware Memory Arenas, Kernel Bypass, and Lock-Free Ring Buffers.
Core Pinning, NUMA Topology, and CPU Overhead
While SQPOLL achieves exceptional throughput, its physical execution characteristics require careful architectural planning. Unlike classic event-driven reactors using epoll, which yield the CPU to the operating system when idle, io_sq_thread consumes 100% of a CPU core as long as it does not hit its idle timeout.
Affinity Configuration with IORING_SETUP_SQ_AFF
When initializing an SQPOLL ring, applications should explicitly specify the target CPU core using IORING_SETUP_SQ_AFF alongside sq_thread_cpu. Without explicit affinity pinning, the kernel scheduler assigns io_sq_thread to any available logical processor, leading to erratic cache locality and cross-core context migration.
struct io_uring_params p = {
.flags = IORING_SETUP_SQPOLL | IORING_SETUP_SQ_AFF,
.sq_thread_idle = 1000, /* 1000ms before falling asleep */
.sq_thread_cpu = 4 /* Pin the kernel thread strictly to Core 4 */
};
When designing an architecture around SQPOLL, engineers typically partition system CPUs into two categories:
- User Worker Cores: Running the application's user-space threads, driving application business logic, connection handling, and updating SQ rings.
- I/O Engine Cores: Isolated cores (configured with the Linux kernel boot parameters
isolcpusandnohz_full) dedicated exclusively to running theio_sq_threadinstances.
NUMA Topology and Cache Line Invalidation
The memory structures shared between the user-space thread and io_sq_thread—specifically the SQ head/tail pointers and the SQE arrays—are subject to standard cache coherency protocols (MESI/MOESI).
+-----------------------------------------------------------------------------------+
| NUMA NODE 0 |
| +---------------------------+ +-----------------------------------+ |
| | CPU Core 2 (User) | | CPU Core 4 (io_sq_thread) | |
| | L1/L2 Unified Cache | | L1/L2 Unified Cache | |
| +-------------+-------------+ +-----------------+-----------------+ |
| | | |
| +----------------------+----------------------+ |
| | |
| +-------------v-------------+ |
| | Shared L3 Cache (LLC) | |
| +-------------+-------------+ |
| | |
| +-------------v-------------+ |
| | Local DRAM Memory | |
| +---------------------------+ |
+-----------------------------------------------------------------------------------+
If the application thread executes on NUMA Node 0 and io_sq_thread runs on a core located on NUMA Node 1:
- Every update to
sq->tailby the user thread triggers a cache invalidate across the interconnect (Intel UPI or AMD Infinity Fabric). - Every read and update to
sq->headby the kernel thread must travel across the socket boundary. - The latency of the ring pointer update increases from ~10–15 ns (local LLC hit) to >80–120 ns (cross-socket interconnect traversal), largely defeating the latency advantage of SQPOLL.
To achieve maximum performance:
- Bind the application thread to a specific core using
pthread_setaffinity_np(). - Allocate the user application's memory arenas from the same NUMA node using
numa_alloc_onnode()ormbind(). - Pin the
io_sq_threadusingIORING_SETUP_SQ_AFFto an adjacent core sharing the same L3 cache slice on that exact same NUMA node.
The Problem of CPU Throttling and Battery/Power Budgets
Because an active io_sq_thread consumes 100% of its pinned CPU core while actively polling, running multiple SQPOLL instances across numerous rings can quickly saturate host CPU capacity.
In Linux 5.13 and later, io_uring introduced the IORING_SETUP_ATTACH_WQ capability, enabling multiple distinct rings to share a single io_sq_thread instance. When multiple event loops or actor runtimes exist within a single process, attaching secondary rings to an existing SQPOLL worker eliminates redundant polling threads:
struct io_uring_params p2;
memset(&p2, 0, sizeof(p2));
p2.flags = IORING_SETUP_SQPOLL | IORING_SETUP_ATTACH_WQ;
p2.wq_fd = ctx1.ring_fd; /* Share io_sq_thread with ring 1 */
int ring_fd2 = syscall(__NR_io_uring_setup, entries, &p2);
Under this configuration, a single polling thread multiplexes submission queues from multiple rings, preserving core allocation while retaining syscall-free operations.
Evaluating SQPOLL: When to Use and When to Avoid
| Metric / Dimension | Standard io_uring (Non-SQPOLL) | SQPOLL (Active Polling) |
|---|---|---|
| System Call Overhead | Low (batched io_uring_enter(2)) |
Zero in steady-state; occasional wakeup calls |
| I/O Submission Latency | Direct kernel transition latency (~800ns - 1.5µs) | Immediate shared memory write (<50ns if local LLC) |
| CPU Core Consumption | Scaled linearly to operational activity | Dedicated 100% core saturation during poll window |
| Core Pinning Requirement | Optional; managed by kernel scheduler | Highly critical; improper NUMA affinity degrades gains |
| Scalability Limit | Scales well across hundreds of client rings | Constrained by hardware core counts and thread pinning |
| Setup Complexity | Minimal configuration | Requires thread affinity, idle tuning, and wake-up paths |
Architectural Recommendations
Deploy SQPOLL if:
- You are engineering predictable, ultra-low-latency financial order routers, memory-mapped flash databases, or high-packet-rate network gateways.
- Your target infrastructure features isolated, unreserved CPU cores capable of running dedicated kernel threads without starving critical workloads.
- The I/O rate is consistently high enough to keep the polling thread saturated, preventing the overhead of cyclic sleep and
IORING_ENTER_SQ_WAKEUPcycles.
Avoid SQPOLL if:
- Your system manages thousands of idle or bursty connections (such as general-purpose web applications). Generating hundreds of
io_sq_threadinstances will overwhelm the OS scheduler and cause severe core contention. - Workloads run within multi-tenant container environments (e.g., Kubernetes pods) subject to strict CFS CPU bandwidth throttling (
cpu.cfs_quota_us). The 100% spin loop ofio_sq_threadwill instantly consume CPU quotas, triggering severe execution throttling.
Conclusion
Submission queue polling represents a paradigm shift in Linux systems architecture. By combining single-producer single-consumer circular buffers with an in-kernel polling thread, io_uring decouples I/O submission from the traditional trap-based hardware system call model.
In this architecture, operating system execution models shift toward shared-memory, lock-free state machines. However, these performance advantages require precise systems engineering: developers must account for memory barrier semantics, manage hardware cache-line synchronization, configure NUMA affinity, and manage dedicated physical CPU cores. When implemented thoughtfully, SQPOLL enables user applications to achieve near-silicon storage and network hardware performance entirely from user space.
References
- Linux Kernel Documentation: io_uring
https://www.kernel.org/doc/html/latest/io_uring/index.html - Efficient IO with io_uring (Jens Axboe, Linux Kernel Architect)
https://kernel.dk/io_uring.pdf - Linux Kernel Source: io_uring/sqpoll.c
https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/io_uring/sqpoll.c