AlgoMaster Logo

The I/O Path: From write() to the Device

15 min readUpdated August 7, 2026
Listen to this chapter
Unlock Audio

A backend service records an event with one ordinary-looking call:

The call does not tell the CPU to place bytes directly on an SSD. The application does not know which storage blocks hold the file, how to issue commands to the device, or when the hardware has finished.

Instead, write() begins a request that crosses several operating-system layers. The kernel identifies what fd refers to, validates the request, routes it to the appropriate subsystem, takes responsibility for the bytes, and eventually coordinates with a device driver and hardware.

For a typical buffered file write, an especially important detail is easy to miss:

write() can return successfully before the new data reaches the storage device.

Following the request end to end explains why that is possible, where I/O time is spent, and what a successful return actually guarantees.

I/O Requests to the Kernel

A CPU can load and store ordinary memory directly. Devices do not behave like normal process memory. They have their own command formats, queues, timing, failure modes, and access restrictions.

The kernel provides a controlled interface between applications and those devices. On Unix-like systems, read() and write() are two of the most important operations in that interface:

The application supplies three pieces of information to write():

  • fd identifies an open I/O endpoint.
  • buffer points to the bytes in the process's address space.
  • count is the maximum number of bytes to write.

The descriptor might represent a regular file, terminal, pipe, socket, or device. The application uses the same basic call for all of them, but the path after descriptor lookup depends on the referenced object.

This is one of the operating system's most useful abstractions. Application code can express “write these bytes” without knowing whether the eventual destination is an SSD, a terminal, a local kernel buffer, or a network interface.

The End-to-End Mental Model

At a high level, an I/O request travels downward through software layers, reaches hardware, and later completes upward through those layers:

  1. The application calls write(fd, buffer, count).
  2. The call crosses the system-call boundary.
  3. Descriptor lookup reaches the generic I/O interface.
  4. The destination-specific subsystem takes over.
  5. The data lands in a kernel buffer or request queue.
  6. The device driver forms the request.
  7. The device controller and physical device carry it out.
  8. On completion, the kernel records the result and releases the request resources.
  9. Anything that was waiting can now continue.

Not every request visits every box in exactly this form. A pipe may finish entirely inside the kernel. A network write passes through the networking stack. A cached file write can return before a storage request is issued. Some specialized interfaces also reduce data copies or combine layers.

The diagram is therefore a map, not a promise that every write() causes one immediate hardware operation.

A Concrete Request: Writing a Regular File

Consider a process that has already opened events.log and received file descriptor 3:

Assume this is an ordinary buffered write to a regular file on a local filesystem. That choice gives us a concrete path to follow. The exact kernel functions and data structures vary among operating systems and filesystems, but the responsibilities remain similar.

Step 1: The application prepares the request

Before the call, the bytes reside in the process's virtual address space:

In process memory, message points at the bytes request=42 status=accepted followed by a newline.

The integer 3 is not a hardware address and does not identify a disk block. It is a small process-local handle. The kernel maintains the state that connects this handle to an open object.

At this point, ownership of the application's buffer has not changed. The process must treat the buffer according to the interface's rules until the call has consumed the data.

Step 2: Execution enters the kernel

The C library wrapper arranges the system-call number and arguments as required by the machine's calling convention. A special instruction transfers execution into the kernel.

The same thread is now executing kernel code in a privileged mode. Crossing this boundary does not by itself switch to another thread, and it does not mean that hardware I/O has begun.

The kernel first treats every argument as untrusted. It checks enough state to answer questions such as:

  • Is 3 a valid descriptor for this process?
  • Does the referenced object permit writing?
  • Is the requested byte count representable and allowed?
  • Can the kernel safely access the supplied user-memory range?

Validation is necessary because an invalid pointer or descriptor must become an error in the calling process, not memory corruption inside the kernel.

Step 3: The descriptor selects an I/O implementation

After validating the descriptor, the kernel follows it to the corresponding open object. That object carries information such as the current access mode, current file position, and operations supported by its type.

The generic I/O layer then dispatches the request to an implementation that understands the destination:

The same write call reaches four different subsystems. What the descriptor refers to, not the call itself, decides which one.

Linux uses a Virtual File System interface to give these objects a common set of operations. For this chapter, the essential idea is simply late dispatch: the descriptor determines which kernel implementation receives the generic write() request.

Step 4: The kernel takes responsibility for the bytes

For an ordinary buffered regular-file write, the kernel commonly copies the bytes from the process's buffer into memory managed by the kernel. The relevant cached file pages are updated and marked as needing eventual write-back.

This creates an important separation:

Foreground syscallLater storage work
Copy bytes into kernel-managed memoryForm device requests
Mark file data as changedSend requests to the driver
Return from write()The device performs the operation
Handle completion

Once the kernel has accepted the bytes, the process may safely reuse its original buffer. The kernel no longer needs that user-space copy to remain unchanged.

For this end-to-end path, two facts about caching are enough:

  1. Kernel memory can temporarily hold changed file data.
  2. Accepting bytes into that memory is not the same event as storing them on the physical device.

Other destinations take responsibility differently. A pipe write places bytes in a pipe's kernel buffer. A socket write commonly places data into kernel networking buffers. Success in either case means the local kernel accepted some bytes; it does not mean another process has already read them or a remote service has processed them.

Step 5: Kernel work becomes a device request

Changed file data eventually has to move from memory toward storage. The filesystem determines what file region needs to be written and translates that work into requests understood by the storage stack.

The kernel's block I/O layer represents and queues operations for block-addressable storage. It can organize requests before passing them to a device driver. The driver then translates the kernel's generic request into commands understood by the particular controller.

These layers exist because each solves a different problem:

Keeping the layers separate allows many filesystems to work with many storage devices without every filesystem implementing every hardware protocol.

This phase may happen as part of the calling operation, or it may be performed later by kernel write-back work. In the ordinary buffered case, it is often later.

Step 6: The driver submits work to the controller

The driver prepares the command and tells the device controller that work is available. The controller is hardware that mediates between the CPU-facing interface and the physical device.

Modern systems normally do not make the CPU copy a large request one byte at a time into the device. The controller can transfer data between memory and the device with limited CPU involvement. When the operation finishes, the device has a mechanism for notifying the kernel.

At this level, the controller's role in the path is:

The driver submits the request, the controller performs or coordinates the transfer, and the controller reports completion.

Submission and completion are separate events. Between them, the CPU can execute unrelated work.

Step 7: Completion travels back through the kernel

When the device reports completion, the kernel identifies the completed request and records whether it succeeded. It updates bookkeeping, releases resources associated with the request, and informs the layer that submitted the work.

If an execution context is waiting for that particular operation, the kernel can make it eligible to continue. It does not necessarily run immediately; it must first receive CPU time.

For the buffered write in this example, the original caller may already be running other code—or may have exited—because its write() returned after the kernel accepted the bytes. Device completion then belongs to the later write-back work rather than to the original foreground syscall.

This gives a more accurate end-to-end timeline:

The application is told the write succeeded before the device has done anything. Everything below that return happens on the kernel's schedule, not the application's.

The downward request path and upward completion path still exist, but they do not have to fit inside one application call.

What Does a Successful write() Mean?

write() returns the number of bytes accepted during that call. If all 27 bytes from the example are accepted, the result is 27.

A nonnegative result can be smaller than the requested count. This is a partial write. Correct code must either accept that outcome or retry with the unconsumed portion when the interface and application protocol call for it.

For a buffered regular file, a successful result normally means the kernel accepted those bytes as file data and advanced the relevant file position. It does not by itself mean:

  • The storage hardware has completed the write.
  • The data would survive an immediate power failure.
  • Every related piece of file metadata is persistent.

Those stronger guarantees require an explicit durability strategy. The boundary to remember here is that acceptance, device completion, and durable persistence are different milestones.

The same reasoning applies to other endpoints:

  • A successful pipe write does not prove that the reader consumed the bytes.
  • A successful socket write does not prove that the peer received or processed them.
  • A successful terminal write does not prove that a person saw the output.

The return value describes the contract at the current layer, not the final real-world effect an application may care about.

Loading simulation...

write() Calls vs. Device Operations

A reasonable first model is that one application write() becomes one command to the device. Real systems deliberately break that relationship. Several small writes can change the same cached page before the kernel sends it to storage. One large request can be split because of device or kernel limits. Requests from different processes can be queued together. A hardware failure can also become visible only after a foreground buffered write has returned.

This decoupling improves throughput and lets the device process work efficiently. It also changes how application observations should be interpreted:

CountWhat it measures
Syscall countApplication-to-kernel requests
Device-operation countKernel-to-device work

The two counts answer different questions. Seeing 10,000 write() calls in a trace does not prove that the storage device received exactly 10,000 commands.

Where I/O Latency Comes From

The complete latency of an I/O operation is not just “device speed.” Time can be spent at several boundaries:

  • Entering and leaving the kernel
  • Validating arguments and finding the referenced object
  • Copying or organizing data in memory
  • Waiting behind other queued requests
  • Submitting work through the driver
  • Performing the operation in the controller and device
  • Handling completion and scheduling waiting work

Which component dominates depends on the endpoint and workload. A tiny cached file write may finish its foreground path without waiting for storage. A request that must wait for a device can spend most of its time queued or in hardware. Thousands of tiny calls can spend substantial CPU time crossing the system-call boundary even when the device is fast.

This is why “I/O is slow” is not a sufficient diagnosis. A useful investigation asks where the request currently is and which milestone the measurement represents.

Observing the Path on Linux

The following program writes one record to a regular file and correctly handles partial writes and interruptions:

Compile it and trace only its write() calls:

A representative trace line looks like this:

The trace exposes the application-to-kernel boundary:

  • 3 is the descriptor selected by the process.
  • The annotation shows the open object currently associated with it.
  • The application requested 27 bytes.
  • The kernel reported that it accepted all 27.

It does not show one corresponding storage command. strace observes system calls, not the complete internal write-back and device-completion path. This limitation is itself useful evidence that the syscall interface and hardware interface are separate layers.

Run the trace again after replacing the one call with many one-byte calls. The file contents can be identical, but strace will show many more kernel crossings. This is a simple way to see why application buffering and batching can matter even before device performance becomes the bottleneck.

The Read Path as the Reverse of the Write Path

A read() request starts with the same broad responsibilities: validate the descriptor and user buffer, identify the destination-specific implementation, and determine whether the requested data is already available in kernel-managed memory.

If the data must come from a device, the kernel submits a request and later handles its completion. Only then can the requested bytes be copied or otherwise made available to the process, and read() can report how many bytes were produced.

  1. The application requests bytes.
  2. The kernel finds the data, or requests it.
  3. The device completes, if device access was necessary.
  4. The kernel makes the bytes available to the application.
  5. read() returns a byte count.

The important common pattern is request, possible wait, completion, result.

Summary

An application begins I/O by describing a request to the kernel. For write(fd, buffer, count), the kernel validates the arguments, resolves the descriptor, dispatches to the implementation for that endpoint, and takes responsibility for the accepted bytes.

For device-backed I/O, work continues through kernel queues and a device driver to a controller. Completion then travels back into the kernel, where request state is updated and any waiting execution can become runnable. Submission, completion, and resumption are separate events.

An ordinary buffered file write can return before storage hardware receives or completes the operation. Its return value reports how many bytes the kernel accepted, not that the data is durable. One syscall also need not correspond to one device command.

The central mental model is:

I/O is a layered request-and-completion path, and each layer reports success only for the responsibility it owns.

Quiz

The I/O Path: From write() to the Device Quiz

5 quizzes