VFS 进阶:锁所有权、持久更新与数据传递

本页目录

配置文件更新时,希望读者只看到完整旧版或完整新版;但锁、rename 和 fsync 各自保证的事情不同。本篇沿所有权、可见性、持久性拆开这次更新,再看数据传递中的缓冲区寿命。

要解释的现象阅读位置
关闭另一个 fd,为何锁也消失?第 1~2 节:inode 与锁所有者
rename 成功是否已经耐受崩溃?第 3 节:文件与目录分别同步
sendfile 返回后,源内容能改吗?第 4 节:复制与引用边界
慢在锁、日志还是设备?第 5 节:按完成点观测

1. inode 标志不是一条简单枚举状态机

i_state 是可组合的状态位集合,不应把 I_NEW、I_DIRTY_SYNC 等伪写成从 0 开始的顺序编号。I_NEW 与初始化发布有关;dirty 位描述不同写回需求;I_FREEING 等参与回收协调。具体位值属于目标内核实现,不适合作为跨版本教程的固定常量。

I_DIRTY_DATASYNC 表示 fdatasync 也必须处理的 inode 元数据修改,例如影响数据读取的元数据,不是单纯“数据页 dirty”。数据页脏状态另有 mapping/folio 层面的跟踪。字段名称相似并不说明其保护范围相同。

引用数归零也不必立即销毁 inode:可缓存的未使用 inode 可留在缓存中,回收时再驱逐;未链接 inode 等情况有不同处理。初始化中的查找者、写回者、回收者必须协调,unlock_new_inode() 的作用也不只是“清一个标志”。结合 fs/inode.c 与 VFS 生命周期说明 阅读,比背一条固定函数链可靠。

2. 三类锁的所有者

锁冲突发生在文件范围,锁的所有者却可以不同 进程 P:fd A、fd B 两次独立 open 同一文件 POSIX 范围锁 所有者:进程 P OFD 范围锁 所有者:打开实例 A 同一 inode 的范围 用于检查锁之间的冲突 关闭 B 可释放 P 对该文件的 POSIX 锁;不会释放独立打开实例 A 的 OFD 锁。
锁范围与所有者dup / fork释放边界
POSIX F_SETLK/F_SETLKW字节范围;进程关联子进程不继承父进程的锁该进程关闭指向同一文件的任意 fd,会释放它对该文件的这类锁
OFD F_OFD_SETLK/F_OFD_SETLKW字节范围;打开实例关联共享打开实例的描述符共同持锁最后一个相关打开引用关闭,或显式解锁
Linux 本地 flock通常整文件;打开实例关联共享打开实例,锁随之共享最后相关引用关闭,或显式解锁

独立 open 得到的 OFD 所有者不同,即使在同一进程内也会发生冲突;同一打开实例的 dup 不产生一个独立竞争者。OFD 的 struct flock.l_pid 输入应为 0,查询到 OFD 锁时其 PID 字段没有普通进程锁的含义。见 fcntl 范围锁。

这些通常是协作式锁⁠:其他调用者不按约定加锁,仍可能读写内容。历史 Linux mandatory locking 不能当作现代通用保障。本地 flock 与 POSIX 锁通常是独立机制,但 NFS/SMB 的实现可能通过范围锁模拟并相互作用,不能把本地行为直接搬到网络文件系统。见 flock(2)。

l_len=0 表示锁到文件末尾且包括之后增长的范围,不是空范围。阻塞式加锁还需要处理信号、退出和死锁;F_GETLK 返回的只是当时观察到的冲突,随后状态可能改变,不能用“先查询没锁,再无锁写入”代替真正加锁。

验证 OFD 的最后引用边界

以下 Linux C 示例使用临时文件,两次独立 open 与一次 dup;不依赖 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 仍保持打开实例 */
    assert(close(alias) == 0);
    assert(set_lock(b) == 0);       /* 最后引用关闭后才可获得 */
    assert(close(b) == 0);
    puts("OFD 独立所有者与最后引用:通过");
}

可用 cc -std=c11 -Wall -Wextra -Werror ofd-demo.c -o ofd-demo 编译。本例把断言用于演示验证,运行时不要加 -DNDEBUG;生产代码应显式处理每个失败分支。

3. 持久替换:文件内容和目录项分别提交

对配置文件,常见目标是读者只看到完整旧版本或完整新版本。直接 truncate 原文件再写,会让并发读者看到空文件或部分内容。可以先在同一目录准备临时文件、完整写入并同步,再 rename 替换目标,最后同步父目录。

把更新拆成四个观察点:

步骤此时建立的边界尚未完成什么
写完临时文件数据被写入接口接受尚无同步完成保证
fsync 临时文件内容及所需元数据同步目标名字尚未替换
rename 替换目标运行时名字绑定改变目录更新尚需同步
fsync 父目录按契约同步目录更新不自动形成跨文件事务

最后一步前崩溃,不能只凭 rename 成功就承诺重启后的名字状态。见 fsync(2)。

下面把文件内容同步和目录同步变成两个独立动作。可在任意阶段模拟崩溃,直接查看恢复使用哪个名字绑定、读到哪个版本。

正在呈现知识画面
名字原子替换了,内容也持久了吗?

rename 改变运行时名字绑定,文件 fsync 与目录 fsync 分别建立内容和名字的持久边界。已有 fd 仍可引用旧 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("新打开看到新版本;旧打开引用仍读旧文件:通过")
    finally:
        if temporary is not None:
            temporary.unlink(missing_ok=True)
        os.close(oldfd)
        os.close(dirfd)

它验证运行时可见性与旧引用,不模拟断电。生产更新还要设置新文件权限、所有者、ACL/xattr,并处理失败后状态不确定;跨目录移动涉及相关目录,跨文件系统 rename 通常不能按此方式完成。多个文件也不会因各自 fsync 就自动形成同一个事务。

替换了 inode,旧锁不会自动跟过去

锁与替换组合还有一个陷阱:旧 inode 上的锁不会自动转移到替换后的新 inode。若各进程都“打开当前文件再加锁”,可能分别锁住不同版本。协调更新通常需要身份稳定、参与者共同遵守的锁对象或更高层协议。

4. sendfile / splice:减少哪一段数据搬运

sendfile 减少用户态中转,不承诺所有层都无复制 文件内容 / 页缓存 内核传输路径 引用页或按需复制 socket / 下游对象 用户态应用 提交 fd、范围与请求 短传输、背压与源内容寿命仍需处理;系统调用进出不等于进程上下文切换。
接口连接哪段数据路径
sendfile避免应用先 read 到用户缓冲,再 write 到下游
splice至少一端为 pipe,通过管道缓冲连接支持的对象
vmsplice把用户内存范围接入 pipe

内核最终引用原页、复制还是使用中间缓冲,仍取决于文件系统、传输、加密、设备能力和具体路径。

“零拷贝”通常指减少特定 CPU 数据复制,不是没有 DMA、没有页引用成本,也不是保证 sendfile 固定一次复制、加 SG 固定零次。一次系统调用的用户/内核转换不等于切换到另一个进程;阻塞、调度和中断又会增加不同开销。见 sendfile(2)。

接口契约应用需要维护的状态
返回正数但小于请求长度推进相应偏移,继续剩余范围
非阻塞返回 EAGAIN等待可写/可读后重试,保留已完成进度
下游仍引用源页不在消费者完成前修改仍被引用的内容
pipe 有容量限制管理生产/消费背压,不能无限塞入

成功返回与源内存可复用是不同事件

vmsplice(SPLICE_F_GIFT) 是对内存页使用的严格承诺,要求遵守对齐等限制,并保证之后不再修改所赠送内存;返回成功不是自动移除用户映射或向应用授予“可立即复用”的通知。不能把普通 malloc 缓冲在调用后马上改写,再假定网络发送已完成。见 vmsplice(2)。

5. 观测要跟随对象与完成点

/proc/locks、lslocks 可观察锁类型和范围;显示内容受 PID namespace 等因素影响,OFD 锁也不对应普通持锁 PID。/proc/sys/fs/inode-state 是系统缓存状态,不是当前活跃文件的精确数量。同步延迟需区分脏页写出、日志提交、设备完成和网络确认,不能从单个 syscall 总耗时直接推断某一层损坏。

源码入口可分别沿 fs/inode.c、文件锁实现、fs/sync.c 与 fs/splice.c 查找;文件锁源码布局可能随版本变化,先用接口名定位。网络文件的完成与重试语义接 NFS。