文章标题旁是三个相连面板,英文标签依次为 Observe、Decide 和 Act。

文件锁保护共同协议中的特定对象

文件锁协调的是一组参与者:大家约定锁哪个对象、何时获取锁,以及哪些操作必须放在临界区内。Linux 提供多种锁接口,它们的所有权和释放规则并不相同。选一个文件名,再调用名称里带 lock 的函数,并不足以构成正确协议;还必须明确底层对象、锁的种类,以及相关每份引用的寿命,否则看起来都成功加锁的程序仍可能同时修改同一份逻辑数据。 坏掉的样子 在本实验使用的 Linux 本地文件系统上,flock 提供建议式互斥。另一个请求冲突锁的进程会等待或失败,但完全没有请求锁的普通写入仍然可以进行。因此,文件锁不会赋予内容隐私,也不会自动限制所有能够打开文件的程序。只有参与者实际遵循共同协议,互斥才能保护需要保持一致的数据操作。 如果更新通过新 inode 替换原路径,直接锁数据文件就会出现对象分离。已有锁仍关联旧的打开文件描述,后续打开者却访问替换后的文件,并可能成功获取另一把锁。两个写者都以为自己独占同一份逻辑文档,实际锁住的却是不同对象。路径名称稳定,并不意味着路径背后的文件身份稳定。 传统 POSIX 记录锁还具有容易忽略的寿命规则:锁属于进程,该进程关闭任何一个指向该文件的描述符,都会释放它在该文件上持有的记录锁。因此,某个库独立打开并关闭同一 inode,也可能使程序其他位置持有的锁失效。这与通过打开文件描述拥有锁的 flock 不同,不能把两套释放规则混用。还要注意,通过任意复制引用显式解锁,也会释放同一把 flock;其他复制描述符仍然打开,并不能抵消显式解锁。 快速验证 把脚本保存为 locking.py,在一次性 Linux 环境中运行 python3 locking.py。实测环境为 Linux 6.12.76、Python 3.11.2 和本地 tmpfs。每个竞争锁的请求都在独立子进程中执行,并使用非阻塞获取,因此预期冲突会产生可检查结果,不会让演示一直挂起: from pathlib import Path from tempfile import TemporaryDirectory import fcntl,os,subprocess,sys probe_code=r''' import fcntl,sys with open(sys.argv[1],'r+') as handle: try: if sys.argv[2]=='flock':fcntl.flock(handle,fcntl.LOCK_EX|fcntl.LOCK_NB) else:fcntl.lockf(handle,fcntl.LOCK_EX|fcntl.LOCK_NB,1,int(sys.argv[3]),0) except BlockingIOError:print('blocked') else:print('acquired') ''' def probe(path,kind='flock',start=0): result=subprocess.run([sys.executable,'-c',probe_code,str(path),kind,str(start)],capture_output=True,text=True,timeout=5) assert result.returncode==0,result.stderr return result.stdout.strip() with TemporaryDirectory(prefix='xbcatm-locks-') as directory: root=Path(directory);target=root/'data';target.write_text('original') handle=target.open('r+');fcntl.flock(handle,fcntl.LOCK_EX) assert probe(target)=='blocked' subprocess.run([sys.executable,'-c',"from pathlib import Path;import sys;Path(sys.argv[1]).write_text('uncooperative')",str(target)],capture_output=True,text=True,check=True) handle.seek(0);assert handle.read()=='uncooperative' duplicate=os.dup(handle.fileno());handle.close() try:assert probe(target)=='blocked' finally:os.close(duplicate) assert probe(target)=='acquired' print('flock blocks cooperating contenders but not ordinary writes; dup retains the same lock') with target.open('r+') as handle: fcntl.flock(handle,fcntl.LOCK_EX) replacement=root/'replacement';replacement.write_text('new inode');replacement.replace(target) assert os.fstat(handle.fileno()).st_ino!=target.stat().st_ino assert probe(target)=='acquired' lockfile=root/'stable.lock';lockfile.touch() with lockfile.open('r+') as handle: fcntl.flock(handle,fcntl.LOCK_EX) replacement.write_text('next inode');replacement.replace(target) assert probe(lockfile)=='blocked' assert probe(lockfile)=='acquired' print('replacing locked data changes the inode; a separate stable lock file protects that replacement protocol') first=os.open(target,os.O_RDWR);second=os.open(target,os.O_RDWR) try: fcntl.lockf(first,fcntl.LOCK_EX|fcntl.LOCK_NB,1,0,0) assert probe(target,'record',0)=='blocked' and probe(target,'record',1)=='acquired' os.close(second);second=None assert probe(target,'record',0)=='acquired' print('POSIX record locks permit disjoint bytes; closing another descriptor to the inode releases process-owned locks') finally: if second is not None:os.close(second) os.close(first) 首先,父进程持有排他 flock,遵守协议的子进程报告 blocked。另一个子进程完全不请求锁就写入文件,父进程随后读到修改后的内容。同一个文件、同一组权限下的两个结果,分别证明互斥确实存在,以及它的建议式边界。不能只展示一个竞争者失败,就推断所有普通读写也会被锁阻止。 ...

十二月 5, 2026 · 1 分钟 · XB Field Notes
三个相连方块,依次标有 Observe、Decide 和 Act。

让持久状态和临时空间拥有不同生命周期

持久应用状态和可丢弃临时文件,需要不同的保留、容量与恢复策略。分开路径有助于表达这些策略,但目录本身不会创造持久性,也不会隔离磁盘容量。真正决定进程重启、容器替换、宿主故障或清理后保留什么的,是路径背后的存储。可靠布局需要把每类数据连接到明确生命周期,而不是仅仅给文件夹起两个不同名字。 坏掉的样子 如果清理任务把混合目录全部当作可丢弃内容,可能在删除临时下载时一起删除已提交记录。相反,也可能备份了大量可重建缓存,却漏掉真正重要的小数据库。文件扩展名和年龄不能代替对应用所有权的理解;需要知道路径由谁管理,以及其内容是否真的能从权威来源重新构造,才能决定能否删除。 容器重启和替换也不同。停止再启动同一个容器,通常保留其可写文件系统层;删除容器才会丢弃该层,而明确保留的命名卷可以继续存在。tmpfs 又有不同生命周期,容器停止时其内容会消失。把这三类位置统称为临时,会掩盖影响恢复流程的区别,导致测试重启成功后就误以为替换也安全。 同一个文件系统里的两个目录,仍然竞争该文件系统的剩余空间。只把临时路径从 /data/tmp 改成 /scratch,却不改变存储策略,并不会自动保护数据库写入。容量隔离需要合适的挂载、配额或其他可执行预算,同时监控应区分临时空间增长与持久状态增长。路径整齐不能替代真正的资源边界。 快速验证 先取得固定镜像,再用宿主 Python 和 Docker 运行脚本。实测为 Docker 29.3.1、Linux arm64,镜像内 Python 3.11.16 提供 SQLite 模块。它创建一个私有命名卷和八 MiB 的 tmpfs 临时挂载,只删除自身容器与卷。小规模临时空间耗尽被限制在实验内部,不修改既有服务存储: import subprocess,uuid BASE='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' def docker(*args,check=True): result=subprocess.run(['docker',*args],text=True,capture_output=True,timeout=120) if check:assert result.returncode==0,result.stderr return result def execute(container,code):return docker('exec',container,'python','-c',code).stdout.strip() name='xbcatm-state-'+uuid.uuid4().hex[:10];volume=name+'-data';containers=[name+'-one',name+'-replacement'] try: docker('volume','create',volume) def start(container): docker('run','-d','--name',container,'--network','none','--mount','type=volume,src='+volume+',dst=/state','--tmpfs','/scratch:rw,size=8m',BASE,'python','-c','import time;time.sleep(180)') start(containers[0]) execute(containers[0],'from pathlib import Path;import sqlite3;c=sqlite3.connect("/state/data.sqlite");c.execute("create table records(value text)");c.execute("insert into records values (?)",("committed",));c.commit();c.close();Path("/ephemeral").write_text("container-layer");Path("/scratch/crumb").write_text("scratch")') docker('stop','--time','1',containers[0]);docker('start',containers[0]) execute(containers[0],'from pathlib import Path;import sqlite3;assert Path("/ephemeral").exists();assert not Path("/scratch/crumb").exists();c=sqlite3.connect("/state/data.sqlite");assert c.execute("select value from records").fetchall()==[("committed",)];c.close()') print('stop/start retains the container writable layer and volume data, while the tmpfs scratch mount loses its file') docker('rm','-f',containers[0]);start(containers[1]) execute(containers[1],'from pathlib import Path;import sqlite3;assert not Path("/ephemeral").exists();c=sqlite3.connect("/state/data.sqlite");assert c.execute("select value from records").fetchall()==[("committed",)];c.close()') print('replacement loses the old writable layer but retains the named-volume database') execute(containers[1],'''import errno,os,sqlite3 fd=os.open('/scratch/fill',os.O_CREAT|os.O_WRONLY,0o600) try: try: while True:os.write(fd,b'x'*1048576) except OSError as error:assert error.errno==errno.ENOSPC finally:os.close(fd) source=sqlite3.connect('/state/data.sqlite');source.execute('insert into records values (?)',('after-scratch-full',));source.commit() backup=sqlite3.connect('/state/check-copy.sqlite');source.backup(backup);backup.close();source.close() restored=sqlite3.connect('/state/check-copy.sqlite');assert restored.execute('select value from records order by rowid').fetchall()==[('committed',),('after-scratch-full',)];restored.close() ''') print('bounded scratch reaches ENOSPC while state still commits; a SQLite backup copy reopens with both committed records') finally: docker('rm','-f',*containers,check=False);docker('volume','rm',volume,check=False) 第一个容器在 /state 下创建 SQLite 数据库并提交一条记录,在普通可写层写入 /ephemeral,再把临时文件放进 /scratch。三个路径都通过普通文件接口访问,背后却是不同存储位置。持久应用记录通过 SQLite commit 提交,不依赖尚未关闭的输出缓冲,从而让后续生命周期对照具有明确数据边界。 ...

十二月 3, 2026 · 1 分钟 · XB Field Notes
文章标题旁是三个相连面板,英文标签依次为 Observe、Decide 和 Act。

fsync 在更新协议中完成存储同步请求

普通 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 保持在同一文件系统内,不依赖跨挂载复制等不同操作来模拟替换。 ...

十一月 27, 2026 · 2 分钟 · XB Field Notes
关于写回、可见性与刷写的标题卡,配有 OBSERVE、DECIDE、ACT(观察、判断、行动)面板。

写回把缓冲可见性与存储完成分开

缓冲文件 I/O 可以在不同时间经过语言缓冲区、内核页缓存和存储栈。被修改的缓存页需要写回,使数据进入后端文件系统与设备。分开这些阶段可以合并工作、提高吞吐,但也意味着另一读取者看到新字节,本身不能证明已经完成可经历崩溃的存储边界。理解性能和持久性时,需要先确定观察的是哪一层完成。 坏掉的样子 缓冲写入不保证瞬间返回。分配、内存压力、脏页限制或文件系统活动,都可能让写者等待。如果生产速度持续超过存储处理能力,脏数据不能无限增长,压力最终会反映到工作负载。把每次 write 都描述为单纯内存复制、所有 I/O 都在之后发生,会错误解释持续负载下的延迟尖峰。 语言层 flush 与文件系统同步含义不同。flush 把应用缓冲数据提交到下一层,fsync 则请求文件同步边界。普通关闭文件,或者从另一个描述符读到相同字节,不能替代这一显式契约。因此只测成功写入的基准,可能测到的是缓存接受速度,而不是稳定、持久的存储吞吐,需要与实际工作要求对应。 写回设置也不是逐文件持久性截止时间。后台阈值促使刷新线程工作,更高的脏页阈值影响生成写入的进程,周期参数则控制唤醒或较老脏数据何时符合处理条件。设备进展、文件系统行为和竞争负载仍会影响结果。不能仅设一个时间间隔,就证明每次已确认更新在这么多秒后必定安全。 快速验证 把脚本保存为 writeback.py,在装有 Python 和 strace 的 Linux 环境中运行 python3 writeback.py,并让临时目录位于 ext4。实测使用 Linux 6.12.76、Python 3.11.2 和 strace 6.1。样本写入约四 MiB、读取记账信息,不改变全局脏内存参数或缓存控制: from pathlib import Path from tempfile import TemporaryDirectory import subprocess,sys program=r''' from pathlib import Path import json,os,subprocess,sys root=Path(sys.argv[1]);path=root/'data' def io():return {key:int(value) for key,value in (line.split(':',1) for line in Path('/proc/self/io').read_text().splitlines())} def dirty():return int(next(line.split()[1] for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith('Dirty:'))) with path.open('wb',buffering=65536) as handle: handle.write(b'buffered') assert path.stat().st_size==0 and path.read_bytes()==b'' before=io();handle.flush();after=io() assert after['wchar']-before['wchar']>=8 reader='from pathlib import Path;import sys;assert Path(sys.argv[1]).read_bytes()==b"buffered"' subprocess.run([sys.executable,'-c',reader,str(path)],check=True) print('visible before explicit fsync',flush=True) before=io();chunk=b'x'*(4*1024*1024);handle.write(chunk);handle.flush();after=io() assert after['wchar']-before['wchar']>=len(chunk) assert after['write_bytes']-before['write_bytes']>=len(chunk) dirty_before=dirty();os.fsync(handle.fileno());dirty_after=dirty() assert path.stat().st_size==len(chunk)+8 print(json.dumps({'wchar_delta':after['wchar']-before['wchar'],'write_bytes_delta':after['write_bytes']-before['write_bytes'],'system_dirty_kib_before':dirty_before,'system_dirty_kib_after':dirty_after})) ''' with TemporaryDirectory(prefix='xbcatm-writeback-') as directory: root=Path(directory);trace=root/'trace' result=subprocess.run(['strace','-f','-yy','-s','200','-e','trace=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() visible=next(i for i,line in enumerate(lines) if 'visible before explicit fsync' in line) syncs=[i for i,line in enumerate(lines) if 'fsync(' in line and str(root/'data') in line] assert len(syncs)==1 and visible<syncs[0] and '= 0' in lines[syncs[0]] print(result.stdout,end='');print(lines[syncs[0]]) print('another process read flushed bytes before the only explicit file synchronization; global Dirty is observational, not an isolated-device counter') 子进程用六万五千五百三十六字节的 Python 缓冲打开文件,并写入八字节。flush 前,路径报告长度零,独立打开也读不到内容,因为数据仍在应用缓冲里。这个对照直接建立最早的边界,没有假定每次语言级 write 都已经发出内核写入。应用接口返回与系统调用发生,需要分别确认。 ...

十一月 26, 2026 · 2 分钟 · XB Field Notes