JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

The Page Cache, Block I/O, and What fsync Promises

When you write to a file, the bytes almost never go straight to the disk — they land in RAM and the disk catches up later. This guide follows a write all the way down, from the page cache through the block layer to the platter, and pins down the one syscall that turns 'probably saved' into 'really saved'.

Your write doesn't reach the disk (and that's the point)

By now you have a solid picture of the storage stack from the earlier guides in this rung: the VFS layer gives every filesystem one API, an inode holds a file's metadata, extents map its bytes to ranges of disk blocks, and a journal keeps the on-disk structure consistent across a crash. This last guide ties the bow: it explains what actually happens, in time and in memory, between the instant your program calls write() and the instant the data is genuinely safe on the platter. The surprising headline is that those two instants are far apart, and the gap between them is where both speed and danger live.

Here is the truth that trips up almost everyone the first time. When write() returns successfully, your data is not on the disk. It is in a slab of kernel RAM called the page cache — the kernel's in-memory copy of file contents, held in page-sized chunks of 4 KiB. The kernel copies your bytes into a cache page, marks that page as dirty (meaning it differs from what's on disk), and returns to your program right away. The actual disk does not move yet. This is not a bug or a shortcut; it is the page cache doing its central job, and it is why a loop that writes a million small records can finish in milliseconds even though the disk could never keep up at that rate.

The same cache serves reads, and that is where its real payoff shows. The first time you read a file, the kernel must fetch it from disk and parks a copy in the page cache. Every later read of those same bytes is served straight from RAM, with no disk access at all — a thousand times faster. The page cache is not a small fixed buffer; it greedily uses all the RAM your applications aren't using, which is why a healthy Linux box shows almost no 'free' memory. That memory isn't wasted — it's full of recently touched files, ready to hand back instantly, and the kernel will evict it the moment a program genuinely needs the RAM.

Writeback: how the disk quietly catches up

So dirty pages pile up in RAM. Something must eventually carry them to disk, and that something is writeback: a background activity, run by dedicated kernel threads, that walks the dirty pages and schedules them out to storage. The whole game of writeback is to delay just long enough to win, without delaying so long that you lose too much on a crash. Delay buys two prizes. First, batching: a hundred small writes to the same file can be coalesced into one big sequential disk write, which spinning disks and even SSDs handle far more efficiently than a hundred scattered ones. Second, cancellation: if you write a temp file and delete it a second later, its dirty pages may never touch the disk at all — the work evaporates before it's done.

What triggers writeback? Two clocks and a high-water mark, roughly. A periodic timer flushes pages that have been dirty longer than a few tens of seconds, so nothing sits in RAM forever. A pressure threshold fires when dirty pages grow past a fraction of RAM, forcing writes to begin before the cache fills. And memory reclaim triggers it too: when the kernel wants to evict a page to make room, a clean page can simply be dropped (its data is identical on disk), but a dirty page must be written out first. This is the deep reason the page cache stays cheap to read from yet honest about updates — clean pages are free to discard, dirty ones carry a debt that writeback must pay.

Reads get the mirror-image optimization, called read-ahead. When the kernel notices you reading a file sequentially — block 10, then 11, then 12 — it bets you'll want 13, 14, 15 next, and fetches them into the page cache before you ask. By the time your next read() runs, the data is already in RAM, so the call returns instantly instead of stalling on the disk. Read-ahead is pure prediction: it pays off enormously for streaming a file front-to-back, and it backs off quietly when your access pattern looks random (jumping all over the file), where guessing ahead would just waste disk bandwidth on blocks you never read.

Down through the block layer to the device

When writeback decides a dirty page must go to disk, it doesn't talk to the hardware directly. It hands the work to the block I/O layer — the kernel subsystem that sits between the filesystem and the storage device, speaking in fixed-size blocks rather than individual bytes. The filesystem says 'write these page contents to logical blocks 4096 through 4103'; the block layer packages that into a request, queues it for the right device, and eventually a block-device driver turns it into the actual commands the SSD or disk understands. This is the same character/block/network split you met in the driver rung: filesystems live on top of block devices precisely because block devices offer random access to fixed-size, addressable chunks.

Inside the block layer sits the I/O scheduler, and its job is to decide the order in which queued requests go to the device. The order matters enormously for a spinning disk: the read/write head physically moves, and seeking from block 9000 to block 12 and back again is slow, so a scheduler can reorder and merge requests to minimize head travel — the storage version of an elevator sweeping floors in one direction instead of zig-zagging. For an SSD, which has no head and roughly uniform access time, the I/O scheduler mostly just merges adjacent requests and tries to keep latency fair across processes, since reordering for seek distance buys nothing. The lesson is honest: the scheduler's value depends on the physics of the device under it.

fsync: turning 'probably' into 'definitely'

Now the heart of the matter. Everything above is built on delay — your write sits in the page cache, dirty, waiting for writeback to get around to it. Most of the time that's fine. But what if you're a database committing a transaction, or a text editor saving a document the user must not lose? You need a way to say: stop, write my dirty data all the way to stable storage now, and don't return until it's truly there. That is exactly what fsync() does. You call fsync(fd) on an open file descriptor, and the kernel pushes every dirty page of that file through the block layer to the device, then blocks your thread until the device confirms the data is durable.

Be precise about the promise, because the precision is the whole point. fsync() guarantees that once it returns successfully, the file's data and the metadata needed to find that data (the file's size, its extent map in the inode) have reached durable storage — so after a power loss, a reboot will find your bytes intact. There is a leaner cousin, fdatasync(), that flushes the data and only the metadata strictly required to read it back, skipping a metadata-only update like the modification timestamp; it can be a touch faster when you don't care about that timestamp. And one sharp caveat: a successful fsync() promises your file is durable, not that the directory entry pointing to a brand-new file is durable — that needs its own fsync on the directory, a subtlety we'll see in the next paragraph.

There is one more layer of delay even fsync must defeat, and it's worth naming. Many storage devices keep their own volatile write cache — a little RAM on the drive that acknowledges a write before the bits are physically on the platter or in flash. A correct fsync() must therefore also send a cache-flush command that forces the device to commit that internal cache to permanent media before it reports success. When this is disabled or buggy — and historically some consumer drives lied about it — fsync() returns but the data isn't really durable, and a power cut at the wrong moment loses it anyway. So the chain of trust runs all the way down: page cache, then block layer, then the device's own cache, and durability means every link has been forced to the floor.

The atomic-rename pattern, and what crash consistency really buys

Knowing fsync() exists is half the lesson; using it correctly to update a file safely is the other half, and there is a single pattern worth memorizing. Imagine you have a config file and you want to overwrite it with new contents. The naive way — open the existing file, truncate it, write the new bytes — is a trap: if the power dies mid-write, you're left with a half-written file that is neither the old version nor the new one, just corruption. The fix is the write-new-then-rename dance, and it leans on a guarantee the filesystem gives you: rename() of one existing name over another is atomic. At any instant a reader sees either the complete old file or the complete new file, never a torn mixture.

  1. Write the full new contents into a fresh temporary file in the same directory (same directory so the rename stays within one filesystem and is therefore a cheap metadata operation, not a copy).
  2. Call fsync() on the temp file's descriptor and check that it returns 0, so the new data is durably on disk before anyone can see it under the real name.
  3. Call rename("config.tmp", "config") to atomically swap the new file into place over the old one; readers see one or the other, never a partial file.
  4. fsync() the containing directory too, so the rename itself (a directory-entry change) is durable; otherwise a crash could lose the rename and leave the old file in place.

Step back and notice how this pattern leans on everything earlier in the rung. The atomicity of rename() is not magic — it's the journal from guide three doing its job, recording the directory-entry change as a single all-or-nothing transaction so a crash can never tear it. A copy-on-write filesystem from guide four achieves the same atomicity by a different route, never overwriting live data in place at all. Either way, crash consistency is the property that makes the whole scheme trustworthy: after any power loss, the filesystem comes back to a valid state — some consistent point in time, never a corrupt jumble of half-applied changes. fsync() then lets you pin which valid state you land on.

Putting the stack together — and an honest closing word

Trace a single byte one last time, top to bottom. Your program calls write(), crossing the syscall boundary into the kernel; the byte is copied into a dirty page in the page cache and write() returns — fast, but not yet durable. Later, writeback hands that page to the block layer, where the I/O scheduler orders it among other requests and a block-device driver issues the command; on an SSD an FTL remaps it one more time. Only when you call fsync() — flushing the page, the metadata, and the device's own cache — does the byte become genuinely safe against power loss. Every layer in that chain exists to trade a little risk for a lot of speed, and fsync is the lever that buys the risk back when you truly need to.

Two honest warnings before you go, because this is exactly where careful systems programmers and careless ones part ways. First, fsync() can fail — the disk can be full or dying — and you must check its return value like any other syscall; a database that ignores an fsync error can silently report a transaction committed when it wasn't. Second, fsync() is expensive on purpose: it stalls your thread until real hardware confirms, often for several milliseconds, an eternity compared to a cached write. So the craft is to call it deliberately — at the points where durability genuinely matters, like a transaction commit or a save — and never reflexively after every small write, which would throw away the entire performance gift the page cache exists to give.