PostgreSQL Does Async I/O with io_uring
PostgreSQL 20 has a pluggable asynchronous I/O subsystem, an io_uring backend that hands read and write requests to the kernel, and a closed-loop read-ahead controller that only looks further ahead when the backend actually had to wait.
Every storage engine eventually runs into the same wall. You ask for a block, the block is not in memory, and the process blocks in the kernel until the disk spins around. It works, but it wastes the machine: one backend sleeps while the storage device is idle, another backend sleeps while the device is busy, and the CPU sits there with nothing to do.
PostgreSQL spent twenty years hiding this from you. The operating system
page cache absorbed most reads; effective_io_concurrency and the
kernel’s own readahead absorbed most sequential scans. What the operating
system could not hide was fdatasync(), and that is exactly the
operation WAL writing is made of.
PostgreSQL 20 (version 20devel, commit e75fcc9867f) contains an answer:
a pluggable asynchronous I/O subsystem with an io_uring backend, an
io_method=worker backend, and — the part I find most interesting — a
read-ahead controller that only looks further ahead when the backend
actually had to wait for I/O.
That last part is the real story. Async I/O without a feedback loop is just a different way to burn memory. The feedback loop is what makes it worth having.
This is a walk through how it all works, based on reading the PostgreSQL source.
The subsystem lives in one directory
The entire asynchronous I/O subsystem is in
src/backend/storage/aio/. The file header of aio.c explains the
split:
* AIO is a complicated subsystem. To keep things navigable, it is split
* across a number of files:
*
* - method_*.c - different ways of executing AIO (e.g. worker process)
*
* - aio_target.c - IO on different kinds of targets
*
* - aio_io.c - method-independent code for specific IO ops (e.g. readv)
*
* - aio_callback.c - callbacks at IO operation lifecycle events
*
* - aio_init.c - per-server and per-backend initialization
*
* - aio.c - all other topics
*
* - read_stream.c - helper for reading buffered relation data
*
* - README.md - higher-level overview over AIO| File | Lines | What it does |
|---|---|---|
README.md | 425 | The design document: motivation, deadlocks, the API walkthrough |
aio.c | 1358 | Core logic — handle lifecycle, state machine, wait references, batch mode |
aio_io.c | 240 | Associating an operation with a handle; the synchronous fallback |
aio_init.c | 255 | Shared memory sizing, per-backend initialization |
aio_target.c | 122 | “What am I reading?” — file identity, reopening for workers |
aio_callback.c | 333 | Completion callbacks, staged/complete_shared/complete_local |
method_io_uring.c | 812 | The io_uring backend |
method_worker.c | 1065 | The worker-process backend |
method_sync.c | 47 | The “do it inline” backend |
read_stream.c | 1495 | The adaptive read-ahead controller |
Note what is not in there: bufmgr.c is not in there, smgr.c is not
in there, md.c is not in there. The AIO subsystem knows nothing about
buffers or relations. It knows about file descriptors, iovecs, and
completion. Everything else is layered on top.
The design goals, in the author’s own words
The README.md in that directory is the best documentation PostgreSQL
has ever written for a new subsystem, and it is worth quoting.
First, the motivation for asynchronous I/O at all:
Until the introduction of asynchronous IO postgres relied on the operating system to hide the cost of synchronous IO from postgres. While this worked surprisingly well in a lot of workloads, it does not do as good a job on prefetching and controlled writeback as we would like.
There are important expensive operations like
fdatasync()where the operating system cannot hide the storage latency. This is particularly important for WAL writes, where the ability to asynchronously issuefdatasync()or O_DSYNC writes can yield significantly higher throughput.
Then, why direct I/O (this is the payoff, and also the trap):
The main reasons to want to use Direct IO are:
- Lower CPU usage / higher throughput. Particularly on modern storage buffered writes are bottlenecked by the operating system having to copy data from the kernel’s page cache to postgres buffer pool using the CPU. Whereas direct IO can often move the data directly between the storage devices and postgres' buffer cache, using DMA. While that transfer is ongoing, the CPU is free to perform other work.
- Reduced latency - Direct IO can have substantially lower latency than buffered IO, which can be impactful for OLTP workloads bottlenecked by WAL write latency.
- Avoiding double buffering between operating system cache and postgres' shared_buffers.
And the catch, which is the sentence that explains why this took twenty years to build:
The main reasons not to use Direct IO are:
- Without AIO, Direct IO is unusably slow for most purposes.
- Even with AIO, many parts of postgres need to be modified to perform explicit prefetching.
- In situations where shared_buffers cannot be set appropriately large, e.g. because there are many different postgres instances hosted on shared hardware, performance will often be worse than when using buffered IO.
“Direct IO is unusably slow without AIO” is not a throwaway line. It is the central constraint. A direct read that misses the page cache costs a full storage round trip and it costs the caller a context switch, because the caller has nowhere else to go while it waits. With a buffered read, the kernel’s readahead covers the next few kilobytes and the syscall returns almost immediately. Take away the kernel’s readahead and you take away the only thing making buffered I/O tolerable.
The third design constraint shaped every decision that follows — the deadlock hazard. From the README:
Consider one backend performing readahead on a table, initiating IO for a number of buffers ahead of the current “scan position”. If that backend then performs some operation that blocks, or even just is slow, the IO completion for the asynchronously initiated read may not be processed.
Completions must be processable even when the issuing backend is stuck.
That is why method_io_uring.c puts a completion lock on each backend’s
ring, and why method_worker.c exists at all.
AIO handles: the central abstraction
The API’s central object is the AIO handle. You acquire one, define
it (attach an operation to it), submit it, and eventually reclaim it.
The state machine is in src/include/storage/aio_internal.h, and it moves
linearly:
| State | Meaning |
|---|---|
PGAIO_HS_IDLE | not in use |
PGAIO_HS_HANDED_OUT | returned by pgaio_io_acquire() |
PGAIO_HS_DEFINED | pgaio_io_start_*() called, not yet staged |
PGAIO_HS_STAGED | staged, ready for submission |
PGAIO_HS_SUBMITTED | handed to the IO method |
PGAIO_HS_COMPLETED_IO | the IO finished, result not yet processed |
PGAIO_HS_COMPLETED_SHARED | shared completion callbacks have run |
PGAIO_HS_COMPLETED_LOCAL | local completion callbacks have run |
One handle per in-flight operation, reused as soon as it completes. That reuse is why the API also needs wait references: a handle can be recycled out from under you, so you cannot use the handle itself to wait for completion. A wait reference carries the handle’s generation counter, so a stale reference is detectable:
iow->aio_index = ioh - pgaio_ctl->io_handles;
iow->generation_upper = (uint32) (ioh->generation >> 32);
iow->generation_lower = (uint32) ioh->generation;There is also a deliberate rule about how many handles a backend may hold at once:
Because an AIO Handle is not executable just after calling
pgaio_io_acquire()and becausepgaio_io_acquire()needs to always succeed (absent a PANIC), only a single AIO Handle may be acquired (i.e. returned bypgaio_io_acquire()) without causing the IO to have been defined (by, potentially indirectly, causingpgaio_io_start_*()to have been called). Otherwise a backend could trivially self-deadlock by using up all AIO Handles without the ability to wait for some of the IOs to complete.
And the code enforces it:
if (pgaio_my_backend->handed_out_io)
elog(ERROR, "API violation: Only one IO can be handed out");Callbacks, not function pointers
Completion handling is done by callbacks, identified by an ID rather
than a pointer. src/include/storage/aio.h explains why:
Memory within PgAioHandle is precious, due to the number of PgAioHandle structs in pre-allocated shared memory.
Due to EXEC_BACKEND function pointers are not necessarily stable between different backends, therefore function pointers cannot directly be in shared memory.
There are two flavours of completion callback, and the distinction matters:
| Callback | Runs in | May modify |
|---|---|---|
complete_shared | whichever backend completed the I/O | shared memory only |
complete_local | the issuing backend | backend-local state |
The complete_shared variant is the one that updates BufferDescs so
other backends can use a buffer that has just been read. The
complete_local variant exists for temporary tables, whose BufferDesc
is only reachable from the owning backend. Both are invoked inside a
critical section, because WAL writes need to be able to complete I/O
inside one.
Shared memory, and why
aio_init.c sizes five shared memory segments — AioCtl, AioBackend,
AioHandle, AioHandleIOV and AioHandleData — and hands out the
handle pool:
ShmemRequestStruct(.name = "AioHandle",
.size = AioHandleShmemSize(),
.ptr = (void **) &AioHandleShmemPtr,
);
ShmemRequestStruct(.name = "AioHandleIOV",
.size = AioHandleIOVShmemSize(),
.ptr = (void **) &AioHandleIOVShmemPtr,
);The iovecs live in shared memory too, because the worker backend has to
be able to execute an I/O that another backend staged. That is also why
aio_target.c has the notion of a target — a description of what file
is being read, sufficient for another process to reopen it:
struct PgAioTargetInfo
{
/*
* To support executing using worker processes, the file descriptor for an
* IO may need to be reopened in a different process.
*/
void (*reopen) (PgAioHandle *ioh);
/* describe the target of the IO, used for log messages and views */
char *(*describe_identity) (const PgAioTargetData *sd);
/* name of the target, used in log messages / views */
const char *name;
};How many handles exist per backend? io_max_concurrency, which defaults
to -1 meaning “figure it out”:
static int
AioChooseMaxConcurrency(void)
{
uint32 max_backends;
int max_proportional_pins;
/* Similar logic to LimitAdditionalPins() */
max_backends = MaxBackends + NUM_AUXILIARY_PROCS;
max_proportional_pins = NBuffers / max_backends;
max_proportional_pins = Max(max_proportional_pins, 1);
/* apply upper limit */
return Min(max_proportional_pins, 64);
}So: one I/O handle per buffer you could plausibly pin, capped at 64.
Three ways to do the same thing
The method is selected by the io_method GUC, which is PGC_POSTMASTER
— you have to restart to change it:
/* Options for io_method. */
const struct config_enum_entry io_method_options[] = {
{"sync", IOMETHOD_SYNC, false},
{"worker", IOMETHOD_WORKER, false},
#ifdef IOMETHOD_IO_URING_ENABLED
{"io_uring", IOMETHOD_IO_URING, false},
#endif
{NULL, 0, false}
};| Method | Platform | How it works | Default |
|---|---|---|---|
sync | everywhere | Does the I/O inline; the API is still exercised | no |
worker | everywhere | Hands the I/O to a pool of worker processes | yes |
io_uring | Linux 5.1+ | Submits to the kernel’s io_uring from the backend itself | no |
io_uring is compiled in only when liburing is available and the build
is not EXEC_BACKEND:
/* io_uring is incompatible with EXEC_BACKEND */
#if defined(USE_LIBURING) && !defined(EXEC_BACKEND)
#define IOMETHOD_IO_URING_ENABLED
#endifmethod_sync.c
The whole of the synchronous method is 47 lines. Every I/O is executed inline:
static bool
pgaio_sync_needs_synchronous_execution(PgAioHandle *ioh)
{
return true;
}
static int
pgaio_sync_submit(uint16 num_staged_ios, PgAioHandle **staged_ios)
{
elog(ERROR, "IO should have been executed synchronously");
return 0;
}The actual preadv()/pwritev() live in aio_io.c, because the worker
method falls back on them too:
case PGAIO_OP_READV:
pgstat_report_wait_start(WAIT_EVENT_DATA_FILE_READ);
result = pg_preadv(ioh->op_data.read.fd, iov,
ioh->op_data.read.iov_length,
ioh->op_data.read.offset);
pgstat_report_wait_end();
break;io_method=sync exists to AIO what io_method=worker is to the whole
server: a way to run the same code paths without the asynchronous
behaviour, so you can measure what the asynchronous behaviour is worth.
method_worker.c
Worker mode is asynchronous from the issuing backend’s point of view but synchronous underneath: a pool of worker processes consumes IOs from a shared memory queue and runs ordinary blocking syscalls.
The interesting part is the decision of what cannot be handed to a worker:
static bool
pgaio_worker_needs_synchronous_execution(PgAioHandle *ioh)
{
return
!IsUnderPostmaster
|| ioh->flags & PGAIO_HF_REFERENCES_LOCAL
|| !pgaio_io_can_reopen(ioh);
}Three cases: we are not under a postmaster (standalone backend, no workers exist); the I/O references backend-local memory (a temporary table’s buffer); or the target cannot be reopened in another process.
Submission is deliberately best-effort — if the queue is full, or the queue lock cannot be taken, the remaining I/Os just happen inline in the submitting backend. The batch is handed to a single worker, which is then woken:
if (LWLockConditionalAcquire(AioWorkerSubmissionQueueLock, LW_EXCLUSIVE))
{
for (int i = 0; i < num_staged_ios; ++i)
{
Assert(!pgaio_worker_needs_synchronous_execution(staged_ios[i]));
if (!pgaio_worker_submission_queue_insert(staged_ios[i]))
{
/*
* Do the rest synchronously. If the queue is full, give up
* and do the rest synchronously. We're holding an exclusive
* lock on the queue so nothing can consume entries.
*/
synchronous_ios = &staged_ios[i];
nsync = (num_staged_ios - i);
break;
}
}
/* Choose one worker to wake for this batch. */
worker = pgaio_worker_choose_idle(-1);
LWLockRelease(AioWorkerSubmissionQueueLock);
/* Wake up chosen worker. It will wake peers if necessary. */
if (worker != -1)
pgaio_worker_wake(worker);
}The worker pool is sized by io_min_workers (2) and io_max_workers
(8), and the workers themselves are scaled dynamically based on recent
demand.
method_io_uring.c
Now the interesting one. io_uring is the method where the I/O is submitted from inside the backend, so no context switch is needed to hand the work over — which is exactly why the README says it “lowers context switch rate / latency”.
One ring per backend, created by the postmaster
/*
* Per-backend state when using io_method=io_uring
*/
typedef struct PgAioUringContext
{
/*
* Align the whole struct to a cacheline boundary, to prevent false
* sharing between completion_lock and prior backend's io_uring_ring.
*/
alignas(PG_CACHE_LINE_SIZE)
/*
* Multiple backends can process completions for this backend's io_uring
* instance (e.g. when the backend issuing IO is busy doing something
* else). To make that safe we have to ensure that only a single backend
* gets io completions from the io_uring instance at a time.
*/
LWLock completion_lock;
struct io_uring io_uring_ring;
} PgAioUringContext;The rings are created in the postmaster, at shared memory init time, one per possible backend. The file header explains why:
For now we create one io_uring instance for each backend. These io_uring instances have to be created in postmaster, during startup, to allow other backends to process IO completions, if the issuing backend is currently busy doing other things. Other backends may not use another backend’s io_uring instance to submit IO, that’d require additional locking that would likely be harmful for performance.
So: backend A owns ring A and submits to it. Backend B may complete A’s I/Os if A is busy — that is the deadlock-avoidance requirement from the README — but B never submits into A’s ring.
The queue depth is io_max_concurrency, the same value that sizes the
handle pool:
ret = io_uring_queue_init(io_max_concurrency, &context->io_uring_ring, 0);There is a newer optimization in here worth noting: on kernels 6.5+ with
a recent liburing, the ring’s memory can be placed in PostgreSQL’s own
shared memory instead of a separate mmap per backend, because “a large
number of memory mappings slows things down, backend exit is particularly
affected”.
Submitting: batching
pgaio_uring_submit() turns staged handles into io_uring submission
queue entries and submits them in one syscall:
static int
pgaio_uring_submit(uint16 num_staged_ios, PgAioHandle **staged_ios)
{
struct io_uring *uring_instance = &pgaio_my_uring_context->io_uring_ring;
Assert(num_staged_ios <= PGAIO_SUBMIT_BATCH_SIZE);
for (int i = 0; i < num_staged_ios; i++)
{
PgAioHandle *ioh = staged_ios[i];
struct io_uring_sqe *sqe;
sqe = io_uring_get_sqe(uring_instance);
if (!sqe)
elog(ERROR, "io_uring submission queue is unexpectedly full");
pgaio_io_prepare_submit(ioh);
pgaio_uring_sq_from_io(ioh, sqe);
}
while (true)
{
int ret;
pgstat_report_wait_start(WAIT_EVENT_AIO_IO_URING_SUBMIT);
ret = io_uring_submit(uring_instance);
pgstat_report_wait_end();One io_uring_submit() syscall for up to PGAIO_SUBMIT_BATCH_SIZE
I/Os, which is 32:
#define PGAIO_SUBMIT_BATCH_SIZE 32That batching is what pgaio_enter_batchmode() is for. The deadlock
hazard is spelled out in aio.c:
Consider backend A staging an IO for buffer 1 and then trying to start IO on buffer 2, while backend B does the inverse. If A submitted the IO before moving on to buffer 2, this works just fine, B will wait for the IO to complete. But if batching were used, each backend will wait for IO that has not yet been submitted to complete, i.e. forever.
So batch mode is opt-in and the caller must promise not to block on another backend while holding unsubmitted I/O.
The error handling on submit is unusually blunt, and the reasoning is in the comment:
else if (ret < 0)
{
/*
* The io_uring_enter() manpage suggests that the appropriate
* reaction to EAGAIN is:
*
* "The application should wait for some completions and try
* again"
*
* However, it seems unlikely that that would help in our case, as
* we apply a low limit to the number of outstanding IOs and thus
* also outstanding completions, making it unlikely that we'd get
* EAGAIN while the OS is in good working order.
*
* Additionally, it would be problematic to just wait here, our
* caller might hold critical locks. It'd possibly lead to
* delaying the crash-restart that seems likely to occur when the
* kernel is under such heavy memory pressure.
*
* Update errno to allow %m to work.
*/
errno = -ret;
elog(PANIC, "io_uring submit failed: %m");
}Building the SQE
pgaio_uring_sq_from_io() translates an AIO handle into an io_uring
submission queue entry:
static void
pgaio_uring_sq_from_io(PgAioHandle *ioh, struct io_uring_sqe *sqe)
{
struct iovec *iov;
size_t io_size = 0;
switch ((PgAioOp) ioh->op)
{
case PGAIO_OP_READV:
iov = &pgaio_ctl->iovecs[ioh->iovec_off];
if (ioh->op_data.read.iov_length == 1)
{
io_uring_prep_read(sqe,
ioh->op_data.read.fd,
iov->iov_base,
iov->iov_len,
ioh->op_data.read.offset);
io_size = iov->iov_len;
}
else
{
io_uring_prep_readv(sqe,
ioh->op_data.read.fd,
iov,
ioh->op_data.read.iov_length,
ioh->op_data.read.offset);
for (int i = 0; i < ioh->op_data.read.iov_length; i++, iov++)
io_size += iov->iov_len;
}
if (pgaio_uring_should_use_async(ioh, io_size))
io_uring_sqe_set_flags(sqe, IOSQE_ASYNC);
break;The single-iov case uses io_uring_prep_read, the multi-iov case uses
io_uring_prep_readv. IOSQE_ASYNC is the interesting flag: it tells
the kernel to hand the request to a worker thread rather than executing
it in the submitting process’s context.
Note that writes deliberately do not get IOSQE_ASYNC:
/*
* For now don't trigger use of IOSQE_ASYNC for writes, it's not
* clear there is a performance benefit in doing so.
*/When to go asynchronous
pgaio_uring_should_use_async() is the heuristic, and its comment is one
of the best explanations of the io_uring-vs-worker tradeoff in the tree:
io_uring executes IO in process context if possible. That’s generally good, as it reduces context switching. When performing a lot of buffered IO that means that copying between page cache and userspace memory happens in the foreground, as it can’t be offloaded to DMA hardware as is possible when using direct IO. When executing a lot of buffered IO this causes io_uring to be slower than worker mode, as worker mode parallelizes the copying. io_uring can be told to offload work to worker threads instead.
If the IOs are small, we only benefit from forcing things into the background if there is a lot of IO, as otherwise the overhead from context switching is higher than the gain.
If IOs are large, there is benefit from asynchronous processing at lower queue depths, as IO latency is less of a crucial factor and parallelizing memory copies is more important. In addition, it is important to trigger asynchronous processing even at low queue depth, as with foreground processing we might never actually reach deep enough IO depths to trigger asynchronous processing, which in turn would deprive readahead control logic of information about whether a deeper look-ahead distance would be advantageous.
And the code:
static bool
pgaio_uring_should_use_async(PgAioHandle *ioh, size_t io_size)
{
/*
* With DIO there's no benefit from forcing asynchronous processing, as
* io_uring will never execute direct IO synchronously during submission.
*/
if (!(ioh->flags & PGAIO_HF_BUFFERED))
return false;
/*
* Once the IO queue depth is not that shallow anymore, the overhead of
* dispatching to the background is a less significant factor.
*/
if (dclist_count(&pgaio_my_backend->in_flight_ios) > 4)
return true;
/*
* If the IO is larger, the gains from parallelizing the memory copy are
* larger and typically the impact of the latency is smaller.
*/
if (io_size >= (BLCKSZ * 4))
return true;
return false;
}Three rules, then:
| Condition | Result |
|---|---|
| Not buffered (i.e. direct I/O) | never IOSQE_ASYNC — the kernel never does DIO inline anyway |
| More than 4 I/Os already in flight | always IOSQE_ASYNC |
I/O size ≥ 4 × BLCKSZ (32 kB) | always IOSQE_ASYNC |
| Otherwise | foreground processing |
The PGAIO_HF_BUFFERED flag is set by the storage manager when direct
I/O is not in use:
if (!(io_direct_flags & IO_DIRECT_DATA))
pgaio_io_set_flag(ioh, PGAIO_HF_BUFFERED);Note the third condition — 32 kB, four blocks — is exactly where read-ahead starts combining blocks. The two heuristics are tuned together, which is a nice example of the subsystem being designed as a whole rather than in pieces.
Completing
Completions are drained under the completion lock, at most
PGAIO_MAX_LOCAL_COMPLETED_IO (32) at a time, and inside a critical
section:
orig_ready = ready = io_uring_cq_ready(&context->io_uring_ring);
while (ready > 0)
{
struct io_uring_cqe *cqes[PGAIO_MAX_LOCAL_COMPLETED_IO];
uint32 ncqes;
START_CRIT_SECTION();
ncqes =
io_uring_peek_batch_cqe(&context->io_uring_ring,
cqes,
Min(PGAIO_MAX_LOCAL_COMPLETED_IO, ready));
Assert(ncqes <= ready);
ready -= ncqes;
for (uint32 i = 0; i < ncqes; i++)
{
struct io_uring_cqe *cqe = cqes[i];
PgAioHandle *ioh = io_uring_cqe_get_data(cqe);
int result = cqe->res;
errcallback.arg = ioh;
io_uring_cqe_seen(&context->io_uring_ring, cqe);
pgaio_io_process_completion(ioh, result);
errcallback.arg = NULL;
}
END_CRIT_SECTION();
}The START_CRIT_SECTION() / END_CRIT_SECTION() pair is not decoration:
completions must be processable inside a critical section so that WAL
writes can complete I/O there.
pgaio_uring_wait_one() is the blocking wait, and it is where the
“other backends may complete my I/O” logic lives:
static void
pgaio_uring_wait_one(PgAioHandle *ioh, uint64 ref_generation)
{
PgAioHandleState state;
ProcNumber owner_procno = ioh->owner_procno;
PgAioUringContext *owner_context = &pgaio_uring_contexts[owner_procno];
bool expect_cqe;
int waited = 0;
/*
* XXX: It would be nice to have a smarter locking scheme, nearly all the
* time the backend owning the ring will consume the completions, making
* the locking unnecessarily expensive.
*/
LWLockAcquire(&owner_context->completion_lock, LW_EXCLUSIVE);
while (true)
{
...
if (pgaio_io_was_recycled(ioh, ref_generation, &state) ||
state != PGAIO_HS_SUBMITTED)
{
/* the IO was completed by another backend */
break;
}
else if (io_uring_cq_ready(&owner_context->io_uring_ring))
{
/* no need to wait in the kernel, io_uring has a completion */
expect_cqe = true;
}
else
{
int ret;
struct io_uring_cqe *cqes;
/* need to wait in the kernel */
pgstat_report_wait_start(WAIT_EVENT_AIO_IO_URING_EXECUTION);
ret = io_uring_wait_cqes(&owner_context->io_uring_ring, &cqes, 1, NULL, NULL);
pgstat_report_wait_end();pgaio_uring_check_one() is the non-blocking variant, used by
pgaio_wref_check_done():
static void
pgaio_uring_check_one(PgAioHandle *ioh, uint64 ref_generation)
{
ProcNumber owner_procno = ioh->owner_procno;
PgAioUringContext *owner_context = &pgaio_uring_contexts[owner_procno];
/*
* This check is not reliable when not holding the completion lock, but
* it's a useful cheap pre-check to see if it's worth trying to get the
* completion lock.
*/
if (!io_uring_cq_ready(&owner_context->io_uring_ring))
return;
/*
* If the completion lock is currently held, the holder will likely
* process any pending completions, give up.
*/
if (!LWLockConditionalAcquire(&owner_context->completion_lock, LW_EXCLUSIVE))
return;The IOSQE_ASYNC / close-FD interaction
One more io_uring-specific hazard, recorded in the ops table: while
io_uring is mostly fine with an FD being closed while the I/O is in
flight, that is not true for IOs submitted with IOSQE_ASYNC. Hence
.wait_on_fd_before_close = true, which makes pgaio_closing_fd() wait
for any in-flight I/O referencing that file descriptor before it is
closed. This is handled generically in aio.c, which is why the flag
exists on the ops structure at all.
The read-ahead controller
Here is where the async I/O subsystem stops being about syscalls and starts being about scheduling.
src/backend/storage/aio/read_stream.c implements a “read stream”: an
object that, given a callback producing block numbers, hands the caller
one pinned buffer at a time while arranging the underlying I/O to be
issued early and in large batches.
The file header explains the algorithm:
The algorithm for controlling the look-ahead distance is based on recent cache / miss history, as well as whether we need to wait for I/O completion after a miss. When no I/O is necessary, there is no benefit in looking ahead more than one block. This is the default initial assumption. When blocks needing I/O are streamed, the combine distance is increased to benefit from I/O combining and the read-ahead distance is increased whenever we need to wait for I/O to try to benefit from increased I/O concurrency. Both are reduced gradually when cached blocks are streamed.
Two distances, and the distinction between them is subtle:
| Field | Meaning | Capped by |
|---|---|---|
readahead_distance | How far ahead of the current position to look | max_pinned_buffers |
combine_distance | How large a single I/O request may grow | io_combine_limit |
They were originally the same variable. Commit 8ca147d582a split them:
In a subsequent commit the read-ahead distance will only be increased when waiting for IO. Without further work that would cause a regression: As IO combining and read-ahead are currently controlled by the same mechanism, we would end up not allowing IO combining when never needing to wait for IO (as the distance ends up too small to allow for full sized IOs), which can increase CPU overhead. A typical reason to not have to wait for IO completion at a low look-ahead distance is use of io_uring with the to-be-read data in the page cache.
Two decisions, two functions
read_stream_should_look_ahead() decides whether to pull more block
numbers from the callback; read_stream_should_issue_now() decides
whether to actually start the pending I/O:
static inline bool
read_stream_should_look_ahead(ReadStream *stream)
{
/* If the callback has signaled end-of-stream, we're done */
if (stream->readahead_distance == 0)
return false;
/* never start more IOs than our cap */
if (stream->ios_in_progress >= stream->max_ios)
return false;
/*
* Don't start more read-ahead if that'd put us over the distance limit
* for doing read-ahead. As stream->readahead_distance is capped by
* max_pinned_buffers, this prevents us from looking ahead so far that it
* would put us over the pin limit.
*/
if (stream->pinned_buffers + stream->pending_read_nblocks >= stream->readahead_distance)
return false;
return true;
}static inline bool
read_stream_should_issue_now(ReadStream *stream)
{
int16 pending_read_nblocks = stream->pending_read_nblocks;
/* there is no pending IO that could be issued */
if (pending_read_nblocks == 0)
return false;
/* never start more IOs than our cap */
if (stream->ios_in_progress >= stream->max_ios)
return false;
/*
* If the callback has signaled end-of-stream, start the pending read
* immediately. There is no further potential for IO combining.
*/
if (stream->readahead_distance == 0)
return true;
/*
* If we've already reached combine_distance, there's no chance of growing
* the read further.
*/
if (pending_read_nblocks >= stream->combine_distance)
return true;
/*
* If we currently have no reads in flight or prepared, issue the IO once
* we are not looking ahead further. This ensures there's always at least
* one IO prepared.
*/
if (stream->pinned_buffers == 0 &&
!read_stream_should_look_ahead(stream))
return true;
return false;
}The asymmetry between the two is the point. combine_distance decides
how big an I/O may get; readahead_distance decides how far ahead we
look. A scan that hits everything in cache should issue small I/Os of one
block, but it should still combine them into fewer syscalls — which is
why the two are separate.
read_stream_look_ahead() is the loop that drives both decisions,
asking the callback for more block numbers and starting I/Os when it
decides to:
static void
read_stream_look_ahead(ReadStream *stream)
{
/*
* Allow amortizing the cost of submitting IO over multiple IOs. This
* requires that we don't do any operations that could lead to a deadlock
* with staged-but-unsubmitted IO. The callback needs to opt-in to being
* careful.
*/
if (stream->batch_mode)
pgaio_enter_batchmode();
while (read_stream_should_look_ahead(stream))
{
...
if (read_stream_should_issue_now(stream))
{
read_stream_start_pending_read(stream);
continue;
}
...
/* Can we merge it with the pending read? */
if (stream->pending_read_nblocks > 0 &&
stream->pending_read_blocknum + stream->pending_read_nblocks == blocknum)
{
stream->pending_read_nblocks++;
continue;
}The closed loop
Here is the heart of it. When the consumer of the stream pulls a buffer and has to wait for I/O to complete, the read-ahead distance is doubled:
/*
* Have the read-ahead distance ramp up rapidly after we needed to
* wait for IO. We only increase the read-ahead-distance when we
* needed to wait, to avoid increasing the distance further than
* necessary, as looking ahead too far can be costly, both due to the
* cost of unnecessarily pinning many buffers and due to doing IOs
* that may never be consumed if the stream is ended/reset before
* completion.
*
* If we did not need to wait, the current distance was evidently
* sufficient.
*
* NB: Must not increase the distance if we already reached the end of
* the stream, as stream->readahead_distance == 0 is used to keep
* track of having reached the end.
*/
if (stream->readahead_distance > 0 && needed_wait)
{
/* wider temporary value, due to overflow risk */
int32 readahead_distance;
readahead_distance = stream->readahead_distance * 2;
readahead_distance = Min(readahead_distance, stream->max_pinned_buffers);
stream->readahead_distance = readahead_distance;
}And the decay, which happens when the I/O did not have to wait:
if (stream->ios_in_progress == 0)
{
if (stream->distance_decay_holdoff > 0)
stream->distance_decay_holdoff--;
else
{
if (stream->readahead_distance > 1)
stream->readahead_distance--;
...
if (stream->combine_distance > 1)
stream->combine_distance--;
}
}The decay is guarded by distance_decay_holdoff, which is reset
whenever an I/O is issued:
/*
* As we needed IO, prevent distances from being reduced within our
* maximum look-ahead window. This avoids collapsing distances too
* quickly in workloads where most of the required blocks are cached,
* but where the remaining IOs are a sufficient enough factor to cause
* a substantial slowdown if executed synchronously.
*
* There are valid arguments for preventing decay for max_ios or for
* max_pinned_buffers. But the argument for max_pinned_buffers seems
* clearer - if we can't see any misses within the maximum look-ahead
* distance, we can't do any useful read-ahead.
*/
stream->distance_decay_holdoff = stream->max_pinned_buffers;And the combine distance grows on any I/O, wait or not:
/*
* Whether we needed to wait or not, allow for more IO combining if we
* needed to do IO. The reason to do so independent of needing to wait
* is that when the data is resident in the kernel page cache, IO
* combining reduces the syscall / dispatch overhead, making it
* worthwhile regardless of needing to wait.
*
* It is also important with io_uring as it will never signal the need
* to wait for reads if all the data is in the page cache. There are
* heuristics to deal with that in method_io_uring.c, but they only
* work when the IO gets large enough.
*/This last comment is a direct link between the two halves of this
article. io_uring will happily serve a read out of the page cache without
ever blocking, so the “did I wait?” signal — the entire basis of the
closed loop — goes silent exactly in the case where io_uring is
fastest. The combine_distance mechanism is the escape hatch: it grows
the I/O size until io_uring’s IOSQE_ASYNC heuristic kicks in and starts
paying attention to how deep the queue is.
The commit that introduced the wait-conditional growth is f63ca337902,
and its message is explicit about the trade-off:
This avoids increasing the distance to the maximum in cases where the I/O subsystem is already keeping up. This turns out to be important for performance for two reasons:
Pinning a lot of buffers is not cheap. If additional pins allow us to avoid IO waits, it’s definitely worth it, but if we can already do all the necessary readahead at a distance of 16, reading ahead 512 buffers can increase the CPU overhead substantially. This is particularly noticeable when the to-be-read blocks are already in the kernel page cache.
If the read stream is read to completion, reading in data earlier than needed is of limited consequences, leaving aside the CPU costs mentioned above. But if the read stream will not be fully consumed, e.g. because it is on the inner side of a nested loop join, the additional IO can be a serious performance issue.
So the controller is not “look ahead as far as possible”. It is “look ahead exactly as far as needed to avoid waiting, and no further”.
The limits
max_pinned_buffers is derived from the buffer manager’s own limits:
max_pinned_buffers = (max_ios + 1) * io_combine_limit;
max_pinned_buffers = Min(max_pinned_buffers,
PG_INT16_MAX - queue_overflow - 1);
/* Give the strategy a chance to limit the number of buffers we pin. */
strategy_pin_limit = GetAccessStrategyPinLimit(strategy);
max_pinned_buffers = Min(strategy_pin_limit, max_pinned_buffers);max_ios comes from effective_io_concurrency (or the tablespace’s
override). So the whole controller is bounded by three knobs a DBA
already knows: effective_io_concurrency, io_combine_limit, and the
buffer manager’s per-backend pin limit.
The buffer manager’s side
The buffer manager does not know about io_uring. It knows about
StartBufferIO() and WaitReadBuffers().
StartSharedBufferIO() in src/backend/storage/buffer/bufmgr.c is
where “someone else is already reading this buffer” is handled — and
with AIO, “someone else” can be another backend entirely:
for (;;)
{
buf_state = LockBufHdr(buf);
if (!(buf_state & BM_IO_IN_PROGRESS))
break;
/* Join the existing IO */
if (io_wref != NULL && pgaio_wref_valid(&buf->io_wref))
{
*io_wref = buf->io_wref;
UnlockBufHdr(buf);
return BUFFER_IO_IN_PROGRESS;
}Joining an in-flight I/O is strictly better than waiting for it: the joining backend attaches its own wait reference to the same I/O and continues doing other work.
Then there is AsyncReadBuffers(), which is the function that actually
starts the read:
/*
* We must get an IO handle before StartBufferIO(), as pgaio_io_acquire()
* might block, which we don't want after setting IO_IN_PROGRESS. If we
* don't need to do the IO, we'll release the handle.
*
* If we need to wait for IO before we can get a handle, submit
* already-staged IO first, so that other backends don't need to wait.
*/
ioh = pgaio_io_acquire_nb(CurrentResourceOwner, &operation->io_return);
if (unlikely(!ioh))
{
pgaio_submit_staged();
ioh = pgaio_io_acquire(CurrentResourceOwner, &operation->io_return);
}Note pgaio_io_acquire_nb() — the non-blocking variant — and the
fallback that submits staged I/O before blocking. Blocking while holding
staged-but-unsubmitted I/O is exactly the deadlock the batch-mode comment
warns about.
Relation extension is not an AIO consumer
The other half of the buffer manager’s I/O surface is relation
extension. In older PostgreSQL this was ExtendBufferedIO(); in this
tree it is ExtendBufferedRel() / ExtendBufferedRelBy() in
src/backend/storage/buffer/bufmgr.c, which does not read anything.
It asks the storage manager for the current size, then takes victim
buffers and zeroes them:
first_block = smgrnblocks(BMR_GET_SMGR(bmr), fork);
...
for (uint32 i = 0; i < extend_by; i++)
{
Buffer buf_block;
buffers[i] = GetVictimBuffer(strategy, io_context);
buf_block = BufHdrGetBlock(GetBufferDescriptor(buffers[i] - 1));
/* new buffers are zero-filled */
MemSet(buf_block, 0, BLCKSZ);
}So extending a relation is not an asynchronous-read workload, and it does not participate in the AIO machinery. The pages it creates are written later through the ordinary dirty-buffer flush path.
Direct I/O, for real
Direct I/O is now reachable, and the README’s warning is the reason it was worth building AIO first.
With the debug_io_direct GUC (a developer option, PGC_POSTMASTER)
set, mdstartreadv() simply stops setting the buffered flag:
if (!(io_direct_flags & IO_DIRECT_DATA))
pgaio_io_set_flag(ioh, PGAIO_HF_BUFFERED);and pgaio_uring_should_use_async() then always returns false for
those I/Os, because the kernel never performs a direct read inline
during submission anyway.
The double-buffering saving is real: with direct I/O, the data goes
straight from the device into PostgreSQL’s shared buffers via DMA, with
no copy through the kernel page cache. But the price is that PostgreSQL
now owns the readahead problem entirely — which is what read_stream.c
is for, and why the README says:
Even with AIO, many parts of postgres need to be modified to perform explicit prefetching.
Instrumenting it: EXPLAIN (IO)
The subsystem ships with instrumentation, exposed through
EXPLAIN (IO). The option is parsed in
src/backend/commands/explain_state.c:
else if (strcmp(opt->defname, "io") == 0)
es->io = defGetBoolean(opt);and printed by print_io_usage() in src/backend/commands/explain.c:
ExplainIndentText(es);
appendStringInfo(es->str, "Prefetch: avg=%.2f max=%d capacity=%d\n",
(stats->distance_sum * 1.0 / stats->prefetch_count),
stats->distance_max,
stats->distance_capacity);
/* prefetch I/O info (only if there were actual I/Os) */
if (stats->io_count > 0)
{
ExplainIndentText(es);
appendStringInfo(es->str, "I/O: count=%" PRIu64 " waits=%" PRIu64
" size=%.2f in-progress=%.2f\n",
stats->io_count, stats->wait_count,
(stats->io_nblocks * 1.0 / stats->io_count),
(stats->io_in_progress * 1.0 / stats->io_count));
}The counters themselves come from read_stream.c:
static inline void
read_stream_count_prefetch(ReadStream *stream)
{
IOStats *stats = stream->stats;
if (stats == NULL)
return;
stats->prefetch_count++;
stats->distance_sum += stream->pinned_buffers;
if (stream->pinned_buffers > stats->distance_max)
stats->distance_max = stream->pinned_buffers;
}So EXPLAIN (ANALYZE, IO) on a sequential scan will tell you the
average look-ahead distance the controller settled on, the maximum it
reached, and how many times it actually had to wait. That is the
feedback loop made visible — and it is the first thing to look at when
tuning effective_io_concurrency on a system that is using
io_method=io_uring.
The instrumentation is being added incrementally: currently it is wired
up for scan nodes that use read streams (sequential scans, bitmap heap
scans, TID range scans), and the IOStats structure
(src/include/executor/instrument_node.h) is a new addition rather than
a long-standing feature.
Caveats
- io_uring is Linux-only, and off by default.
io_method=workeris the default;io_method=io_uringrequires a build with liburing and a non-EXEC_BACKENDconfiguration.io_methodisPGC_POSTMASTER, so changing it requires a restart. - The kernel version matters. The README says “available on Linux
5.1+”. Newer kernels (6.5+) allow the ring memory to live in
PostgreSQL’s shared memory, which avoids one
mmapper backend. - Many code paths still need explicit prefetching. The README is explicit: “Even with AIO, many parts of postgres need to be modified to perform explicit prefetching.” Read streams exist for the access patterns that have been converted; others have not been.
- The write path is not converted yet. The AIO API has a write
operation —
PGAIO_OP_WRITEV,pgaio_io_start_writev(), and thePGAIO_OP_WRITEVbranch inpgaio_uring_sq_from_io()— but in this tree it has no callers outside the AIO subsystem itself. Ordinary relation writes still go throughmdwritev(), which does a plainpwritev(), and the WAL write path has not been converted either. So the README’s claim that asynchronous WAL writes “can yield significantly higher throughput” describes the design goal, not something you can turn on and measure yet. - Direct I/O is still a sharp tool. It requires that
shared_buffersbe large enough, that the workload tolerate the loss of the kernel’s page cache, and that the access patterns have been converted to read streams. - The io_uring heuristics are empirical. The comment on
pgaio_uring_should_use_async()says outright: “We have done some basic benchmarking to validate the thresholds used, but it’s quite plausible that there are better values.” The> 4in-flight threshold and theBLCKSZ * 4size threshold are starting points, not laws. - EXPLAIN (IO) coverage is partial. It exists and works for the scan nodes that use read streams, but it is new and being extended.
What it all means in practice
A few consequences worth carrying around:
- Async I/O is a scheduling problem, not a syscall problem. The
interesting code in this subsystem is
read_stream.c, notmethod_io_uring.c. The io_uring backend is a fairly thin layer; the closed loop that decides when to look ahead is where the value is. - “Did I wait?” is the signal. A read-ahead controller that grows unconditionally is a memory hog; one that never grows is synchronous I/O with extra steps. Doubling the distance on a wait and halving it otherwise is a simple, effective feedback loop.
- Batching and deadlock avoidance pull in opposite directions. The batch API gives you one syscall for 32 I/Os, but it also gives you a way to deadlock with yourself. The code is careful about this, and the comments explain why at each site.
- The shared-memory constraint shapes everything. Callbacks are IDs, not pointers. Targets exist so a file can be reopened. Completion happens in critical sections. Almost every awkward-looking decision in this subsystem traces back to “the state has to be visible to other processes.”
Further reading
src/backend/storage/aio/README.md— the design document. Read it first; it explains the “why” behind almost everything above.src/backend/storage/aio/read_stream.c— the read-ahead controller, including the ASCII art diagram of the buffer and I/O queues at the top of the file.src/backend/storage/aio/method_io_uring.c— the io_uring backend.- Commit
f63ca337902(“read_stream: Only increase read-ahead distance when waiting for IO”) and8ca147d582a(“read stream: Split decision about look ahead for AIO and combining”) — the two commits that built the closed loop. doc/src/sgml/config.sgml— theio_method,io_max_concurrency,io_min_workers,io_max_workersGUCs.