Evolution of Linux I/O Mechanisms
Traditional applications rely on standard File Descriptor (FD) operations where read() and write() block the execution thread until data transfer completes. While this model is simple, high-speed storage devices and complex application architectures have exposed significant performance bottlenecks associated with thread suspension and rescheduling.
In response, Linux introduced libaio (Kernel Native AIO) starting with version 2.6. This library allows developers to offload I/O requests by calling io_submit() and polling for completion via io_getevents(). However, native AIO carries several constraints:
- High Overhead: Frequent system calls required for submission and retrieval cause excessive context switching, degrading CPU efficiency under high IOPS loads.
- I/O Type Restrictions: It typically mandates
O_DIRECT, bypassing the page cache. This restricts utility mostly to specialized database workloads rather than general file systems. - Alignment Requirements: Buffers must strictly align with filesystem block sizes and memory pages, complicating memory management.
- Limited Scalability: The interface was not originally designed for rapid expansion or modern usage patterns.
The io_uring Architecture
io_uring addresses these limitations through a fundamentally different approach centered on shared memory and reduced system call frequency.
- Native Asynchronicity: Requests are pushed to a queue and completed asynchronously with out requiring blocking waits during submission.
- Caching Support: Unlike libaio, io_uring supports both cached (standard) and direct access I/O seamlessly.
- Extensibility: The framework allows system calls to be handled directly within the kernel ring structures, facilitating massive scalability.
Core Components
The communication channel between user space and kernel space relies on three critical memory segments mapped via mmap():
- Submission Queue (SQ): A circular buffer containing indices into the Submission Queue Entry array. Users update the tail pointer to notify the kernel of new requests.
- Completion Queue (CQ): A circular buffer where the kernel posts results once an I/O operation finishes. Users check the head pointer to process outcomes.
- Submission Queue Entries (SQEs): The actual descriptors defining the operation type, file descriptor, memory addresses, and parameters.
Submission Queue Structure
struct io_uring_sq {
volatile unsigned *khead; // Head pointer updated by kernel
volatile unsigned *ktail; // Tail pointer updated by user space
struct io_uring_sqe *sqes; // Pointer to the entry array
unsigned sqe_head; // Current index for allocation
unsigned sqe_tail; // Current index for reading
unsigned ring_mask; // Mask for circular indexing
unsigned ring_entries; // Total entries in the ring
};
Submission Queue Entry (SQE)
This structure encapsulates the specific details of an I/O request. Fields such as opcode determine action (e.g., read/write), addr points to the buffer, and fd specifies the target file handle.
Completion Queue Structure
struct io_uring_cq {
volatile unsigned *khead;
volatile unsigned *ktail;
struct io_uring_cqe *cqes; // Pointer to completion entries
unsigned ring_mask;
unsigned ring_entries;
};
Execution Flow in Kernel Poll Mode
When configured correctly (often requiring the O_DIRECT flag), a dedicated kernel thread named io_uring-sq manages the workflow:
- Application writes a request index to the Submission Queue tail.
- The kernel thread picks up the request and enitiates the hardware operation.
- Upon hardware completion, the kernel thread writes the result (status, bytes transferred) to the Completion Queue.
- The application polls or waits on the Completion Queue to retrieve results.
Implementation Example
The following snippet demonstrates setting up a uring context, preparing asynchronous reads, and processing completions. Note the use of aligned memory allocation for performance optimization.
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include "liburing.h"
/* Define the depth of the ring buffer */
#define RING_DEPTH 8
int main(int argc, char *argv[])
{
/* Validate arguments */
if (argc < 2) {
fprintf(stderr, "Usage: %s <filename>\n", argv[0]);
return 1;
}
struct io_uring uring_context;
int file_descriptor;
void *aligned_buffer;
ssize_t file_total_size = 0;
struct iovec io_vector[RING_DEPTH];
/* Initialize the uring instance */
if (io_uring_queue_init(RING_DEPTH, &uring_context, 0) != 0) {
perror("Failed to initialize queue");
return 1;
}
/* Open file with O_DIRECT to leverage native performance features */
file_descriptor = open(argv[1], O_RDONLY | O_DIRECT);
if (file_descriptor < 0) {
perror("Error opening file");
io_uring_queue_exit(&uring_context);
return 1;
}
/* Retrieve file metadata to calculate total size */
struct stat stats;
fstat(file_descriptor, &stats);
file_total_size = stats.st_size;
/* Allocate memory aligned to 4KB boundaries */
aligned_buffer = NULL;
posix_memalign(&aligned_buffer, 4096, 4096);
/* Setup vector structure for buffered I/O */
io_vector[0].iov_base = aligned_buffer;
io_vector[0].iov_len = 4096;
/* Prepare multiple read submission entries (SQEs) in a single batch */
int pending_requests = 0;
off_t current_offset = 0;
for (int i = 0; i < RING_DEPTH; i++) {
if (current_offset + io_vector[0].iov_len > file_total_size)
break;
struct io_uring_sqe *submit_req = io_uring_get_sqe(&uring_context);
if (!submit_req) break;
/* Configure the read operation within the SQE */
io_uring_prep_readv(submit_req, file_descriptor, &io_vector[i], 1, current_offset);
current_offset += io_vector[0].iov_len;
pending_requests++;
}
/* Submit the batch of requests to the kernel */
int sub_result = io_uring_submit(&uring_context);
if (sub_result < pending_requests) {
perror("Submission incomplete");
io_uring_queue_exit(&uring_context);
close(file_descriptor);
return 1;
}
/* Process completed events */
int processed_count = 0;
struct io_uring_cqe *comp_event;
size_t total_bytes_read = 0;
while (processed_count < pending_requests) {
int ret = io_uring_wait_cqe(&uring_context, &comp_event);
if (ret) break;
if (comp_event->res < 0) {
perror("I/O Error detected");
io_uring_cqe_seen(&uring_context, comp_event);
continue;
}
total_bytes_read += comp_event->res;
io_uring_cqe_seen(&uring_context, comp_event); /* Mark event as handled */
processed_count++;
}
/* Cleanup resources */
printf("Transferred %zu bytes successfully\n", total_bytes_read);
free(aligned_buffer);
close(file_descriptor);
io_uring_queue_exit(&uring_context);
return 0;
}