File System Reliability & Virtual File System

1. Lecture 14: Storage Naming

1.1. Storage roadmap context

Lecture 14 continues the storage and file-system part of the course.

The broad roadmap is:

  1. block storage;
  2. file-system structure;
  3. free-space management;
  4. directories and naming;
  5. reliability;
  6. POSIX / Unix file-system API;
  7. virtual file system.

The lecture first finishes the leftover topic of free-space management, then moves to naming and directories.

1.2. Free-space management

1.2.1. The basic question

When a file is created or grows, the file system must decide which free disk block or blocks to allocate.

This is the dual problem of file layout:

  • file layout asks: given a file and an offset, where is the data on disk?
  • free-space management asks: given a need for space, which unused blocks should be assigned?

The answer depends on the file organization scheme.

If the file system uses extents, it prefers contiguous runs of blocks, because a large contiguous region can be represented compactly and accessed efficiently.

If the file system uses indexed blocks, then any free block may be acceptable, because the inode or index structure can point to arbitrary blocks.

The two main goals are in tension:

  • allocation should be fast;
  • allocation should avoid excessive fragmentation.

1.2.2. Bitmap-based free-space tracking

A simple approach is a bitmap.

Each disk block corresponds to one bit:

  • 1 means the block is free;
  • 0 means the block is used.

Allocation:

alloc():
    find a set bit
    clear it
    return the corresponding block number

Freeing:

free(n):
    set bit n

Example size calculation:

\[ \frac{4\ \text{TB}}{4\ \text{KB}} = \frac{2^{42}}{2^{12}} = 2^{30}\ \text{blocks} \]

One bit per block gives:

\[ 2^{30}\ \text{bits} = 2^{27}\ \text{bytes} = 128\ \text{MB}. \]

So even a simple bitmap can be large.

The problem is not only storage overhead. A 128 MB bitmap does not fit conveniently in one page and may not fit in CPU caches. If allocation requires scanning the bitmap linearly, allocation can become slow, especially when free bits are far away.

1.2.3. Bitmap optimizations

Real systems add structure on top of the raw bitmap.

  1. Per-chunk statistics

    Break the bitmap into chunks.

    For each chunk, cache summary information such as:

    • number of free blocks;
    • largest contiguous free run;
    • maybe first or last free position.

    Then the allocator can skip chunks that clearly cannot satisfy a request.

    Example: if a file needs a run of 100 contiguous blocks, a chunk whose largest free run is 20 can be skipped immediately.

  2. Location hints

    Remember where the last allocation happened.

    The next search can start near that location, because nearby blocks may still contain useful free space.

    This also helps locality: a growing file may get nearby blocks.

  3. Whole-word scanning

    Instead of checking one bit at a time, scan machine words, for example 64 bits at a time.

    If a 64-bit word is zero, all 64 blocks are used and can be skipped at once.

  4. Batched allocation

    If a file asks for one block, the file system may reserve several nearby blocks for it.

    This is useful for growing files:

    • it avoids repeated allocator calls;
    • it keeps the file clustered;
    • it reduces fragmentation.

1.2.4. Other free-space schemes

  1. Linked list of free blocks

    Free blocks can be chained together, where each free block stores a pointer to the next free block.

    This is simple, but inefficient when many blocks are needed, because the file system has to follow many pointers.

  2. Grouped linked list

    Instead of linking individual blocks, each list node represents a group of blocks or a contiguous run.

    This is better for larger allocations, but more complex than a plain linked list.

  3. Free-space list

    A dedicated on-disk structure can list free regions, often as extents.

    This can be efficient for sequential allocation, but requires extra metadata maintenance.

  4. Tree-based free-space management

    Extent-based file systems often use trees, such as B+ trees, to manage free extents.

    Examples include XFS and Btrfs.

    The general lesson is that there is no universally best free-space scheme. The best choice depends on the file-system layout policy and workload.

1.3. Directories and naming

1.3.1. Why naming is needed

At a lower level, file systems can identify files by inode numbers.

But users do not want to say:

open inode 742

They want to say:

/home/laurent/pintos/readme

A directory provides the mapping from human-readable names to inode numbers.

On Linux, this mapping is visible with:

ls -i

This prints directory entries together with their inode numbers.

1.3.2. Two separate translations

Accessing a named byte range in a file involves two conceptually separate translations.

First:

\[ \text{name/path} \rightarrow \text{inode number}. \]

Second:

\[ (\text{inode number}, \text{file offset}) \rightarrow \text{storage block}. \]

The directory resolves the name.

The inode and its block index resolve the offset.

So the file system has at least three major data structures:

  • inode: owns file metadata and points to data blocks;
  • free map: decides which blocks are free or allocated;
  • directory: maps names to inode numbers.

1.4. Names and files

1.4.1. One name per file

In simpler file systems, such as FAT, a file has exactly one name.

In this design:

  • metadata can live directly in the directory entry;
  • there is no need for a separate inode abstraction;
  • sharing the same file under multiple names is not naturally supported.

This was historically associated with DOS / early Windows-style file systems.

1.4.2. Many names per file

Unix takes a different view: a file may have multiple names.

This requires an extra level of indirection:

\[ \text{name} \rightarrow \text{inode number} \rightarrow \text{file data}. \]

A directory entry contains:

  • a name;
  • an inode number.

Multiple directory entries may point to the same inode.

These multiple names are called hard links.

All hard links to the same inode are equivalent. There is no special “original” name.

1.4.3. Reference counts and unlink

Because several names may point to one inode, the inode stores a reference count, also called a link count.

When a new hard link is created, the count increases.

When a name is removed, the count decreases.

The POSIX operation is called unlink rather than delete because removing one name does not necessarily delete the file.

The file is actually deleted only when:

  1. no directory entry links to the inode anymore;
  2. no process still has the file open.

The lecture focused mainly on the first condition, the link count.

1.5. Directory implementation

1.5.1. Directories are files

A directory is itself a special kind of file.

It has:

  • an inode;
  • data blocks;
  • metadata.

The difference is that the file system interprets its contents as directory entries.

Users are normally not allowed to write arbitrary bytes directly into a directory file, because doing so could corrupt the directory structure.

1.5.2. Special entries: . and ..

Every Unix directory contains two special entries:

  • . points to the directory itself;
  • .. points to the parent directory.

These entries are actually represented in the directory structure, not just imaginary syntax.

They are conventionally maintained by the file system.

1.5.3. Directory entries as variable-length records

A Unix-like directory file is a sequence of records.

A simplified directory entry format is:

#define MAXNAMELEN 255

struct dir_entry {
    uint32_t d_ino;      /* inode number */
    uint16_t d_reclen;   /* length of this record */
    uint8_t  d_type;     /* file type, redundant with inode */
    uint8_t  d_namelen;  /* length of name */
    char     d_name[MAXNAMELEN + 1];
};

Important fields:

  • d_ino gives the inode number;
  • d_reclen gives the length of the whole record;
  • d_type stores the file type;
  • d_namelen stores the length of the name;
  • d_name stores the file name.

The record length makes the directory behave like a linked list: to find the next entry, add d_reclen to the current entry’s address.

1.5.4. Why duplicate the file type?

The file type is already stored in the inode.

However, storing it in the directory entry is an optimization.

For example, ls -l or a graphical file browser often wants to know whether entries are files or directories.

Without d_type, the system would need to load every inode just to display the directory.

With d_type, it can often list entries without loading every inode.

1.5.5. Variable-length names

File names have variable length.

Using fixed-size 256-byte entries for every name would waste space.

So directory records are sized according to the actual name length plus the fixed metadata fields.

This makes storage compact, but creates internal holes when entries are deleted.

1.6. Finding, creating, and deleting directory entries

1.6.1. Finding a file inside one directory

To look up a name in a simple directory:

  1. start at the beginning of the directory file;
  2. read one directory entry;
  3. compare the stored name with the target name;
  4. if it matches, load the inode;
  5. otherwise, advance by d_reclen;
  6. repeat until found or end of directory.

This is a linear scan.

The VFS layer can speed up repeated lookups using the dentry cache, which caches recently resolved directory entries.

1.6.2. Creating a file in a directory

Creating a file requires inserting a new directory entry.

The file system must:

  1. allocate or initialize an inode;
  2. find space in the directory file for the new name;
  3. write the directory entry;
  4. increment the inode’s reference count.

Because names must be unique inside a directory, the file system must scan the directory anyway to check that the name does not already exist.

During that scan, it can also look for a hole large enough for the new entry.

A hole can be detected using:

\begin{equation*} \text{hole size} = \text{previous record length} - \text{actual size of previous entry}. \end{equation*}

If a suitable hole exists, the new entry can reuse it.

If no hole exists, the directory entry is appended at the end.

If the end of the directory block is reached, the directory may need to grow by allocating another data block.

1.6.3. Deleting a file from a directory

Deleting a name is also a directory operation.

The system:

  1. finds the directory entry by name;
  2. removes the entry;
  3. updates the record lengths to cover the removed space;
  4. decrements the inode’s link count;
  5. if the link count becomes zero, frees the inode and its data blocks.

There is no general reverse lookup from inode number to all names pointing to it.

Therefore deletion normally starts from the directory name, not from the inode.

If the deleted entry is at the end, the directory can potentially be truncated.

If it is in the middle, the previous entry’s d_reclen is extended so that a linear scan skips over the removed entry.

1.7. Large directories and tree-based directories

1.7.1. Problem with linear scans

A directory with thousands or millions of entries makes linear scanning expensive.

Lookup, create, delete, and rename may all become slow.

Caching helps repeated lookups, but it does not fully solve creation or collision detection for new entries.

1.7.2. Tree-based directory indexes

A common optimization is to organize directory entries using a search tree.

Instead of comparing variable-length strings at every step, the file system hashes the name and uses the hash as a fixed-length key.

For example:

\[ \text{hash}(\texttt{foo.txt}) = 0x30. \]

Then lookup uses integer comparisons in a tree, conceptually similar to a B+ tree.

The tree maps from hashed names to directory entries.

Collisions are possible, because different names may have the same hash. This is handled by chaining or by checking the actual name at the leaf.

Examples:

  • ext3/ext4 use HTree directory indexing;
  • Btrfs also uses tree-based structures.

1.7.3. Tree index stored inside the directory file

A subtle point is compatibility.

Old file-system drivers may only understand the traditional linear directory layout.

Therefore, in ext-style designs, the tree index is stored inside the same directory file and arranged so that old scanners can ignore it.

The directory file contains both:

  • regular directory entry blocks;
  • tree index blocks.

The tree pointers are offsets inside the directory file.

A modern driver can use the tree.

An old driver can still scan the directory linearly and skip the hidden tree-related regions because they are covered by record lengths.

This is a compatibility trick: the on-disk format is extended without making older readers completely unable to read the directory.

1.8. Hierarchical path traversal

1.8.1. Resolving a full path

A path such as:

/home/laurent/pintos/readme

is resolved one component at a time.

The algorithm is:

  1. start at the root directory;
  2. look up home in the root directory;
  3. load the inode for home;
  4. check that it is a directory;
  5. look up laurent inside home;
  6. load the inode;
  7. continue with pintos;
  8. continue with readme;
  9. return the final inode.

The root directory has a known inode number. In ext-style file systems, the root inode is commonly inode 2.

A simplified recursive lookup is:

lookup(path) {
    return lookup_dir(root_inode, path);
}

lookup_dir(inode, path) {
    name, rest = split(path, '/');

    next_inode_no = scan_dir(inode, name);
    next_inode = load_inode(next_inode_no);

    if (empty(rest))
        return next_inode;

    if (next_inode->type != DIR) {
        errno = ENOTDIR;
        return NULL;
    }

    return lookup_dir(next_inode, rest);
}

Each directory has its own directory contents and, if optimized, its own tree index.

The tree index is not a global index over the whole file system. It only indexes entries inside one directory.

1.9. Hard links to directories

1.9.1. Why ordinary hard links to directories are forbidden

The data structures could represent a hard link to a directory, but the semantics become problematic.

  1. Ambiguous parent

    If a directory has two parents, what should .. mean?

    The directory tree is no longer a tree.

  2. Cycles

    A hard link could point a directory back to one of its ancestors.

    Then recursive traversal could loop forever.

  3. Reference-count leaks

    Cycles can keep reference counts above zero forever.

    Then unreachable directory subtrees might never be freed.

    For these reasons, Unix-like systems generally forbid user-created hard links to directories.

    The exceptions are the conventional . and .. entries, which the kernel understands and maintains specially.

1.9.2. Hard links cannot cross file systems

A hard link points to an inode number.

Inode numbers are meaningful only inside one file system.

Therefore hard links cannot cross file-system boundaries.

1.10. Symbolic links

1.10.1. Basic idea

A symbolic link, or soft link, is a special file whose content is a path string.

It does not point directly to an inode.

Instead, it stores something like:

/data/user/toni

When the kernel encounters the symbolic link during path lookup, it reads this path and continues lookup from there.

1.10.2. Hard link vs symbolic link

Hard link:

  • points directly to an inode;
  • target must exist;
  • increments the inode link count;
  • lightweight: just another directory entry;
  • cannot point to directories in ordinary user use;
  • cannot cross file systems.

Symbolic link:

  • stores a destination path string;
  • has its own inode;
  • may require data blocks for the path;
  • target may not exist, giving a dangling link;
  • can point to directories;
  • can cross file systems;
  • can create loops.

1.10.3. Lookup with symbolic links

When lookup reaches a symbolic link before the end of the path, the kernel reads the link target and restarts lookup.

Example:

/home/toni/file.txt

Suppose:

/home/toni -> /data/user/toni

Then the lookup becomes:

/data/user/toni/file.txt

A simplified version is:

lookup_dir(inode, path) {
    name, rest = split(path, '/');

    next_inode_no = scan_dir(inode, name);
    next_inode = load_inode(next_inode_no);

    if (empty(rest))
        return next_inode;

    if (next_inode->type == FILE) {
        errno = ENOTDIR;
        return NULL;
    }

    if (next_inode->type == SYMLINK) {
        buf = file_read(next_inode, 0, next_inode->size);
        return lookup(buf + "/" + rest);
    }

    return lookup_dir(next_inode, rest);
}

1.10.4. Symlink loops

Symbolic links can form loops.

Example:

a -> b
b -> a

The kernel usually does not try to solve this with arbitrary graph-cycle detection.

Instead, it caps symlink traversal depth.

On Linux, the limit is roughly 40 symlink traversals. If the limit is exceeded, lookup fails with ELOOP.

1.11. Real file-system layouts: FAT and FFS/ext

1.11.1. FAT: File Allocation Table

FAT is the original DOS file system.

It is simple and uses a one-name-per-file design.

Its on-disk layout contains:

  • boot sector;
  • file allocation table;
  • data blocks.

The boot sector stores metadata such as:

  • block size;
  • number of blocks;
  • location of the FAT;
  • location of the root directory.

The FAT itself has one entry per data block.

Each FAT entry points to the next block in the file, forming a linked list of blocks.

A directory entry stores:

  • file name;
  • starting block;
  • metadata inline.

There are no separate Unix-style inodes.

Sequential reads are relatively straightforward.

Random access deep into a large file can be slow, because the system may need to walk the linked list.

FAT may keep multiple copies of the FAT for resilience.

1.11.2. FFS and ext-style file systems

Unix Fast File System and descendants such as ext2, ext3, and ext4 are inode-centric.

The layout includes:

  • superblock;
  • inode bitmap;
  • free-block bitmap;
  • inode table;
  • data blocks.

The superblock stores information such as:

  • block size;
  • number of blocks;
  • number of inodes;
  • inode size;
  • root inode number;
  • locations of important regions.

The inode table has fixed-size inodes indexed by inode number.

The number of inodes is typically chosen when formatting the file system.

This loses some flexibility, but makes inode lookup efficient: the file system can compute the inode location with simple arithmetic.

1.11.3. Block groups

Ext-style file systems split the disk into block groups.

Each block group contains local metadata and data regions.

This improves locality:

  • an inode and its data blocks can be placed near each other;
  • related metadata can be kept nearby.

It also improves resilience:

  • replicated superblocks or group-local metadata can help recovery if part of the disk is damaged.

1.11.4. Inside an ext inode

An ext inode contains metadata fields such as:

  • file type;
  • access permissions;
  • user ID;
  • group ID;
  • file size;
  • sector count;
  • timestamps;
  • hard-link reference count;
  • flags and feature bits.

It also contains a block index.

Classic ext-style indexing uses:

  • direct block pointers;
  • one singly indirect pointer;
  • one doubly indirect pointer;
  • one triply indirect pointer.

Small files use direct pointers.

Large files use indirect blocks.

This lets the inode remain fixed-size while supporting large files.

1.12. Transition to reliability

At the end of Lecture 14, the lecturer motivates the next topic.

File-system updates are not single atomic operations.

Creating, deleting, or modifying a file may require updates to:

  • directory entries;
  • inodes;
  • allocation bitmaps;
  • data blocks;
  • metadata timestamps.

If the system crashes or loses power in the middle, the file system may be left inconsistent.

Lecture 15 studies how file systems preserve or recover consistency.

2. Lecture 15: File-System Reliability and VFS

2.1. Why file-system reliability is hard

2.1.1. File-system updates are multi-step operations

File systems maintain complex on-disk data structures.

A logical operation such as creating a file may require many physical writes.

Example: creating a regular file may require:

  1. finding and marking a free inode as allocated;
  2. initializing the inode with type, permissions, owner, timestamps, link count, size, and block pointers;
  3. finding and marking data blocks as allocated;
  4. writing the file’s data blocks;
  5. updating the inode to point to those blocks and record the size;
  6. updating the parent directory with a name-to-inode mapping;
  7. updating parent directory metadata, such as size and modification time.

These writes go to different places on disk.

Therefore the update is not naturally atomic.

2.1.2. Writes can be reordered

Without explicit flushes or write barriers, the storage stack may reorder writes.

This is especially obvious with sophisticated storage backends such as network block devices.

A network block device may look like one disk to the OS, but internally writes may go through a network to different servers or disks.

Different writes can arrive and persist in different orders.

A flush is a request to the I/O subsystem to ensure that all previous writes have reached stable storage before continuing.

2.1.3. Consistent states and transient states

The on-disk file system can be viewed as moving through a sequence:

\[ S_1 \rightarrow S_2 \rightarrow S_3 \rightarrow \cdots \]

Between consistent states, there are transient update phases.

During normal operation, the OS can hide transient states from applications.

The problem is that a crash can happen during a transient phase.

Then the on-disk representation may be inconsistent.

2.1.4. Causes of interrupted updates

Interrupted updates are a fact of life.

Examples:

  • power failure;
  • battery depletion;
  • plug pulled;
  • kernel panic;
  • watchdog reset;
  • machine reset;
  • storage-controller firmware crash;
  • controller reset;
  • accidental hot-unplug of USB or external SSD;
  • cable, bus, or network failure for remote storage;
  • virtual machine crash;
  • hypervisor crash;
  • host crash;
  • forced shutdown after the system stops responding.

Even if the file-system code is correct, it cannot assume that every multi-write operation will finish.

2.2. Possible corruption effects

A crash during an update may produce states such as:

  • allocated inode not referenced by any directory entry;
  • directory entry pointing to an unallocated inode;
  • directory entry pointing to a partially initialized inode;
  • inode pointing to a data block not marked allocated;
  • block bitmap saying a block is free while an inode still points to it;
  • same block allocated to two files;
  • link counts not matching actual directory references;
  • newly written file data missing, old, zero-filled, or partially written.

2.3. Data corruption vs metadata corruption

2.3.1. Data

Data is the byte stream that applications asked the file system to store.

If power is lost while an application is writing a video file, that file may be damaged.

This is bad, but the damage is local to that file.

2.3.2. Metadata

Metadata is the file system’s own structure, such as:

  • superblocks;
  • allocation bitmaps;
  • inodes;
  • directory entries;
  • indirect blocks;
  • extent trees;
  • free-space structures.

Metadata corruption is much more dangerous.

A small metadata corruption can make the entire file system unreadable.

Therefore many file systems focus primarily on protecting metadata.

They may preserve file-system structural integrity even if the newest user data is lost or stale.

2.4. Recovery goals

2.4.1. Forward recovery

Forward recovery means finishing the incomplete update after reboot.

If the system can infer that it was trying to move from state \(S_{n-1}\) to \(S_n\), it completes the missing work and reaches \(S_n\).

2.4.2. Backward recovery

Backward recovery means rolling back to the last known consistent state.

If the system cannot safely finish the operation, it restores or continues from \(S_{n-1}\).

Recent in-flight updates may be lost, but the file system becomes structurally consistent.

2.4.3. Recovery must itself be crash-safe

A major challenge is that recovery can also crash.

A correct design must tolerate:

normal operation
crash
recovery
crash
recovery
crash
...
eventual successful recovery

Recovery must not make the situation worse.

Eventually, when crashes stop, recovery should reach a consistent state.

2.5. Four approaches to file-system resilience

The lecture discusses four broad approaches:

  1. fsck: do nothing special during normal operation, then repair after failure;
  2. soft updates: carefully order writes to maintain structural consistency;
  3. write-ahead logging: log intended writes and replay committed transactions;
  4. copy-on-write file systems: never modify blocks in place; publish a new root atomically.

2.6. Approach 1: fsck

2.6.1. Basic idea

fsck means file-system check.

The simplest approach is:

  • do nothing special during normal operation;
  • after a crash, scan the file system;
  • find violated invariants;
  • try to repair them.

This is essentially “pick up the pieces after the crash.”

2.6.2. Why fsck is hard

A modern file system has many invariants.

After a crash, fsck sees only the final broken state.

It does not know which operation caused that state.

Different histories can produce the same broken state.

Therefore the correct repair action may be ambiguous.

2.6.3. Ambiguous corruption example

Suppose the system creates file:

d/x

Steps:

  1. allocate inode 17;
  2. initialize inode 17;
  3. add directory entry x -> 17 to directory d.

If write 3 reaches disk before write 2, and then the system crashes, fsck may observe:

  • directory d points to inode 17;
  • inode 17 contains old garbage or stale block pointers;
  • those blocks may be marked free.

What happened?

Possibility A: a file creation was interrupted.

Possibility B: a deletion or truncation was interrupted.

The visible corrupted state may not uniquely identify the history.

So fsck cannot always know whether to keep, delete, reconnect, or repair the inode.

2.6.4. What fsck can do

Traditional fsck uses conservative heuristics:

  • remove impossible references;
  • reconnect orphaned inodes;
  • rebuild free-space maps;
  • fix counters;
  • quarantine suspicious files;
  • put recovered files in lost+found.

This may restore structural consistency, but it can lose names, files, or recent updates, and may create spurious recovered files.

2.6.5. Performance problem

fsck may need to scan the entire file system.

On multi-terabyte disks, this can take hours.

A server that crashes may spend a long time checking disks before it becomes available again.

This is not acceptable for many modern systems.

2.7. Approach 2: Soft updates

2.7.1. Basic idea

Soft updates make fsck’s job simpler.

The key invariant is:

After any crash, the on-disk metadata must describe a structurally consistent file system.

The file system may still leak resources, such as blocks that are allocated but not reachable.

But the metadata graph should not contain dangerous impossible references.

Then fsck mainly has to clean up leaks, rather than guess the meaning of arbitrary corruption.

2.7.2. Enforcing write-ordering constraints

Soft updates explicitly track dependencies between metadata writes.

Typical rules:

  1. Never write a pointer to a newly allocated object before the object has been initialized and persisted.

    Example: initialize and flush an inode before writing a directory entry that names it.

  2. Never mark a resource free before all on-disk references to it have been removed.

    Example: remove old inode block pointers before marking the corresponding data blocks free for reuse.

The goal is to avoid crash states in which, for example, two files appear to own the same block.

2.7.3. Runtime implementation

The file system tracks metadata dependencies in the buffer cache.

Dirty metadata buffers are annotated with dependency information.

When flushing a metadata block, the system may:

  • delay the write until dependencies are satisfied;
  • reorder writes;
  • temporarily roll back part of an in-memory block to a safe version;
  • write that safe version to disk;
  • later restore the in-memory version and flush the remaining changes.

This is subtle because one metadata block may contain several fields with different dependencies.

2.7.4. Example implementation

Soft updates were implemented in UFS in FreeBSD.

The lecture emphasizes that this is impressive engineering, but difficult.

2.7.5. Downsides of soft updates

  1. High implementation complexity

    The file-system developer must identify all metadata dependencies.

    Missing one dependency can reintroduce inconsistent crash states.

  2. Hard to test

    The bug may only appear under a specific crash timing and write reordering.

    It is hard to test for “missing dependency” bugs.

  3. Hard to evolve

    Adding a new file-system feature may create new metadata dependencies.

    Therefore a soft-updates code base becomes difficult to modify safely.

  4. Recovery may still be slow

    Even if the metadata is structurally consistent, fsck may still need to scan the whole file system to find leaked blocks.

  5. Flushes hurt performance

    To enforce ordering, the file system may need flushes.

    Flushes prevent the storage stack from freely reordering or batching writes, reducing I/O performance.

2.8. Approach 3: Write-ahead logging

2.8.1. Basic idea

Write-ahead logging, or WAL, records intended writes before applying them to the main file-system locations.

The file system writes:

  • planned updates;
  • commit markers.

After a crash, recovery redoes all committed writes.

This is the same basic idea as database transactions.

2.8.2. Redo log and idempotence

The lecture focuses on redo logging.

The key property is idempotence:

\[ \text{writing the same value to the same location multiple times has the same final effect as writing it once.} \]

If recovery crashes while replaying the log, replay can simply start again later.

Repeating committed writes is safe.

2.8.3. WAL update protocol

A simplified redo-log protocol:

  1. write planned writes to the redo log;
  2. flush the planned writes;
  3. write a commit marker to the redo log;
  4. flush the commit marker;
  5. carry out the planned writes in the real file-system locations;
  6. eventually free or reuse the log entries after the writes are known to be persisted.

The flush before the commit marker is essential.

Without it, the commit marker could reach disk before the log records it claims to commit.

Then recovery might see a committed transaction whose contents are incomplete.

2.8.4. Recovery with WAL

Recovery does the following:

  1. scan the log from the last known checkpoint;
  2. identify complete transactions;
  3. verify records, checksums, and commit markers;
  4. ignore incomplete transactions without commit markers;
  5. redo committed transactions;
  6. advance the checkpoint after replayed writes are persisted;
  7. reuse old log space.

If recovery crashes, the same committed transactions can be replayed again.

Idempotence makes this safe.

2.8.5. WAL example: creating /d/x

Suppose the system creates an empty file /d/x using inode 17.

The intended updates include:

  • update inode bitmap: inode 17 is now used;
  • initialize inode 17;
  • update directory d: add x -> 17.

With WAL:

  1. write these intended updates to the log;
  2. flush the log records;
  3. write and flush the commit marker;
  4. later copy the changes from the log to their home locations.

Before the commit marker, the operation is considered not to have happened.

After the commit marker, recovery must eventually redo the operation.

2.8.6. Crash cases

  1. Crash before commit marker

    The real file-system locations have not been changed yet.

    The transaction has no commit marker.

    Recovery ignores it.

    The file creation did not happen.

  2. Crash after commit marker but before home-location writes

    The transaction is committed.

    Recovery replays it.

    The file creation happens.

  3. Crash during replay

    Recovery may have applied some writes but not all.

    On the next boot, recovery replays the transaction again.

    Because writes are idempotent, this is safe.

  4. Crash while checkpointing or freeing log entries

    If checkpoint advancement did not fully persist, the system may replay old committed transactions again.

    This is also safe because replay is idempotent.

2.8.7. Metadata-only logging vs full-data logging

Many journaling file systems log only metadata.

This ensures file-system structural consistency, but does not guarantee that the newest user data reached disk.

If user data is also logged, the guarantee is stronger, but write traffic increases significantly.

Applications with important data, such as databases, often implement their own WAL at the application layer.

For example, SQLite can protect its own data transactions.

The file system protects metadata integrity, while the application protects application-level data integrity.

2.8.8. Advantages of WAL

  • Principled and easier to reason about than fsck heuristics.
  • Independent of specific metadata structures.
  • Recovery only scans the log, not the whole file system.
  • Recovery is deterministic and bounded.
  • Natural batching: many metadata updates can be committed together.
  • Sequential log writes are efficient.
  • The log can be placed on a separate fast device.
  • Crash during recovery is easy to handle because redo is idempotent.

2.8.9. Disadvantages of WAL

  • Write amplification: metadata, and possibly data, is written first to the log and then to its final location.
  • The log can become a bottleneck.
  • The log can fill up, forcing old transactions to be checkpointed before more writes proceed.
  • Commit latency increases because flushes are required.
  • Metadata-only logging does not protect newest user data.
  • Full-data logging gives stronger guarantees but costs much more I/O bandwidth.

2.8.10. WAL in practice

WAL is the basis of journaling file systems.

Examples:

  • ext3;
  • ext4;
  • XFS;
  • NTFS;
  • HFS+ with journaling.

The tradeoff is usually attractive: extra writes during normal operation, but much faster and less ambiguous recovery after a crash.

2.9. Approach 4: Copy-on-write file systems

2.9.1. Basic idea

Copy-on-write, or COW, avoids in-place modification.

The core idea is:

If we never overwrite existing blocks, we cannot leave those blocks half-updated.

Instead of changing a block in place:

  1. copy the block;
  2. modify the copy;
  3. update parent pointers to point to the new copy;
  4. repeat up to the root;
  5. atomically publish the new root.

This is analogous to immutable trees in functional programming.

2.9.2. File system as an immutable tree

A COW file system is organized as a tree of blocks.

Blocks may contain:

  • metadata;
  • user data;
  • pointers to other blocks.

The on-disk file system has:

  • many blocks;
  • one or more root pointers indicating the current version.

The root pointer defines the currently visible file-system state.

2.9.3. COW update protocol

To modify a leaf block:

  1. allocate a new block;
  2. copy the old leaf block and apply the change;
  3. copy the parent so it points to the new child;
  4. continue copying ancestors up to the root;
  5. write a new root block;
  6. atomically switch the root pointer to the new root.

Old blocks remain intact until no live root or snapshot references them.

2.9.4. COW example: creating /d/x

Suppose the old tree has:

  • root block \(R\);
  • bitmap block \(B\);
  • directory block \(D\);
  • other shared block \(A\).

Creating /d/x using inode 17 requires changes to:

  • bitmap;
  • directory;
  • inode structure.

COW writes new blocks:

  • \(B'\): bitmap with inode 17 allocated;
  • \(D'\): directory with x -> 17;
  • \(I'\): new inode 17;
  • \(R'\): new root pointing to the updated blocks.

Before the root switch, these new blocks may exist on disk but are unreachable.

After the root pointer switches to \(R'\), the new file-system version is published atomically.

2.9.5. Recovery in COW

Recovery is simple:

  1. read the small set of possible root pointers;
  2. choose the newest valid root using generation numbers and checksums;
  3. ignore blocks not reachable from that root.

This is backward recovery: if the new root was not fully published, the system continues from the old root.

2.9.6. Multiple root pointers

Conceptually, one root pointer is enough.

In practice, file systems often keep several root pointers.

Reasons:

  • redundancy against bit flips or broken writes;
  • ability to check consistency with checksums;
  • generation numbers identify the newest valid root;
  • updates can rotate among root-pointer locations to reduce hot spots.

2.9.7. Broken writes and checksums

The lecture distinguishes crash interruption from another class of failures: broken writes or bit flips.

A storage device may claim to have written data, but reading it back may reveal corruption.

Modern COW file systems often use checksums to detect such corruption.

2.9.8. Batching updates

A naive COW model is sequential: one update publishes one new root.

Real systems batch many updates into one root switch.

For example, a file system may collect updates for a short time and publish them together.

This improves throughput but means more recent updates may be lost if a crash happens before the next root switch.

2.9.9. Advantages of COW

  1. Elegant recovery

    Recovery only needs to find the latest valid root.

    There is no need to scan the whole file system or replay a large log.

  2. Natural snapshots

    A snapshot is just an old root pointer that is kept alive.

    This gives cheap, atomic snapshots of the whole file system.

  3. Useful for backups

    A backup can operate on a consistent read-only snapshot.

    Users can keep modifying the live file system while the backup reads the old snapshot.

  4. Useful for OS upgrades

    Before an OS upgrade, the system can take a snapshot.

    If the upgrade fails, it can roll back to the snapshot.

  5. Cheap copies / clones

    Two files or directories can share blocks until one is modified.

    This is the same COW idea used in virtual memory.

  6. Snapshot isolation

    Readers can see one consistent version while writers prepare a new version.

2.9.10. Disadvantages of COW

  1. Write amplification

    Changing one logical block may require writing a new path of metadata blocks up to the root.

    Worst case: random one-byte writes.

    A tiny logical update can become:

    • one full block write;
    • plus metadata updates;
    • plus root updates.
  2. Scattering

    Because each update allocates new blocks, logically adjacent data may become physically scattered.

    This is bad for spinning disks, where sequential layout is important.

    SSDs reduce this downside because random access is much faster.

  3. Garbage collection complexity

    Old blocks cannot be freed while any snapshot or clone still references them.

    The file system must track reachability across multiple roots.

  4. Surprising space usage

    Deleting a file may not free space if a snapshot still references the file’s blocks.

    Users may delete large files and still see no free-space increase.

  5. Out-of-space handling

    Freeing space may itself require metadata updates.

    But metadata updates need free blocks.

    Therefore COW file systems need reserved space or special mechanisms to handle “out of space” safely.

2.9.11. COW in practice

COW is used in several modern file systems:

  • ZFS;
  • Btrfs;
  • APFS;
  • WAFL in NetApp appliances.

COW works especially well with SSDs because SSDs tolerate random access better than spinning disks.

Linux F2FS, the Flash-Friendly File System, also uses log-structured or COW-like techniques in its write path to fit flash storage behavior.

2.10. Summary of crash recovery approaches

The common problem is:

A crash can occur between any two persistent writes, and those writes may be reordered.

The solutions reduce arbitrary partial writes to well-understood cases.

  • fsck tries to infer and repair after the fact, but the state may be ambiguous.
  • Soft updates enforce write ordering so the on-disk metadata is always structurally consistent.
  • WAL writes intended updates to a log first, then redoes committed transactions after a crash.
  • COW writes a new version elsewhere, then atomically switches the root pointer.

WAL and COW are especially elegant because their correctness argument is mostly independent of the exact metadata format.

2.11. Virtual File System

Lecture 15 then starts the VFS topic.

2.11.1. Motivation

Early operating systems often supported one file system.

Later, systems needed to support many file systems for:

  • interoperability;
  • removable media;
  • CD-ROMs;
  • network file systems;
  • different workloads;
  • specialization.

It would be bad to expose separate system calls such as:

read_ext4(...)
read_fat(...)
read_nfs(...)

Applications should just call:

read(fd, buf, n)

The VFS is the abstraction layer that makes this possible.

2.11.2. VFS as a plugin system plus global namespace

The VFS acts as:

  1. a translation layer;
  2. a dispatch layer;
  3. a manager of the global namespace.

It maintains the Unix-style namespace rooted at:

/

It tracks:

  • mount points;
  • path lookup;
  • open files;
  • directories;
  • permissions;
  • metadata;
  • file descriptors;
  • common kernel file objects.

It maps generic operations to file-system-specific code.

Examples of concrete file systems:

  • ext4;
  • XFS;
  • tmpfs;
  • NFS;
  • procfs;
  • sysfs;
  • device files under /dev.

2.11.3. Why the VFS is an important engineering boundary

The VFS is not only for convenience.

It avoids duplicated implementations of common semantics.

For example, permission checking should not be independently reimplemented in every file-system driver.

If 17 file systems each implement security checks separately, one of them will likely get it wrong.

So the VFS centralizes common behavior, while file-system drivers handle storage-layout-specific details.

Examples of file-system-specific details:

  • journaling;
  • COW updates;
  • block allocation;
  • on-disk directory format;
  • persistence rules;
  • caching and locking details.

2.11.4. Uniform operations

From the application’s point of view:

read(fd, buf, n)
stat(path, &info)
mkdir(path)
rename(old, new)
unlink(path)

look the same regardless of the underlying file system.

The file may be:

  • on an ext4 partition on a local SSD;
  • in tmpfs in memory;
  • on an NFS remote server;
  • generated by procfs;
  • backed by a device driver.

The application does not need to know.

2.11.5. Limits of the abstraction

The VFS hides many details, but not all file systems support the same metadata.

Example:

  • FAT / DOS-style file systems do not store Unix users and permissions.
  • Modern file systems may support extended attributes.
  • Copying files with tools unaware of extended attributes may lose metadata.

So the abstraction works best for common operations such as names and byte streams, but file-system-specific features can still leak through.

2.12. Global namespace and mounting

2.12.1. Drive letters vs mount points

DOS / Windows traditionally expose file-system boundaries using drive letters:

C:
D:
E:

Historically, A: and B: were floppy drives, while C: became the main hard drive.

Unix-like systems instead use one global namespace rooted at /.

Different file systems are attached into this namespace using mount points.

2.12.2. Mounting

Mounting means exposing one file system at a directory inside an already existing namespace.

Example:

Before mounting:

/
├── bin
├── home
└── mnt
    └── usb

Suppose a USB file system has:

/
├── photos
└── report

Mount it at:

/mnt/usb

After mounting:

/mnt/usb/photos
/mnt/usb/report

When path lookup reaches /mnt/usb, the VFS switches from the parent file system to the mounted file system.

Applications continue to use ordinary paths.

This makes file-system boundaries mostly transparent.

2.13. FUSE: File System in Userspace

2.13.1. Basic idea

The VFS can dispatch operations to in-kernel file-system drivers.

FUSE extends this idea by forwarding file-system operations to a user-space daemon.

The path still looks like an ordinary mounted file system.

But operations such as:

  • lookup;
  • getattr;
  • read;
  • write;
  • readdir;

are handled by a normal user-space process.

2.13.2. FUSE architecture

A typical flow:

  1. application calls read("/mnt/remote/a");
  2. VFS resolves the path;
  3. VFS sees that /mnt/remote is a FUSE mount;
  4. the FUSE kernel module forwards the request to a FUSE daemon;
  5. the daemon handles the request using libraries, network APIs, databases, cloud APIs, SSH, archives, etc.;
  6. the daemon replies through the kernel module;
  7. the application receives the result as if it came from a normal file system.

2.13.3. Benefits

FUSE is useful because:

  • development is easier in user space;
  • bugs usually crash only the daemon, not the kernel;
  • complex libraries can be used;
  • research prototypes are easier to build;
  • teaching file systems can be implemented without writing kernel modules.

2.13.4. Costs

FUSE has overhead:

  • extra context switches;
  • copying between kernel and user space;
  • daemon availability matters;
  • performance may be lower than in-kernel file systems.

2.13.5. Examples

Common FUSE-style uses include:

  • sshfs: expose a remote directory over SSH as a local directory;
  • cloud/object-store file systems, such as exposing buckets as files;
  • archive file systems, such as browsing ZIP, TAR, or ISO contents without unpacking;
  • encrypted file systems;
  • research and teaching file systems.

2.14. “Everything is a file”

2.14.1. Kernel information as files

The kernel needs to expose many kinds of information:

  • processors;
  • processes;
  • network connections;
  • storage devices;
  • serial ports;
  • kernel parameters.

A bad design would add a separate system call for every kind of information.

Unix-like systems instead reuse the file interface.

Pseudo file systems expose kernel information as virtual files.

Examples:

  • /proc exposes process and kernel information;
  • /sys exposes system and device information;
  • /dev exposes device nodes.

These are not ordinary persistent files, but they use the same path and file-descriptor machinery.

2.14.2. Kernel objects as file-like resources

Many kernel-managed objects can also be treated through the file interface:

  • pipes;
  • sockets;
  • devices;
  • terminals;
  • timers in some systems;
  • pseudo-files;
  • kernel data objects.

The common interface is:

open(path)
read(fd)
write(fd)
close(fd)

Not every object supports every operation.

For example, a directory may not support normal writing, and a socket has different semantics from a disk file.

But the shared descriptor machinery simplifies the OS interface.

2.14.3. Not literally everything

The phrase “everything is a file” is a useful Unix mantra, but not perfectly literal.

Some abstractions still have special APIs.

For example, sockets have additional socket-specific operations.

The important idea is that many resources can share one naming and access model.

2.15. Plan 9 and the evolution of VFS

Plan 9 from Bell Labs was designed as a successor to Unix.

It pushed the “everything is a file” idea much further.

Its key mechanism is the 9P protocol.

9P provides a generic hierarchical service interface.

It can expose not only files, but also:

  • graphical user interfaces;
  • windows;
  • events;
  • semaphores;
  • network connections;
  • file systems;
  • other services.

Plan 9 also explored ideas such as:

  • per-process namespaces;
  • union mounts;
  • hierarchical service composition.

Many of these ideas have influenced mainstream systems later.

The broader lesson is that VFS evolved from an abstraction over storage file systems into a general mechanism for exposing many kinds of services through a common namespace and file-like interface.

Author: Lowtroo

Created on: 2026-06-28 Sun 16:27

Powered by Emacs 29.3 (Org mode 9.6.15)