VFS Advanced: Lock Ownership, Persistent Updates, and Data Passing

On this page

When updating configuration files, you want readers to see only either the complete old version or the complete new version; however, locks, rename, and fsync each guarantee different things. This article breaks down this update along the lines of ownership, visibility, and persistence, and then examines buffer lifetimes in data passing.

Phenomenon to ExplainReading Location
Why does the lock disappear when closing another fd?Sections 1–2: Inodes and Lock Owners
Is a successful rename already crash-resistant?Section 3: Synchronizing Files and Directories Separately
Can the source content be modified after sendfile returns?Section 4: Copying and Reference Boundaries
Is the slowdown due to locks, logging, or the device?Section 5: Observing by Completion Points

1. Inode Flags Are Not a Simple Enumerated State Machine

i_state is a set of composable state bits; do not write I_NEW, I_DIRTY_SYNC, etc., as sequential numbers starting from 0. I_NEW relates to initialization publication; dirty bits describe different writeback requirements; I_FREEING, etc., participate in reclaim coordination. Specific bit values belong to the target kernel implementation and are not suitable as fixed constants for cross-version tutorials.

I_DIRTY_DATASYNC indicates that inode metadata modifications must also be handled by fdatasync, such as metadata affecting data reads, rather than simply "data pages dirty." Data page dirty status is tracked at the mapping/folio level. Similar field names do not imply identical protection scopes.

A reference count reaching zero does not necessarily mean immediate inode destruction: unused but cacheable inodes can remain in the cache and be evicted during reclaim; unlinked inodes and other cases have different handling. The finder, writebacker, and reclaimer during initialization must coordinate, and the role of unlock_new_inode() is not merely "clearing a flag." Reading in conjunction with fs/inode.c and the VFS Lifecycle Description is more reliable than memorizing a fixed function chain.

2. Owners of Three Types of Locks

Locks conflict on file ranges, but can have different owners Process P: fd A, fd B Two independent opens of one file POSIX range lock Owner: process P OFD range lock Owner: open instance A Ranges in the same inode Compare ranges for lock conflicts Closing B can release P's POSIX locks; it does not release the independently opened A's OFD lock.
LockScope and Ownerdup / forkRelease Boundary
POSIX F_SETLK/F_SETLKWByte range; process-associatedChild processes do not inherit parent's locksClosing any fd pointing to the same file in that process releases such locks on that file
OFD F_OFD_SETLK/F_OFD_SETLKWByte range; open-instance-associatedDescriptors sharing the open instance jointly hold the lockWhen the last relevant open reference is closed, or explicitly unlocked
Linux local flockUsually whole file; open-instance-associatedLocks are shared with shared open instancesWhen the last relevant reference is closed, or explicitly unlocked

OFD owners obtained via independent opens differ, causing conflicts even within the same process; dup of the same open instance does not create an independent competitor. The struct flock.l_pid input for OFD should be 0; when querying an OFD lock, its PID field has no meaning for ordinary process locks. See fcntl range locking.

These are typically cooperative locks: other callers may still read or write content if they do not follow the convention of acquiring locks. Historical Linux mandatory locking should not be treated as a modern general guarantee. Local flock and POSIX locks are usually independent mechanisms, but NFS/SMB implementations may simulate them via range locks and interact; local behavior cannot be directly applied to network file systems. See flock(2).

l_len=0 means the lock extends to the end of the file and includes any subsequently growing range, not an empty range. Blocking lock acquisition also requires handling signals, exits, and deadlocks; F_GETLK returns only the conflict observed at that moment, and the state may change subsequently. You cannot replace actual lock acquisition with "check for no lock first, then write without a lock."

Verifying the Last Reference Boundary for OFD

The following Linux C example uses a temporary file, two independent opens, and one dup; it does not rely on manual binary layout of struct flock:

#define _GNU_SOURCE
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static int set_lock(int fd) {
    struct flock lock = {
        .l_type = F_WRLCK, .l_whence = SEEK_SET,
        .l_start = 0, .l_len = 1, .l_pid = 0
    };
    return fcntl(fd, F_OFD_SETLK, &lock);
}
static void expect_conflict(int fd) {
    int rc = set_lock(fd);
    assert(rc == -1 && (errno == EAGAIN || errno == EACCES));
}
int main(void) {
    char path[] = "/tmp/ofd-demo-XXXXXX";
    int a = mkstemp(path);
    assert(a >= 0);
    int b = open(path, O_RDWR | O_CLOEXEC);
    assert(b >= 0);
    assert(unlink(path) == 0);
    int alias = dup(a);
    assert(alias >= 0);
    assert(set_lock(a) == 0);
    expect_conflict(b);
    assert(close(a) == 0);
    expect_conflict(b);            /* alias still holds the open instance */
    assert(close(alias) == 0);
    assert(set_lock(b) == 0);       /* Can only be acquired after the last reference is closed */
    assert(close(b) == 0);
    puts("OFD independent owners and last reference: Passed");
}

Compile with cc -std=c11 -Wall -Wextra -Werror ofd-demo.c -o ofd-demo. This example uses assertions for demonstration verification; do not add -DNDEBUG at runtime; production code should explicitly handle each failure branch.

3. Persistent Replacement: File Content and Directory Entries Submitted Separately

For configuration files, the common goal is for readers to see only the complete old version or the complete new version. Directly truncating the original file and then writing will cause concurrent readers to see an empty file or partial content. You can first prepare a temporary file in the same directory, write it completely and synchronize, then rename to replace the target, and finally synchronize the parent directory.

Break the update into four observation points:

StepBoundary Established HereWhat Is Not Yet Complete
Writing the temporary fileData is written to the interface acceptorNo synchronization completion guarantee
fsync temporary fileContent and required metadata synchronizedTarget name has not yet been replaced
rename replaces targetRuntime name binding changesDirectory update still needs synchronization
fsync parent directoryDirectory update synchronized per contractDoes not automatically form a cross-file transaction

Crashing before the last step does not allow you to promise the name state after restart based solely on a successful rename. See fsync(2).

Rename success alone does not promise the recovered name after a crash. fsync

Preparing the visual
An atomic name replacement: is the content durable too?

rename changes the live name binding. File and directory fsync establish separate durability boundaries for content and naming. Existing fds can still reference the old inode.

import os
import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory(prefix="replace-demo-") as directory:
    root = Path(directory)
    target = root / "config"
    target.write_bytes(b"old\n")
    dirfd = os.open(root, os.O_RDONLY | os.O_DIRECTORY)
    oldfd = os.open(target, os.O_RDONLY)
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(dir=root, delete=False) as file:
            temporary = Path(file.name)
            file.write(b"new\n")
            file.flush()
            os.fsync(file.fileno())
        os.replace(temporary, target)
        temporary = None
        os.fsync(dirfd)
        assert target.read_bytes() == b"new\n"
        assert os.pread(oldfd, 4, 0) == b"old\n"
        print("New open sees new version; old open reference still reads old file: Passed")
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)
        os.close(oldfd)
        os.close(dirfd)

It verifies runtime visibility and old references, but does not simulate power loss. Production updates also need to set new file permissions, owner, ACL/xattr, and handle uncertain states after failure; cross-directory moves involve related directories, and cross-filesystem rename usually cannot be completed in this manner. Multiple files do not automatically form the same transaction just because each is fsynced.

Replacing the Inode Does Not Automatically Move Old Locks

There is another pitfall in combining locks with replacement: locks on the old inode do not automatically transfer to the new inode after replacement. If each process "opens the current file and then locks," they may lock different versions. Coordinating updates usually requires stable identity, lock objects agreed upon by participants, or higher-level protocols.

4. sendfile / splice: Which Data Transfer Segment Is Reduced?

sendfile avoids a userspace relay, not every possible copy File contents / page cache Kernel transfer path Page references / copies Socket / downstream object Userspace application Submit fd, range, and request Handle short transfers, backpressure, and source lifetime. Syscall transitions are not task context switches.
InterfaceConnects Which Data Path Segment
sendfileAvoids the application first reading to a user buffer, then writing to the downstream
spliceAt least one end is a pipe, connecting supported objects via pipe buffering
vmspliceConnects a user memory range to a pipe

Whether the kernel ultimately references the original page, copies it, or uses an intermediate buffer still depends on the file system, transmission, encryption, device capabilities, and the specific path.

"Zero-copy" usually refers to reducing specific CPU data copying, not the absence of DMA, absence of page reference costs, nor does it guarantee that sendfile always copies once or that adding SG always copies zero times. A single system call's user/kernel transition does not equal switching to another process; blocking, scheduling, and interrupts add different overheads. See sendfile(2).

Interface ContractState the Application Needs to Maintain
Returns positive but less than requested lengthAdvance the corresponding offset, continue with the remaining range
Non-blocking returns EAGAINWait for writable/readable, then retry, retaining completed progress
Downstream still references source pagesDo not modify content still referenced before the consumer completes
Pipe has capacity limitsManage producer/consumer backpressure, cannot infinitely stuff data

Successful Return and Source Memory Reusability Are Different Events

vmsplice(SPLICE_F_GIFT) is a strict commitment to memory page usage, requiring adherence to alignment limits, and guarantees that the gifted memory will not be modified subsequently; a successful return does not automatically remove the user mapping or notify the application that "immediate reuse" is granted. You cannot immediately rewrite a regular malloc buffer after the call and assume network transmission is complete. See vmsplice(2).

5. Observations Should Follow Objects and Completion Points

/proc/locks, lslocks can observe lock types and ranges; displayed content is affected by factors such as PID namespaces, and OFD locks do not correspond to ordinary holding PIDs. /proc/sys/fs/inode-state is the system cache state, not the exact count of currently active files. Synchronization latency needs to distinguish between dirty page writeout, log commit, device completion, and network acknowledgment; you cannot directly infer damage to a specific layer from the total time of a single syscall.

Source code entry points can be found separately along fs/inode.c, file lock implementations, fs/sync.c, and fs/splice.c; file lock source code layout may vary by version, so start by locating via interface names. Network file completion and retry semantics follow NFS.