AnyLearn
All lessons
Computer Scienceintermediate

Filesystems and the I/O Path

A file has no name; the name is a directory entry pointing at an inode. This lesson follows the kernel's I/O path: the VFS abstraction over every filesystem, the page cache and its writeback thresholds, buffered versus direct I/O, what fsync actually promises and what it does after a failure, ext4 journaling modes, and how a write finally reaches flash.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 12

A file is an inode; the name is separate

The inode is the file. It holds the type, permissions, owner, size, timestamps, a link count, and the map from file offsets to disk blocks. What it does not hold is a name.

A directory is just a file whose contents map names to inode numbers. So a "name" is a directory entry, and several entries may point at the same inode. That is a hard link, and it is why the inode carries a link count rather than a name.

unlink() therefore does not delete a file. It removes one directory entry and decrements the link count. The inode and its blocks are freed only when the link count reaches zero and no process still holds the file open. This is the whole explanation for the classic operations puzzle: you delete a 40 GiB log, df reports no change, and the space returns the instant you restart the process holding it open.

Full lesson text

All 12 steps on one page, for reading, reference, and search.

Show

1. A file is an inode; the name is separate

The inode is the file. It holds the type, permissions, owner, size, timestamps, a link count, and the map from file offsets to disk blocks. What it does not hold is a name.

A directory is just a file whose contents map names to inode numbers. So a "name" is a directory entry, and several entries may point at the same inode. That is a hard link, and it is why the inode carries a link count rather than a name.

unlink() therefore does not delete a file. It removes one directory entry and decrements the link count. The inode and its blocks are freed only when the link count reaches zero and no process still holds the file open. This is the whole explanation for the classic operations puzzle: you delete a 40 GiB log, df reports no change, and the space returns the instant you restart the process holding it open.

2. The VFS: one interface, many filesystems

The kernel documentation describes the Virtual File System as "the software layer in the kernel that provides the filesystem interface to userspace programs", and the abstraction "which allows different filesystem implementations to coexist".

It is built from four object types, each with a table of function pointers a filesystem fills in:

  • superblock - a mounted filesystem instance, with super_operations.
  • inode - a filesystem object, with inode_operations. On disk for ext4, synthesised on the fly for procfs.
  • dentry - a cached name-to-inode link. The docs are explicit that dentries "live in RAM and are never saved to disc: they exist only for performance".
  • file - an open file description: a dentry pointer, a position, and file_operations.

This is why read() works identically on ext4, NFS, procfs, and a FUSE mount. The syscall reaches the VFS, which calls whatever read pointer that inode's filesystem installed.

3. The page cache is where your data actually goes

A write() to a normal file does not reach the device. The VFS finds or allocates page-cache pages for that inode and offset range, copies your bytes in, marks the pages dirty, and returns. The call is a memory copy that happens to update kernel bookkeeping.

Reads work the same way in reverse: a hit on a cached page never touches storage, and sequential access triggers readahead so the next pages arrive before you ask.

Writeback is governed by thresholds initialised in the kernel's mm/page-writeback.c. dirty_background_ratio starts at 10, the percentage of memory at which background writeback threads begin flushing. vm_dirty_ratio starts at 20, at which point the writing process itself is made to do writeback and stalls. dirty_writeback_interval is 5 seconds and dirty_expire_interval is 30 seconds, the longest a page is allowed to stay dirty.

So a successful write() means "in RAM". Nothing more.

4. From write() to the media

Note where the call returns. Everything after that point happens without the application watching.

flowchart TD
  A["Application calls write"] --> B["VFS dispatches to the filesystem"]
  B --> C["Page cache: bytes copied in, pages marked dirty"]
  C --> D["write returns success to the application"]
  C --> E["Writeback thread builds bio structures later"]
  E --> F["blk-mq per-CPU software queue"]
  F --> G["Hardware queue mapped to an NVMe submission queue"]
  G --> H["Device controller and its volatile write cache"]
  H --> I["Flash cells or magnetic platter"]

5. Buffered versus direct I/O

O_DIRECT asks the kernel to skip the page cache and DMA between your buffer and the device. It exists for applications that already maintain their own cache and do not want a second, dumber one competing for RAM. Database engines are the canonical users, and the trade-offs are covered from the database side in the storage and pages lesson.

BufferedO_DIRECT
Cached in kernel RAMyesno
Readaheadyesno
Write returns whencopied to page cachedevice acknowledges
Alignmentnone requiredbuffer, offset and length must be block aligned
Double caching with an app cacheyesno
Durability without fsyncnostill not guaranteed

The last row is the trap. O_DIRECT bypasses the page cache, not the device's volatile write cache. Unless the write is issued with a force-unit-access flag or followed by a flush, it can still be lost on power failure.

6. What fsync actually promises

fsync(fd) asks the kernel to push every dirty page for that file down the stack and, critically, to issue a cache-flush command to the device so the data leaves the drive's volatile buffer. It returns when the device says the data is on stable media.

Three things it does not do, each a real bug in the wild:

  1. It does not persist the directory entry. Create a file, write it, fsync() it, and after a crash the file may not appear in its directory. You must also fsync() the directory file descriptor.
  2. It does not cover other file descriptors. It is per open file, not per filesystem. syncfs() is the filesystem-wide version.
  3. It cannot fix a lying device. If the drive acknowledges a flush it has not completed, no kernel code can detect that.

fdatasync() is the cheaper sibling: it skips metadata not required to read the data back, such as the modification timestamp, saving an inode write per call.

7. The durable-replace recipe

Replacing a file safely is four operations, and every one of them is load-bearing.

int fd = open("config.json.tmp", O_WRONLY|O_CREAT|O_TRUNC, 0644);
write(fd, buf, len);
fsync(fd);        /* 1: the new contents are on stable media */
close(fd);

rename("config.json.tmp", "config.json");  /* 2: atomic swap */

int dir = open(".", O_RDONLY|O_DIRECTORY);
fsync(dir);       /* 3: the rename itself is now durable */
close(dir);

Drop step 1 and a crash can leave a correctly named file full of zeros, because the rename reached the journal before the data reached the media. Drop step 3 and the crash can lose the rename entirely, leaving the old file and an orphan temp file.

rename() over an existing name is atomic on POSIX filesystems: a reader sees either the old inode or the new one, never a partial file. That atomicity is what the whole pattern is built on.

8. When fsync fails, the rules get strange

A failed fsync() is not a retryable error, and for twenty years almost nobody knew.

When writeback fails, Linux historically dropped the failed pages and marked them clean rather than keeping them dirty for a retry, which otherwise lets an unplugged USB stick pin memory forever. The error is then reported to a file descriptor once and cleared. Jonathan Corbet's LWN write-up of the 2018 incident notes that Linux 4.13 improved reporting with errseq_t tracking, but a descriptor opened after the error still sees nothing.

PostgreSQL hit exactly this: Craig Ringer reported in March 2018 that a checkpoint saw EIO, retried, got success from the second fsync() because the flag had been cleared, and declared durable data that was gone. PostgreSQL now panics on fsync() failure instead of retrying.

Rebello and colleagues followed up at USENIX ATC 2020 in "Can Applications Recover from fsync Failures?", testing ext4, XFS and Btrfs against PostgreSQL, LMDB, LevelDB, SQLite and Redis, and found no application strategy sufficient to avoid data loss or corruption.

9. Journaling: why crash consistency is hard

Appending one block to a file is not one write. It is at least three: allocate the block in the free-space bitmap, update the inode's size and block map, and write the data. A crash between any two leaves the filesystem inconsistent, and some inconsistencies are worse than data loss. An inode pointing at a block the bitmap thinks is free will hand your bytes to the next file that allocates it.

A journal makes the group atomic. Write the intended metadata changes to a dedicated on-disk area, flush, mark the transaction committed, and only then apply the changes in place. After a crash, recovery replays committed transactions and discards incomplete ones. That turns an fsck proportional to filesystem size into a replay proportional to journal size.

The important subtlety: the journal protects metadata structure by default. It is not a promise about your file's contents.

10. The three ext4 journaling modes

The kernel's ext4 documentation defines three, selected at mount time:

  • data=journal - all data and metadata go through the journal. Safest, and every byte is written twice.
  • data=ordered - the default. Only metadata is journalled, but ext4 "write[s] all data before committing the associated metadata to the journal". The ordering is the point: metadata never becomes visible pointing at blocks whose contents have not landed.
  • data=writeback - metadata is journalled with no data ordering at all. The docs warn this "can cause incorrect data to appear in files which were written shortly before the crash": correct structure, stale or garbage contents.

The gap between ordered and journal is exactly where application-level durability lives. data=ordered guarantees the filesystem is coherent after a crash; it does not guarantee your last write() survived. That is what fsync() is for, and why databases layer their own logging on top rather than trusting the filesystem journal.

11. Below the filesystem: the block layer and the device

Once writeback runs, the filesystem builds bio structures, each describing a transfer between a set of pages and a device range. Since Linux 3.13 these enter blk-mq, the multiqueue block layer: per-CPU software queues that merge and sort, feeding hardware queues that map one-to-one onto NVMe submission and completion queue pairs. The old single request queue with its global lock could not saturate an SSD; this design can, which is why NVMe devices commonly run with no I/O scheduler at all.

Underneath, the abstraction leaks. Flash cannot overwrite in place: a page is programmed once and can only be reset by erasing its whole block, often megabytes. A flash translation layer therefore writes updates elsewhere and remaps, garbage-collecting stale pages in the background. The consequences are visible from above: a 4 KiB update can cost far more than 4 KiB of flash wear, and latency is occasionally dominated by garbage collection you cannot see or schedule.

12. Gotchas to carry away

  • A successful write() means nothing about durability. It means "copied into RAM". Only fsync() or fdatasync() changes that.
  • Never retry a failed fsync(). The second call can return success while the data is gone. Treat it as fatal, the way PostgreSQL now does.
  • fsync() the directory after creating or renaming. Otherwise the file exists and its name does not.
  • Free space and deleted files are different questions. df counts blocks; a deleted-but-open file still holds them. lsof +L1 finds them.
  • O_DIRECT is not a durability feature and not a speed feature. It removes a cache. If you do not have a better cache of your own, you will simply be slower.
  • Benchmarks that never call fsync() are measuring your RAM. Any storage comparison without an explicit sync policy is measuring the page cache.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. You delete a 40 GiB log file, but df shows no additional free space until you restart the service that was writing it. Why?
    • The filesystem journal must be checkpointed before space is released.
    • unlink() only removes a directory entry and decrements the link count; the inode's blocks are freed when the link count is zero AND no process holds it open.
    • df caches its output and must be re-run with the --sync flag.
    • Deleted blocks are moved to a reserved pool and only released by the writeback thread.
  2. An application calls write() and gets a successful return, then the machine loses power one second later. What can it assume about the data?
    • It is durable, since a successful write() means the device acknowledged the transfer.
    • It is durable if the file was opened with O_DIRECT.
    • It is durable if the filesystem is mounted data=ordered.
    • Nothing. The bytes were copied into dirty page-cache pages, and writeback may not have run.
  3. A service calls fsync(), receives EIO, logs a warning, and retries; the second fsync() returns 0. What is the correct interpretation?
    • The second call succeeded because the kernel retried the writeback and it worked.
    • The failed pages were dropped and marked clean and the error flag was cleared, so the success is meaningless and the data may be lost.
    • EIO on fsync always indicates a transient device error that a retry resolves.
    • The retry was unnecessary because fsync() is idempotent by definition.
  4. An ext4 filesystem is mounted with the default data=ordered. What does that guarantee after a crash?
    • Every write() issued before the crash is present in the file.
    • Both data and metadata blocks were written twice, once to the journal and once in place.
    • Metadata is consistent and never points at blocks whose data had not yet been written, but recent file contents can still be missing.
    • Nothing at all, since only data=journal provides any crash guarantee.
  5. Why does replacing a config file safely require an fsync() on the containing directory as well as on the file?
    • Because directory entries live in a separate journal that fsync() on a file cannot reach.
    • Because rename() is not atomic unless the directory is synced first.
    • Because the directory fsync() is what flushes the device's volatile write cache.
    • Because fsync() on a file persists that file's data and metadata, not the directory entry naming it, so the rename can be lost in a crash.

Related lessons

Computer Science
intermediate

Syscalls and the Kernel Boundary

User code cannot touch a disk, a network card, or another process's memory without crossing into the kernel, and the crossing is not free. This lesson covers ring transitions and the syscall path, interrupts versus traps versus exceptions, measured boundary costs, why vDSO and io_uring exist, and how namespaces plus cgroups turn ordinary kernel features into containers.

11 steps·~17 min
Computer Science
intermediate

Virtual Memory: Page Tables, TLBs and Faults

Virtual memory is not a trick for pretending you have more RAM. It is the hardware and kernel machinery that gives every process a private, relocatable address space. This lesson covers multi-level page tables, why a TLB miss costs real cycles, demand paging, copy-on-write, mmap, swapping, huge pages, and how the OOM killer picks a victim.

11 steps·~17 min
Computer Science
intermediate

Processes and Scheduling: What the Kernel Actually Runs

A process is an address space plus a control block, and the thing Linux actually schedules is neither. This lesson walks the kernel side: what a context switch physically costs, how per-CPU run queues and preemption flags work, how nice values become weights, and why CFS was replaced by EEVDF in Linux 6.6.

11 steps·~17 min
Computer Science
advanced

What Has Actually Been Verified, and What It Cost

Two landmark systems carry machine-checked proofs of real code: a C compiler and an operating system kernel. Their published effort figures give the honest price of full verification, and their trusted computing bases show exactly what a proof still leaves unproved. This lesson uses both to decide where verification pays.

8 steps·~12 min