libaio
callbacks
context data
asynchronous I/O
programming best practices

Proper handling of context data in libaio callbacks?

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

With libaio, the hardest part is usually not submitting the I/O request but keeping enough per-request state alive until completion. Each asynchronous operation needs context such as the buffer, file descriptor, offset, and whatever higher-level object should receive the result. The safe pattern is to attach a stable request structure to the operation and reclaim it only after the completion event has been handled.

Think in Terms of Request Objects

libaio does not manage your application state for you. It submits iocb structures and later returns io_event completions. That means you need a request object that owns everything required for the lifetime of one I/O operation.

A practical C structure looks like this:

c
1#include <libaio.h>
2#include <stdlib.h>
3#include <string.h>
4#include <unistd.h>
5
6struct read_request {
7    struct iocb iocb;
8    int fd;
9    off_t offset;
10    size_t size;
11    char *buffer;
12    void (*on_complete)(struct read_request *, long, long);
13};

The important design choice is that the iocb lives inside the same heap-allocated object as the rest of the request state.

Store Context Through iocb.data

Each iocb has a user-data slot that comes back in the completion event. Use it to point to your request object.

c
1#include <stdio.h>
2
3static void handle_read_done(struct read_request *req, long res, long res2) {
4    if (res >= 0) {
5        write(STDOUT_FILENO, req->buffer, (size_t)res);
6    } else {
7        fprintf(stderr, "read failed: %ld\n", res);
8    }
9}
10
11struct read_request *make_request(int fd, off_t offset, size_t size) {
12    struct read_request *req = calloc(1, sizeof(*req));
13    req->fd = fd;
14    req->offset = offset;
15    req->size = size;
16    req->buffer = aligned_alloc(512, size);
17    req->on_complete = handle_read_done;
18
19    io_prep_pread(&req->iocb, fd, req->buffer, size, offset);
20    req->iocb.data = req;
21    return req;
22}

After io_submit, when io_getevents returns a completion, event.data points back to the request object.

Completion Handling

A completion loop typically extracts the request pointer, invokes the stored callback or completion logic, and only then frees the resources.

c
1void process_event(struct io_event *event) {
2    struct read_request *req = (struct read_request *)event->data;
3
4    req->on_complete(req, event->res, event->res2);
5
6    free(req->buffer);
7    free(req);
8}

This ordering matters. If you free the buffer or request structure before processing the result, the callback operates on invalid memory.

Why Stack Data Is Dangerous

A common mistake is preparing an iocb, buffer, or context structure on the stack and submitting it asynchronously. The function returns, the stack frame disappears, and the kernel later completes an operation whose associated pointers are no longer valid.

That is the core rule for libaio: any memory referenced by the request must remain valid until completion has been observed and processed.

This includes:

  • the iocb
  • the I/O buffer
  • any user context stored in iocb.data
  • any higher-level object the callback assumes is still alive

Heap allocation is not the only valid strategy, but it is the safest default because the lifetime is under your control.

A Useful Ownership Pattern

For larger systems, treat each in-flight request as an owned object with exactly one cleanup path. A common pattern is:

  1. allocate and populate request
  2. submit request
  3. wait for completion event
  4. process result
  5. release request resources

That makes cancellation, retries, and error handling easier because ownership is clear.

If your application has a connection or job object that must also survive until I/O completion, either reference-count it or arrange a shutdown path that waits for in-flight requests before destroying shared state.

Common Pitfalls

The most common mistake is storing a pointer to stack memory in iocb.data or in the buffer field. Asynchronous completion means that memory may be gone before the event arrives.

Another mistake is reusing or modifying the same iocb before the previous operation has completed. Each in-flight request needs stable state.

Developers also sometimes free the request immediately after io_submit, assuming the kernel copied everything it needs. Your user-space callback context still needs to exist when the event is returned.

Finally, do not ignore alignment requirements for direct I/O workloads. Even correct callback state management will not save a request that fails due to invalid buffer alignment.

Summary

  • Keep per-request state in a stable object that lives until completion.
  • Store a pointer to that object in iocb.data.
  • Process the completion event before freeing buffers or context.
  • Never use stack-allocated context for in-flight asynchronous requests.
  • Treat ownership and cleanup as part of the I/O design, not as an afterthought.

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.