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.