Operating Systems Lesson 5 of 6 ⏱️ 5 min read

File Systems & I/O

How files, directories and inodes work, buffering and caching, blocking vs non-blocking I/O, and why fsync matters.

Updated Oct 30, 2026

What is a file system?

A file system organises data on a storage device into files and directories, tracking where each file's bytes live, who owns them and when they changed. Common file systems: ext4 and XFS (Linux), NTFS (Windows), APFS (macOS).

Inodes: how Unix-like systems store files

On Linux, each file has an inode — a record containing its metadata and pointers to its data blocks. Surprisingly, the inode does not contain the file name.

Stored in the inode Stored in the directory
Size, owner, permissions File name
Timestamps (modified, accessed, changed) → the inode number it points to
Link count
Pointers to data blocks

A directory is just a special file mapping names to inode numbers. That design explains several behaviours:

  • Renaming a file within a file system is instant — only a directory entry changes.
  • A hard link is a second name for the same inode.
  • A file's data is deleted only when its link count reaches zero and no process has it open.
  • You can run out of inodes while still having free disk space (millions of tiny files).
Python
import os
import tempfile

folder = tempfile.mkdtemp()
original = os.path.join(folder, "notes.txt")
link = os.path.join(folder, "notes-link.txt")

with open(original, "w") as f:
    f.write("Operating systems are fun")

os.link(original, link)                    # hard link: a second name for the same file
print("same file:", os.stat(original).st_ino == os.stat(link).st_ino)
print("link count:", os.stat(original).st_nlink)

os.remove(original)                        # remove one name...
with open(link) as f:
    print("still readable:", f.read())     # ...the data survives through the other

Output:

Plaintext
same file: True
link count: 2
still readable: Operating systems are fun

A symbolic (soft) link, by contrast, is a small file containing a path. It can cross file systems and point to directories, but breaks if the target is moved or deleted.

File descriptors

When a process opens a file, the kernel returns a file descriptor — a small integer referring to an entry in the process's open-file table. Descriptors 0, 1 and 2 are stdin, stdout and stderr. Sockets and pipes are file descriptors too ("everything is a file").

Each process has a limit on open descriptors (ulimit -n). A server that forgets to close files or connections eventually fails with "Too many open files".

Buffering and the page cache

Disk access is slow, so data passes through several layers of buffers:

Plaintext
your program ──▶ language buffer (Python/libc) ──▶ OS page cache (RAM) ──▶ disk
                  flush()                            fsync()
  • Application buffering — write() in Python collects small writes and issues fewer system calls.
  • Page cache — the OS keeps recently used file data in RAM. Reading the same file twice is fast the second time. Unused RAM is used as cache, which is why Linux often shows little "free" memory.
  • Write-back — writes land in the page cache and reach the disk later.

Why fsync matters

If the power fails after write() returns, data still in the page cache is lost. Databases call fsync() to force data to the disk before confirming a transaction — that's what makes commits durable.

Python
import os

def save_durably(path: str, data: str) -> None:
    tmp = path + ".tmp"
    with open(tmp, "w") as f:
        f.write(data)
        f.flush()                 # Python buffer → OS
        os.fsync(f.fileno())      # OS page cache → disk
    os.replace(tmp, path)         # atomic rename: readers see the old or new file, never half

The write-temp-then-rename pattern prevents half-written files if the program crashes mid-write.

Blocking vs non-blocking I/O

  • Blocking I/O — read() puts the thread to sleep until data arrives. Simple, but one thread per connection doesn't scale to tens of thousands of clients.
  • Non-blocking I/O with event loops — the OS notifies the program when sockets are ready (epoll on Linux, kqueue on macOS/BSD, IOCP on Windows). One thread can juggle thousands of connections. This powers Nginx, Node.js, Redis and Python's asyncio.
Python
import asyncio
import time

async def fetch(name: str) -> str:
    await asyncio.sleep(1)          # stands in for a network call; the loop runs others meanwhile
    return name

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(fetch("users"), fetch("orders"), fetch("courses"))
    print(results, f"in {time.perf_counter() - start:.1f}s")

asyncio.run(main())

Output:

Plaintext
['users', 'orders', 'courses'] in 1.0s

Three one-second waits overlapped, finishing in about one second instead of three.

Direct memory access and zero-copy

Devices use DMA to move data to RAM without the CPU copying every byte. Servers use zero-copy system calls such as sendfile() to send a file straight from the page cache to a socket — one reason Kafka and Nginx serve files so efficiently.

Common mistakes

  • Not closing files and sockets — use with blocks / try-finally.
  • Assuming write() means the data is on disk — it's usually only in the page cache.
  • Writing config or data files in place — use temp file + atomic rename.
  • Blocking calls inside an async event loop — they freeze every other task.

Key takeaways

  • Unix file systems store metadata in inodes; directories map names to inodes.
  • Hard links share an inode; symbolic links store a path.
  • File descriptors reference open files, sockets and pipes, and they're limited.
  • Buffers and the page cache make I/O fast; fsync makes it durable.
  • Event loops with non-blocking I/O let one thread serve many connections.
Finished this lesson?
Tick it off to track your Operating Systems progress.
Next lesson