配置文件更新时,希望读者只看到完整旧版或完整新版;但锁、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. 三类锁的所有者
| 锁 | 范围与所有者 | 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);
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。
{"id":"durable-file-replacement","title":"名字原子替换了,内容也持久了吗?","summary":"rename 改变运行时名字绑定,文件 fsync 与目录 fsync 分别建立内容和名字的持久边界。已有 fd 仍可引用旧 inode。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"actions\"><button id=\"write\" data-i18n=\"write\"></button><button id=\"fileSync\" data-i18n=\"fileSync\"></button></div><div class=\"actions\"><button id=\"rename\" data-i18n=\"rename\"></button><button id=\"dirSync\" data-i18n=\"dirSync\"></button></div><div class=\"track-row\"><span data-i18n=\"live\"></span><div id=\"live\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"stable\"></span><div id=\"stable\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"held\"></span><div id=\"held\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"b\"></span><div id=\"b\" class=\"track\"></div></div><div class=\"actions\"><button id=\"crash\" data-i18n=\"crash\"></button></div><div class=\"card\"><header data-i18n=\"recovered\"></header><div id=\"recovered\" class=\"readout\"></div></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"note\"></p></div><div class=\"actions\"><button id=\"reset\" data-i18n=\"reset\"></button></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n.readout{min-height:4.8em}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let binding='A',saved='A',b=0,bStable=0,dead=false;const value=v=>v?'N'+v:t('empty');function draw(){cells('live',[{text:'target → '+binding+' → '+(binding==='A'?'O':value(b)),cls:'on'}]);cells('stable',[{text:'target → '+saved+' → '+(saved==='A'?'O':value(bStable)),cls:'gold'}]);cells('held',[{text:dead?'—':'fd → A → O',cls:dead?'empty':'on'}]);cells('b',[{text:value(b)+' / '+value(bStable)}]);set('recovered',dead?saved+' → '+(saved==='A'?'O':value(bStable)):t('running'));for(const id of ['write','fileSync','rename','dirSync','crash'])$('#'+id).disabled=dead||(id==='rename'&&binding==='B');set('explanation',t('note'));}$('#write').onclick=()=>{b++;draw();};$('#fileSync').onclick=()=>{bStable=b;draw();};$('#rename').onclick=()=>{binding='B';draw();};$('#dirSync').onclick=()=>{saved=binding;draw();};$('#crash').onclick=()=>{dead=true;draw();};$('#reset').onclick=()=>{binding=saved='A';b=bStable=0;dead=false;draw();};draw();","audio":false,"strings":{"b":"B 的当前 / 稳定内容","crash":"在这里掉电","dirSync":"fsync 父目录","empty":"空文件","fileSync":"fsync B","heading":"名字原子替换了,内容也持久了吗?","held":"旧 fd 的引用","intro":"新 inode B 已准备好。自由安排写入、文件同步、rename 和目录同步,再切断电源,比较运行时与恢复后的目标。","live":"运行时:target → inode → 内容","note":"只同步目录而未同步 B,可能得到“新名字指向空内容”。先同步 B 但未同步目录,旧名字绑定仍可能恢复。","recovered":"重启后通过 target 读取","rename":"rename B → target","reset":"重置","running":"尚未掉电","scope":"同目录单文件替换,无 I/O 错误。B 初始为空;故障模型只恢复显式同步的内容和目标绑定,未同步修改全部丢弃。这是允许出现的失败情景,不枚举所有文件系统结果。重启会关闭原 fd;不包含临时名字恢复、权限或跨文件事务。","scopeLabel":"模型范围与约定","stable":"恢复依据:绑定 → 稳定内容","write":"写 B 的下一版本"}}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 | 避免应用先 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。