The VFS
Three concepts in the six-part treatment: the four objects, path lookup, and the I/O
paths — buffered, direct, and io_uring.
The Virtual File System is the reason read() works identically on a file, a pipe, a socket, a
device node, and /proc/cpuinfo. It is a set of function-pointer tables and a great deal of shared
machinery around them, and it is the most reusable piece of design in the kernel.
Concept 1: The Four Objects
1. What problem it solves
"File" means four different things that people conflate: the bytes on disk, the name you used to get to them, the fact that you have it open at a particular position, and the filesystem it lives in. Each has a different lifetime and a different sharing rule.
Two processes open the same file.
- the same INODE (one file on disk)
- probably the same DENTRY (if they used the same path)
- DIFFERENT struct file (different offsets, different flags)
One process opens the same file twice.
- the same inode, the same dentry, TWO struct files.
`ln` creates a second name.
- the same inode, TWO dentries.
That is why there are four objects and not one.
2. Where it exists in the kernel
rg -n "struct file \{" -A 35 include/linux/fs.h
rg -n "struct inode \{" -A 50 include/linux/fs.h
rg -n "struct dentry \{" -A 30 include/linux/dcache.h
rg -n "struct super_block \{" -A 40 include/linux/fs.h
$EDITOR Documentation/filesystems/vfs.rst
3. Who owns each, and for how long
| Object | Created by | Lives until | Shared between |
|---|---|---|---|
struct file | open() | The last close() of that fd (and its dups) | Only via dup/fork |
struct inode | The filesystem, on first lookup | Evicted from the inode cache under pressure | Every opener and every hard link |
struct dentry | Path lookup | Evicted from the dcache under pressure | Every process using that path |
struct super_block | mount | umount | The whole system |
cat /proc/sys/fs/dentry-state # nr_dentry, nr_unused, age_limit, ...
cat /proc/sys/fs/inode-nr
ls -l /proc/self/fd/ # your struct files, as symlinks
sudo slabtop -o | grep -E 'dentry|inode_cache|ext4_inode'
4. The four ops tables
This is the architecture. Everything else is detail.
| Table | On | Answers |
|---|---|---|
file_operations | inode (i_fop), copied into each struct file | What can I do to this open file? read_iter, write_iter, mmap, fsync, unlocked_ioctl, poll |
inode_operations | inode (i_op) | What can I do to this name-space object? lookup, create, unlink, rename, symlink, getattr, permission |
address_space_operations | inode->i_mapping->a_ops | How do I move this file's pages to and from the device? read_folio, writepages, dirty_folio, direct_IO |
super_operations | superblock (s_op) | How do I manage this filesystem? alloc_inode, write_inode, evict_inode, sync_fs, statfs |
The split between file_operations and inode_operations is the one people get wrong:
inode_operations is about names and metadata; file_operations is about contents. unlink is
an inode op because it removes a name. read is a file op because it moves bytes.
/* A minimal filesystem, structurally: */
static const struct super_operations myfs_sops = { .alloc_inode = ..., .statfs = ... };
static const struct inode_operations myfs_dir_iops = { .lookup = ..., .create = ... };
static const struct file_operations myfs_file_fops = { .read_iter = ..., .write_iter = ... };
static const struct address_space_operations myfs_aops = { .read_folio = ..., .writepages = ... };
5. Experiment
CLAIM. The four objects have independent lifetimes, and you can observe each one separately.
METHOD.
# One inode, two dentries:
echo hello > /tmp/a; ln /tmp/a /tmp/b
stat -c '%i %h %n' /tmp/a /tmp/b # same inode number, link count 2
# One inode, one dentry, two struct files with independent offsets:
exec 3</tmp/a; exec 4</tmp/a
head -c 2 <&3 ; echo
head -c 2 <&4 ; echo # both read from the START. Separate f_pos.
head -c 2 <&3 ; echo # fd 3 continues where IT left off
exec 3<&- 4<&-
# Two fds that SHARE a struct file:
exec 5</tmp/a; exec 6<&5 # dup, not open
head -c 2 <&5; head -c 2 <&6 # the second continues from the first
exec 5<&- 6<&-
PREDICT FIRST: for each of the three blocks, what does each head print? The dup case is the
one that surprises people, and it is the reason struct file exists separately from the inode.
Then watch the caches:
cat /proc/sys/fs/dentry-state
find /usr -type f > /dev/null # populate the dcache
cat /proc/sys/fs/dentry-state
sudo sh -c 'echo 2 > /proc/sys/vm/drop_caches' # drop dentries and inodes
cat /proc/sys/fs/dentry-state
6. Failure mode
| Mistake | Symptom |
|---|---|
| Storing per-open state on the inode | Two openers corrupt each other. It belongs in file->private_data. |
Putting a contents operation in inode_operations | It does not compile, and then you discover the split |
Not implementing evict_inode | The filesystem leaks inodes; slabtop grows without bound |
| Assuming a dentry implies the file exists | Negative dentries record non-existence |
Holding a struct file reference forever | The filesystem cannot be unmounted |
Forgetting .owner = THIS_MODULE in file_operations | rmmod while open → jump into freed text |
Concept 2: Path Lookup
1. What problem it solves
open("/usr/lib/x86_64-linux-gnu/libc.so.6") must resolve seven components, each requiring a
directory read, a permission check, and possibly a symlink traversal — and it happens millions of
times a second on a busy machine, mostly for paths that were resolved a microsecond ago.
Path lookup is one of the most heavily optimized paths in the kernel, and the optimization is visible in its structure.
2. Where it exists in the kernel
rg -n "link_path_walk|path_lookupat|walk_component" fs/namei.c | head
rg -n "lookup_fast|__d_lookup_rcu" fs/namei.c fs/dcache.c | head
$EDITOR Documentation/filesystems/path-lookup.rst # one of the best docs in the tree
3. The two walks
RCU-WALK (the fast path)
- no locks, no reference counts, no atomic operations
- reads the dcache under rcu_read_lock()
- uses a per-dentry SEQLOCK to detect that something changed
- if ANYTHING is unexpected -- a miss, a symlink, a permission
question it cannot answer, a mount point -- it BAILS OUT
- "unlazy_walk()" converts to ref-walk and retries from there
REF-WALK (the slow path)
- takes references and locks as it goes
- can call into the filesystem for a real lookup
- always works
The performance of the whole system depends on RCU-walk succeeding, which is why the dcache is such a large piece of machinery and why negative dentries matter:
NEGATIVE DENTRY: a dentry with d_inode == NULL.
It records that "libfoo.so does not exist in this directory".
Without it, every failed lookup would hit the filesystem again.
A program searching ten library paths for one file creates nine
negative dentries and then finds them cached forever after.
sudo bpftrace -e 'kprobe:link_path_walk { @[comm] = count(); }'
cat /proc/sys/fs/dentry-state # column 2 is nr_unused, largely negative
sudo perf stat -e 'syscalls:sys_enter_openat' -- ls -R /usr >/dev/null 2>&1
4. Experiment
CLAIM. The dcache dominates path-lookup cost, and you can measure both states.
METHOD.
# Cold: drop dentries and inodes (2), not the page cache.
sudo sh -c 'echo 2 > /proc/sys/vm/drop_caches'
time find /usr -type f -name 'nothing-matches' 2>/dev/null
# Warm: the dcache is now populated.
time find /usr -type f -name 'nothing-matches' 2>/dev/null
# And negative dentries specifically:
sudo sh -c 'echo 2 > /proc/sys/vm/drop_caches'
cat /proc/sys/fs/dentry-state
for i in $(seq 20000); do stat "/tmp/does-not-exist-$i" 2>/dev/null; done
cat /proc/sys/fs/dentry-state
PREDICT FIRST: the cold/warm ratio for find. And: does stating 20,000 non-existent files
increase nr_dentry? By how much?
5. Failure mode
| Mistake | Symptom |
|---|---|
A d_op callback that sleeps | RCU-walk cannot call it; every lookup falls back to ref-walk and the system slows down measurably |
A filesystem whose lookup is slow | Every cold path resolution pays; users experience it as "the first access is slow" |
| Creating and deleting millions of files | Negative dentries accumulate until the shrinker runs |
| Assuming a path is stable | It can be renamed under you; that is what the seqlock detects |
Resolving a user path without LOOKUP_* flags | Symlink and .. traversal you did not intend — a security bug |
Concept 3: The I/O Paths
1. What problem it solves
There are three ways to move bytes between a file and a program, and they exist because they make genuinely different trades.
2. Where they exist in the kernel
rg -n "vfs_read\b|vfs_write\b" -A 25 fs/read_write.c | head -40
rg -n "filemap_read\b|generic_perform_write" mm/filemap.c | head
rg -n "iomap_dio_rw|iomap_file_buffered_write" fs/iomap/*.c | head
ls io_uring/
3. The three
| Buffered | Direct (O_DIRECT) | io_uring | |
|---|---|---|---|
| Goes through the page cache | Yes | No | Either |
| Syscall per I/O | Yes | Yes | No — a shared ring |
| Alignment requirements | None | Offset, length, and buffer must be block-aligned | Depends on the op |
| Readahead | Yes | No | For buffered ops |
| Write returns when | Data is in the page cache | The device has it (but maybe in its cache) | On completion, via the ring |
| Best for | Almost everything | Applications with their own cache (databases) | High IOPS, many concurrent ops |
Buffered is the default and usually right: the cache absorbs re-reads, readahead hides latency, and writes are batched.
Direct hands the pages to the device with no copy and no caching. It is often slower — you gave up readahead and caching — and it is right when the application caches better than the kernel can, which for a database is often true.
io_uring is not a third caching policy; it is a different submission model. Two shared
ring buffers, one for submissions and one for completions, so a program can queue a thousand
operations and reap them with no syscall at all in the best case.
io_uring:
userspace kernel
───────── ──────
write SQE into the SQ ring
(optionally) io_uring_enter() ──▶ pick up SQEs
submit them
...work happens asynchronously...
read CQE from the CQ ring ◀──── post completions
With SQPOLL, a kernel thread polls the SQ ring and the fast path
involves NO SYSCALL AT ALL.
rg -n "struct io_uring_sqe \{" -A 30 include/uapi/linux/io_uring.h
rg -n "IORING_OP_" include/uapi/linux/io_uring.h | head -30
ls io_uring/
$EDITOR Documentation/ 2>/dev/null; find Documentation -iname '*io_uring*'
4. Experiment
CLAIM. Buffered, direct, and io_uring have measurably different profiles, and "direct is
faster" is usually false.
METHOD.
dd if=/dev/urandom of=/tmp/io bs=1M count=512 status=none
# Buffered, cold and warm:
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
dd if=/tmp/io of=/dev/null bs=4k 2>&1 | tail -1 # cold buffered
dd if=/tmp/io of=/dev/null bs=4k 2>&1 | tail -1 # warm buffered
# Direct, cold and "warm" (there is no warm -- that is the point):
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
dd if=/tmp/io of=/dev/null bs=4k iflag=direct 2>&1 | tail -1
dd if=/tmp/io of=/dev/null bs=4k iflag=direct 2>&1 | tail -1
# With a larger block size, which changes the answer:
dd if=/tmp/io of=/dev/null bs=1M iflag=direct 2>&1 | tail -1
# And the syscall counts:
sudo perf stat -e 'syscalls:sys_enter_read' -- dd if=/tmp/io of=/dev/null bs=4k 2>/dev/null
PREDICT FIRST: rank the five dd invocations by throughput before running. Most people put
bs=4k iflag=direct too high and warm buffered too low.
If fio is available, the comparison that actually matters:
fio --name=buffered --rw=randread --bs=4k --size=512M --filename=/tmp/io --ioengine=psync
fio --name=direct --rw=randread --bs=4k --size=512M --filename=/tmp/io --ioengine=psync --direct=1
fio --name=uring --rw=randread --bs=4k --size=512M --filename=/tmp/io --ioengine=io_uring --direct=1 --iodepth=32
5. Failure mode
| Mistake | Symptom |
|---|---|
O_DIRECT with unaligned buffers or offsets | -EINVAL, and the alignment requirement is device-dependent |
Assuming O_DIRECT means durable | It bypasses the page cache, not the device's write cache. You still need fsync or FUA. |
| Benchmarking buffered I/O without dropping caches | You measured memcpy |
Using O_DIRECT with small block sizes | You pay full device latency per I/O with no readahead |
Using io_uring and blocking on every completion | You reimplemented synchronous I/O with more machinery |
Assuming io_uring operations are ordered | They are not, unless you link them (IOSQE_IO_LINK) |
| Mixing buffered and direct I/O on one file | Coherency is mostly handled and the corners are sharp. Do not. |
Validation / Self-check
- Name the four VFS objects and give, for each, its lifetime and what it is shared between.
- Two processes
open()the same path. Which objects are shared and which are not? - Two fds from
dup(). Same question. Why does the answer differ? - Name the four ops tables and the question each answers.
- Why is
unlinkan inode operation andreada file operation? - Describe RCU-walk and ref-walk, and name three things that force a bail-out.
- What is a negative dentry and what does it make cheap? Show the counter that proves they exist.
- Why does a
d_opcallback that sleeps hurt system-wide performance? - Compare buffered, direct, and
io_uringon four axes. - Why is
O_DIRECToften slower, and when is it right? - Does
O_DIRECTmake a write durable? Explain precisely. - What are the two rings in
io_uring, and what doesSQPOLLremove?
Next: The Block Layer — how a page becomes a device command.