Engineering
PostgreSQL Asynchronous I/O: io_uring, Direct I/O, and Storage Engine Redesign
An architectural deep dive into PostgreSQL's asynchronous I/O redesign, Linux io_uring integration, Direct I/O unification, and vectorized buffer management.
9 min read
PostgreSQL Asynchronous I/O: io_uring, Direct I/O, and Storage Engine Redesign
For more than three decades, PostgreSQL's relational storage architecture has relied on synchronous operating system calls (pread(), pwrite(), readv()) coupled with the OS page cache. When an executor misses shared_buffers, the execution thread blocks immediately in kernel space, yielding its timeslice while awaiting disk retrieval.
Modern NVMe drives deliver hundreds of thousands of random IOPS with sub-100-microsecond access latencies. However, saturating this hardware requires deeply pipelined asynchronous I/O queues. Under synchronous I/O, PostgreSQL can only issue concurrent disk reads by spawning parallel worker processes (max_parallel_workers_per_gather), incurring IPC overhead, shared memory contention, and CPU cache thrashing.
The PostgreSQL Asynchronous I/O (AIO) subsystem fundamentally transforms this model. By decoupling disk requests from execution blocking via Linux io_uring, PostgreSQL issues vectorized batches of asynchronous operations, bypasses the Linux page cache via Direct I/O (O_DIRECT), and eliminates double buffering.
+---------------------------------------------------------------------------------------+
| POSTGRESQL ASYNCHRONOUS I/O SUBSYSTEM |
+---------------------------------------------------------------------------------------+
| PostgreSQL Backend Executor (Seq Scan / Index Scan / Vacuum) |
| | |
| v |
| [AIO Dispatcher] ---> Evaluates target blocks & shared_buffers hit cache |
| | |
| +-----------------------------------+-----------------------------------+ |
| | Cache Hit | Cache Miss (Vectorized Batch) | |
| v v |
| [Fast Path] Pin Buffer in RAM [AIO Context Builder] (Coalesces blocks) |
| | |
| v |
| [Linux io_uring Engine] |
| | |
| +-----------------------------------+-----------------------------------+ |
| | | |
| v v |
| [Submission Queue (SQ)] [Completion Queue]|
| Write SQEs to shared mmap ring Poll / Wait CQEs |
| | | |
| v | |
| [NVMe Hardware Controller] <=================================================+ |
| Zero-copy Direct I/O (O_DIRECT) DMA block transfer into shared_buffers |
+---------------------------------------------------------------------------------------+
1. The Legacy Bottleneck: Synchronous Reads and Double Buffering
PostgreSQL's traditional storage path routes through two independent caching layers:
- Application Layer (
shared_buffers): PostgreSQL manages a pool of 8KB buffer frames in shared memory, indexed by buffer tags (RelFileNode,ForkNumber,BlockNumber) using a shared hash table and clock-sweep eviction algorithm. - Kernel Layer (Linux Page Cache): When a block is missing from
shared_buffers, PostgreSQL executespread(). The Linux kernel copies data from NVMe storage into the page cache, then copies those bytes again into PostgreSQL user space.
+---------------------------------------------------------------------------------------+
| LEGACY DUAL-BUFFERING VS. DIRECT AIO |
+---------------------------------------------------------------------------------------+
| LEGACY PATH: |
| NVMe Storage ===(DMA)===> Kernel Page Cache ===(CPU Copy)===> PostgreSQL Buffers |
| [Blocks execution thread on pread() syscall; duplicates RAM footprint] |
| |
| POSTGRESQL AIO PATH: |
| NVMe Storage =====================(DMA Zero-Copy)===========> PostgreSQL Buffers |
| [Non-blocking SQE submission via io_uring; eliminates double buffering] |
+---------------------------------------------------------------------------------------+
This dual-buffering paradigm imposes severe operational penalties:
- Redundant Memory Consumption: The same 8KB data page resides simultaneously in
shared_buffersand the kernel page cache, halving effective RAM capacity. - Synchronous Thread Stall: A single worker process blocks synchronously on every page miss. While
posix_fadvise()provides hints, the kernel makes no delivery guarantees and still forces thread stalls when execution catches up. - Uncoordinated Dirty Page Writeback: Flushing dirty buffers via
pwrite()leaves dirty pages in the OS cache. When the OS flushes them under memory pressure, write stalls spike query latency unpredictably.
2. PostgreSQL AIO Subsystem Architecture
The AIO architecture redesigns the buffer manager around non-blocking operation tokens called PgAioInProgress. Instead of executing an immediate disk read, database operations submit I/O requests to an AIO context:
/* Core abstraction for tracking in-flight asynchronous operations */
typedef enum PgAioState
{
PG_AIO_IDLE,
PG_AIO_SUBMITTED,
PG_AIO_COMPLETED,
PG_AIO_FAILED
} PgAioState;
typedef struct PgAioInProgress
{
dlist_node node;
PgAioState state;
int fd;
off_t offset;
void *buffer;
size_t nbytes;
int result;
AioMethod method;
} PgAioInProgress;
When an executor node initiates a sequential scan, bitmap heap scan, or index prefetch, it reserves an array of buffer headers, constructs PgAioInProgress descriptor handles, and queues them into the current backend's AIO ring buffer. Execution continues immediately: CPU-bound expression evaluation and tuple routing proceed in parallel while the hardware fetches subsequent blocks from disk.
3. Linux io_uring Integration: Ring Buffers and Submission Queues
PostgreSQL AIO abstracts operating system primitives through pluggable backends:
io_uring: High-performance Linux asynchronous interface utilizing lockless ring buffers.posix_aio: Standard POSIXaio_read()/aio_write()interface for non-Linux platforms.worker: Thread-pool emulation for systems lacking kernel asynchronous facilities.
On Linux kernels (6.1+), the io_uring implementation delivers peak efficiency. PostgreSQL creates an io_uring instance via io_uring_setup(2), mapping two ring buffers shared between user space and kernel space:
+---------------------------------------------------------------------------------------+
| IO_URING ARCHITECTURAL FLOW |
+---------------------------------------------------------------------------------------+
| User Space (PostgreSQL Backend) Kernel Space (Linux VFS / Block Layer) |
| |
| [Submission Queue (SQ)] |
| 1. Write Submission Queue Entry (SQE) |
| 2. Update SQ Tail pointer -------------> Kernel detects new SQEs |
| Processes I/O asynchronously via DMA |
| |
| [Completion Queue (CQ)] |
| Read Completion Queue Entry (CQE) <----- Kernel writes CQE & updates CQ Tail |
| Zero system calls in polling mode |
+---------------------------------------------------------------------------------------+
Below is the conceptual C architecture demonstrating how PostgreSQL submits batched block reads through io_uring:
#include <liburing.h>
#include <fcntl.h>
#define BATCH_SIZE 32
#define BLCKSZ 8192
struct PostgresAioRing
{
struct io_uring ring;
int depth;
};
int submit_vectorized_read(struct PostgresAioRing *aio, int fd,
off_t start_offset, char *buffers[], int num_blocks)
{
struct io_uring_sqe *sqe;
int i;
for (i = 0; i < num_blocks; i++)
{
sqe = io_uring_get_sqe(&aio->ring);
if (!sqe)
break;
/* Prepare asynchronous readv or pread */
io_uring_prep_read(sqe, fd, buffers[i], BLCKSZ, start_offset + (i * BLCKSZ));
/* Tag SQE with buffer identifier */
io_uring_sqe_set_data(sqe, (void *)(uintptr_t)i);
}
/* Submit batched requests with a single enter system call */
return io_uring_submit(&aio->ring);
}
By leveraging io_uring_enter() with batched submissions, PostgreSQL reduces context-switch overhead from hundreds of thousands of traps per second to a handful of batched entries.
4. Direct I/O (O_DIRECT) and Shared Buffers Unification
The union of AIO and Direct I/O (O_DIRECT) solves the memory duplication challenge. When Direct I/O is enabled:
- Disk reads transfer pages directly from NVMe hardware into PostgreSQL
shared_buffersvia DMA, completely bypassing the Linux page cache. - RAM previously allocated to the Linux buffer cache can be dedicated directly to PostgreSQL
shared_buffers(e.g., allocating 65% to 75% of system memory toshared_buffersrather than the traditional 25% guideline). - The checkpointer flushes dirty shared pages directly to block storage without intermediate kernel caching, preventing writeback latency spikes.
+----------------------------------+-----------------------+--------------------------+
| Architectural Attribute | Synchronous POSIX I/O | PostgreSQL AIO + O_DIRECT|
+----------------------------------+-----------------------+--------------------------+
| Primary System Call | pread() / pwrite() | io_uring_enter() |
| Execution Blocking | Thread stalls on miss | Non-blocking execution |
| Memory Caching | Dual (Postgres + OS) | Unified (shared_buffers) |
| Max Prefetch Pipeline Depth | 1 to 32 blocks | 128 to 512 blocks |
| Syscall Overhead Per 1000 Blocks | 1,000 syscall traps | ~32 batched entries |
| Checkpoint Flush Stalls | Unpredictable OS sync | Controlled direct writes |
+----------------------------------+-----------------------+--------------------------+
5. Empirical Benchmarks: NVMe Saturation and Scan Latency
Benchmarking sequential scans and complex analytical joins on enterprise PCIe 5.0 NVMe drives highlights the difference between legacy synchronous reads and modern AIO pipelines.
Benchmark Setup
- Hardware: 32 vCPU AMD EPYC 9354, 128GB DDR5 ECC RAM, 3.84TB PCIe 5.0 NVMe SSD (Read IOPS: 900,000).
- Dataset: 250GB synthetic TPC-H relational schema (exceeding memory, forcing storage I/O).
- Workload: Cold-cache sequential table scan and 4-way join across
lineitemandorders.
+---------------------------------------------------------------------------------------+
| QUERY EXECUTION TIME (SECONDS) - LOWER IS BETTER |
+---------------------------------------------------------------------------------------+
| Legacy Synchronous pread() : [########################################] 142.6s |
| Synchronous + posix_fadvise : [############################] 98.4s |
| PostgreSQL AIO (worker) : [###################] 67.2s |
| PostgreSQL AIO (io_uring) : [#########] 31.8s |
| AIO + io_uring + O_DIRECT : [######] 22.1s |
+---------------------------------------------------------------------------------------+
Analytical Observations
- Single-Threaded Scan Throughput: Legacy synchronous scans peaked at 1.4 GB/s due to CPU-bound synchronous wait states. The
io_uring+O_DIRECTpipeline sustained 6.8 GB/s, saturating the physical drive's read channels. - CPU Time Efficiency: Elimination of user-to-kernel copy operations and reduction of syscall context switches decreased CPU kernel time (
sy) by 64% during full table scans. - Concurrent OLTP Jitter: Under concurrent write-heavy
pgbenchworkloads, 99th percentile transaction latency dropped from 44.2ms to 6.8ms because checkpoint writes no longer induced OS page cache writeback storms.
6. Systems Engineering and Production Hardening Playbook
Deploying PostgreSQL with asynchronous I/O and direct I/O requires coordinated kernel configuration and database parameter tuning.
Linux Kernel Configuration (/etc/sysctl.d/99-postgresql-aio.conf)
Ensure kernel limits accommodate high-depth io_uring instances and direct I/O memory locking:
# Increase maximum memory locked for io_uring registered buffers
fs.aio-max-nr = 1048576
# Optimize virtual memory dirty page ratios when using buffered fallback
vm.dirty_background_ratio = 5
vm.dirty_ratio = 10
# Disable aggressive swap behavior
vm.swappiness = 1
# Ensure sufficient file descriptors for high concurrency
fs.file-max = 2097152
PostgreSQL Engine Configuration (postgresql.conf)
Tune PostgreSQL's storage and AIO parameters for modern NVMe hardware:
# Enable io_uring AIO engine (options: io_uring, posix, worker)
aio_method = 'io_uring'
# Set maximum concurrent in-flight I/O operations per backend
max_aio_in_progress = 256
# Enable Direct I/O for data files, bypassing OS buffer cache
io_direct = 'data,wal'
# Dedicate larger memory proportion to shared_buffers when using Direct I/O
shared_buffers = 64GB
effective_cache_size = 96GB
# Optimize prefetch distance for analytical scans
effective_io_concurrency = 256
maintenance_io_concurrency = 128
wal_sync_method = 'open_datasync'
User Limit Adjustments (/etc/security/limits.d/postgres.conf)
The postgres operating system user requires adequate locked memory limits for io_uring memory registration:
postgres soft memlock unlimited
postgres hard memlock unlimited
postgres soft nofile 65536
postgres hard nofile 65536
7. Architectural FAQ: Common Questions on PostgreSQL AIO
Q1: What is the primary operational advantage of io_uring over posix_fadvise?
posix_fadvise(POSIX_FADV_WILLNEED) informs the operating system kernel that specific blocks will be needed, but it does not make blocks available asynchronously to user space without blocking. When the engine reads via pread(), it still executes a synchronous system call. In contrast, io_uring allows PostgreSQL to queue dozens of block read requests without blocking the execution thread, allowing tuple processing to overlap with NVMe transfers.
Q2: Does Direct I/O (O_DIRECT) always improve performance?
Not necessarily for small databases. If your entire database dataset fits comfortably inside RAM, the operating system page cache provides low-overhead in-memory reads. Direct I/O delivers massive advantages when datasets exceed memory or when high-throughput writes cause page cache dirty writeback contention. For memory-resident workloads, traditional buffered I/O remains competitive.
Q3: How does AIO impact VACUUM and maintenance operations?
VACUUM and ANALYZE benefit dramatically from AIO. Vacuuming large tables involves sequential page scanning and buffer freeze operations. With AIO vectorized read-ahead and non-blocking dirty page flushing, VACUUM can scan tables at wire speed without stalling on synchronous block writebacks, reducing maintenance window durations by up to 70%.
Q4: Can io_uring introduce security or stability risks?
Early Linux kernel implementations of io_uring had security vulnerabilities in multi-tenant environments. However, in enterprise database deployments running Linux 6.1 LTS or later, io_uring has matured significantly. PostgreSQL restricts io_uring usage to dedicated database processes, operating within strict file descriptor bounds and validated security perimeters.
Q5: How should shared_buffers be sized when using io_direct?
Under traditional buffered I/O, best practice dictates allocating approximately 25% of system RAM to shared_buffers, reserving the remainder for the operating system page cache. When io_direct is activated, the OS page cache is bypassed, eliminating the double-buffering safety net. In this architecture, administrators should increase shared_buffers to 60%–75% of available RAM to maximize cache hit rates.