mateusz@systems ~/book/filesystem-semantics $ cat section.md

Essential Linux Filesystem Semantics

Linux provides several atomic filesystem operations that are crucial for building robust systems. Understanding these semantics—and their limitations—is essential for reliable software.

# Atomic Rename

The rename() system call (ordinarily used by GNU mv when source and destination are on the same filesystem) can atomically replace an existing target pathname. During a successful replacement, other processes resolving the target pathname do not observe it missing or partially replaced. This is a namespace-atomicity guarantee, not by itself a crash-durability guarantee.

A common use is publishing a complete configuration update without exposing an intermediate pathname state. Create the new file beside the target, finish its contents, and replace the target only after each preceding step succeeds:

target = "/etc/myapp/config"
new_file = create_unique_file(in = same_directory_as(target))
write_complete_contents(new_file, new_config)
finish_writing(new_file)
atomically_replace(new_file.path, target)

Processes that subsequently resolve target see either the previous file or the complete replacement, not a partly replaced pathname. This example is about namespace atomicity. Making the new contents and directory entry survive a crash is a separate, filesystem-specific durability problem and is outside this pattern's scope.

Important limitation: rename() only works within the same filesystem. You cannot atomically rename across filesystem boundaries (e.g., from /tmp on tmpfs to /var on ext4). The system call will fail with EXDEV (cross-device link).

Code reference: fs/namei.c:vfs_rename() in the kernel.

NFS caveat: On NFS, the server performs the rename atomically, but handing the new file's contents to the server and making the resulting state survive failures are separate concerns. Verify the guarantees of the deployed client, protocol, server, and backing filesystem when either property matters. See the NFS close-to-open deep dive for the full protocol breakdown and RPC costs.

# Unlink with Open File Descriptors

On POSIX systems, unlink() removes a pathname. If that pathname was the file's last hard link but a process still has the file open, the file remains accessible through existing open descriptors. For this pattern, the lifetime is reference-counted: unlinking drops the name reference, open handles keep the file alive, and its storage can be reclaimed after the final open reference is released.

This provides a powerful pattern for temporary files with automatic cleanup:

new_file = create_unique_file(in = temporary_directory)
handle = new_file.handle
unlink(new_file.path)
use_file(handle)
close(handle)

If no other reference exists, process termination closes the handle and lets the filesystem reclaim the unnamed file, so no pathname is left for later cleanup. Duplicated, inherited, or transferred descriptors keep the file alive until they too are closed.

Passing the descriptor over a Unix-domain socket (for example, with SCM_RIGHTS) gives the receiving process a descriptor for the same open file description. Separately, on Linux and subject to access checks and the underlying object's permissions, another process can open /proc/<pid>/fd/<n> to obtain a new descriptor for the referenced ordinary file. These entries commonly show a (deleted) suffix after the pathname has been unlinked.

Code reference: fs/namei.c:vfs_unlink() removes the directory entry and updates link state; i_nlink records the inode's hard-link count. When the final inode reference is dropped, fs/inode.c:iput_final() consults drop_inode() and may evict the inode, with filesystem-specific eviction code performing final cleanup.

# O_TMPFILE: Unnamed Inodes

Modern Linux (3.11+) provides O_TMPFILE, which creates an unnamed inode with no directory entry from the start. This is cleaner than the open-then-unlink pattern.

int fd = open("/tmp", O_TMPFILE | O_RDWR, 0600);
// Unnamed regular file: no directory entry, accessible through fd
// ... work with file ...
// Optional: create a new pathname for it (check linkat() for failure)
char path[64];
sprintf(path, "/proc/self/fd/%d", fd);
linkat(AT_FDCWD, path, AT_FDCWD, "/tmp/result", AT_SYMLINK_FOLLOW);
close(fd);

Use cases: Build a file completely before atomically publishing it at a previously nonexistent pathname, and hold temporary data without ever creating a directory entry.

# File Locking

Linux provides two primary file locking mechanisms: flock() and POSIX fcntl() locks. They have different semantics and use cases.

flock() - BSD-style locks:

  • Locks the entire file (no byte-range locking)
  • Associated with open file description (shared by forked children)
  • Released explicitly or when all file descriptors referring to that same open file description are closed
  • Simple, easy to reason about
  • On Linux NFS clients since 2.6.12, normally emulated as a whole-file fcntl() lock; mount options can instead make locking local-only
int fd = open("datafile", O_RDWR);
flock(fd, LOCK_EX);  // Exclusive lock
// ... critical section ...
flock(fd, LOCK_UN);  // Unlock
close(fd);

fcntl() - POSIX locks:

  • Byte-range locking (can lock portions of a file)
  • Associated with process, not file descriptor
  • Released when any file descriptor to the file is closed by that process
  • More complex semantics, easier to misuse
  • Works over NFS: NFSv2/v3 use the sideband NLM protocol, while NFSv4 carries locking in the main protocol
struct flock fl;
fl.l_type = F_WRLCK;    // Write lock
fl.l_whence = SEEK_SET;
fl.l_start = 0;         // Offset
fl.l_len = 0;           // 0 means lock entire file
fcntl(fd, F_SETLKW, &fl);  // Block until lock acquired

Advisory locking: On ordinary local Linux filesystems, flock() and traditional POSIX fcntl() locks are advisory: cooperating processes must request or check locks. Linux no longer supports mandatory locking as of kernel 5.15.

NFS behavior: File locking over NFS is complex. flock() may be emulated using fcntl() locks. Lock state can be lost if the NFS server reboots. Modern NFSv4 has better lock recovery, but edge cases remain.

# Sparse Files and Hole Punching

Sparse files can contain holes: logical byte ranges that read as zeros without allocated data blocks. Filesystems may also allocate unwritten extents through operations such as fallocate(), and metadata still consumes space.

Creating a sparse file:

int fd = open("sparse", O_CREAT | O_WRONLY, 0644);
lseek(fd, 1024*1024*1024, SEEK_SET);  // Seek to offset 1 GiB
write(fd, "x", 1);                     // Write single byte
close(fd);
// Logical size is 1 GiB + 1 byte; on a sparse-capable filesystem, the hole uses no data blocks

fallocate() system call provides several operations:

  • Preallocate space: fallocate(fd, 0, offset, len) reserves space; previously unwritten portions read as zeros, commonly without physically writing zero-filled blocks
  • Punch holes: fallocate(fd, FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE, offset, len) deallocates full filesystem blocks in the range, zeros partial boundary blocks, and preserves file size
  • Zero range: fallocate(fd, FALLOC_FL_ZERO_RANGE, offset, len) makes the range read as zeros and, where supported, commonly converts it to unwritten extents while preallocating space rather than punching a hole

SEEK_HOLE and SEEK_DATA (available on Linux since 3.1) can seek to filesystem-reported hole and data regions without scanning every byte. Support and granularity are filesystem-dependent; a filesystem may conservatively report the whole file as data and only EOF as a hole.

off_t data = lseek(fd, 0, SEEK_DATA);  // Find first data
off_t hole = lseek(fd, data, SEEK_HOLE);  // Find next hole after that data

Tools such as cp --sparse=always and rsync --sparse can create sparse destination files by representing suitable zero-filled ranges as holes, avoiding unnecessary data-block allocation.