普通 write(2) 成功,可以让实际接受的字节通过内核文件状态被访问,但本身不能建立崩溃持久性。fsync 请求同步文件数据及相关元数据,并等待存储栈报告完成。它是更新协议中的一个操作,仍需要正确顺序、错误检查、目录项处理和底层真实保证。不能只在代码里出现一次 fsync,就推断整个更新已经具有完整事务与恢复语义。

坏掉的样子

用户态缓冲还形成更早一层边界。语言缓冲区尚未把字节提交给内核时,对描述符调用 fsync 无法同步这些字节。应先 flush 缓冲,并检查两个阶段的错误。普通 write 也可能只完成部分写入,延迟存储错误则可能在同步时才出现,而不是在最初写入时报告,因此每层返回结果都需要按契约处理。

同步新建文件,不一定同步命名它的目录项。在 Linux 上,持久创建或替换协议还需要适当的目录同步。原子 rename 提供运行期间的命名空间可见性保证,却不会独立证明目录变化能够经历崩溃保留。当前打开新路径能读到数据,与重启后仍能通过该名字找到预期对象,是不同问题。

fdatasync 不意味着完全不处理元数据。它同步数据,以及后续正确读取数据所需的元数据,包括变化后的文件长度;对于读取不必需的其他元数据更新,则可以省略。把它理解为与 fsync 在所有方面相同,或者完全无视元数据,都会掩盖选择两者的真正依据,应根据应用需要保持的状态理解同步范围。

快速验证

把脚本保存为 syncfile.py,在装有 Python 和 strace 的一次性 Linux 系统中运行 python3 syncfile.py。临时目录必须支持普通文件和目录同步。实测使用 Linux 6.12.76、Python 3.11.2、strace 6.1 和 ext4 卷。样本检查真实调用与运行结果,不会切断存储电源:

from pathlib import Path
from tempfile import TemporaryDirectory
import subprocess,sys

program=r'''
from pathlib import Path
import errno,os,sys
root=Path(sys.argv[1]);current=root/'current';candidate=root/'candidate'
current.write_bytes(b'old');old=current.open('rb')
try:
    with candidate.open('wb') as handle:
        handle.write(b'new');handle.flush();os.fsync(handle.fileno())
    os.replace(candidate,current)
    directory=os.open(root,os.O_RDONLY|os.O_DIRECTORY)
    try:os.fsync(directory)
    finally:os.close(directory)
    assert current.read_bytes()==b'new' and old.read()==b'old'
    with current.open('ab') as handle:
        handle.write(b'-extended');handle.flush();os.fdatasync(handle.fileno())
    assert current.read_bytes()==b'new-extended' and current.stat().st_size==12
    read_end,write_end=os.pipe()
    try:
        try:os.fsync(write_end)
        except OSError as error:assert error.errno==errno.EINVAL
        else:raise AssertionError('fsync on a pipe unexpectedly succeeded')
    finally:os.close(read_end);os.close(write_end)
finally:old.close()
print('replacement content, old open reference, appended length, and unsupported-pipe error verified')
'''
with TemporaryDirectory(prefix='xbcatm-fsync-') as directory:
    root=Path(directory);trace=root/'trace'
    result=subprocess.run(['strace','-yy','-s','200','-e','trace=%file,write,fsync,fdatasync','-o',str(trace),'--',sys.executable,'-c',program,str(root)],capture_output=True,text=True,timeout=10)
    assert result.returncode==0,result.stderr
    lines=trace.read_text().splitlines()
    file_sync=next(i for i,line in enumerate(lines) if 'fsync(' in line and str(root/'candidate') in line and '= 0' in line)
    rename=next(i for i,line in enumerate(lines) if line.startswith('rename') and str(root/'current') in line and '= 0' in line)
    directory_sync=next(i for i,line in enumerate(lines) if 'fsync(' in line and '<'+str(root)+'>' in line and '= 0' in line)
    assert file_sync<rename<directory_sync
    assert any('fdatasync(' in line and str(root/'current') in line and '= 0' in line for line in lines)
    print(result.stdout,end='')
    for line in lines:
        if line.startswith(('fsync(','fdatasync(','rename')):print(line)
    print('syscall order verified: flush data, fsync candidate, replace name, fsync parent directory')

子进程创建内容为 old 的当前文件,并保留一份打开读取引用。然后把 new 写入独立候选文件,flush Python 缓冲,再对候选描述符调用 fsync。候选文件位于同一个目录,也使此实验的后续 rename 保持在同一文件系统内,不依赖跨挂载复制等不同操作来模拟替换。

接着替换当前路径,并同步包含它的目录描述符。追踪断言顺序为:候选文件 fsync 成功,rename 成功,父目录 fsync 成功。这些是实际观察到、且检查了返回值的系统调用,不是仅凭注释声明已经遵守同步顺序。程序意图和真实执行之间的对应关系,可以从保留的追踪中核对。

读取当前路径得到 new,先前打开的引用却仍读到 old。这区分原子名字替换与修改全部既有引用:旧引用继续指向原来文件对象,不会因为同名路径换成新文件就自动转移。需要读取新版本的消费者,仍应根据应用发布协议选择对象,不能假定手里已有的句柄会始终代表路径最新状态。

之后子进程追加 extended 后缀,flush 缓冲,再执行 fdatasync。最终内容为 new-extended,长度十二,实际覆盖了文件长度改变后的成功数据同步。这与后续读取所需元数据不能被简单忽略的契约一致,而不是只在内容长度不变的最简单情况里验证调用能够返回。

最后对管道调用 fsync,得到 EINVAL。脚本检查真实失败,没有把任意描述符都当作可同步的普通文件;清理时关闭全部句柄。追踪证明本地内核接受了相应文件与目录请求,却不能独立证明某台物理设备在断电时正确兑现刷新。接口行为与底层故障保证,需要按证据范围分别描述。

定义可以确认提交的边界

应明确哪次更新必须保留,以及读取者允许观察什么。单文件替换中,发布前同步候选,发布后同步受影响目录;跨目录移动还需要考虑两边目录更新。跨文件系统移动采用不同机制,不能假定方便的 rename 协议可以原样适用。更新范围决定哪些对象和命名关系需要被纳入提交边界。

同步失败时,不应报告持久更新已提交。之前写入或 rename 可能已经生效,因此失败不是自动回滚。应保留足够的事务或版本信息,判断实际状态,并按应用契约恢复。fsync 本身不会把多个文件的一系列变化变成单个原子事务,也不会替代应用对于中间状态的处理设计。

应用契约允许时,可以让多项操作共享提交边界,以降低同步成本,但确认应在所需边界成功后发出。还需要在真实存储栈验证崩溃恢复,包括文件系统和设备缓存行为。虚拟化实验中成功的系统调用追踪,可以证明接口执行,却不能替所有下层组件证明各自的故障保证。

延伸阅读

来源

  • fsync(2),文件目录同步、fdatasync 元数据与错误,已核验 2026-09-12。
  • rename(2),原子路径替换与既有打开引用,已核验 2026-09-12。
  • write(2),缓冲写入完成、部分写入与延迟错误,已核验 2026-09-12。