文件锁协调的是一组参与者:大家约定锁哪个对象、何时获取锁,以及哪些操作必须放在临界区内。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。另一个子进程完全不请求锁就写入文件,父进程随后读到修改后的内容。同一个文件、同一组权限下的两个结果,分别证明互斥确实存在,以及它的建议式边界。不能只展示一个竞争者失败,就推断所有普通读写也会被锁阻止。
父进程接着复制描述符,并关闭最初的包装对象。竞争者仍被阻止,因为复制引用继续指向带锁的打开文件描述。直到最后这份复制引用关闭,下一次竞争才成功。只统计包装对象,或者认为第一次 close 一定释放锁,都会误判真实寿命;资源管理必须追踪仍然存在的打开引用。
随后,父进程锁住数据文件,再用新文件原子替换其路径。描述符报告的 inode,与此时按路径查到的 inode 不同。子进程打开该路径,并成功获取自己的锁。原子替换保证名字在替换过程中可用,却不会把旧打开描述上的锁搬到新 inode;这正是“同名所以同锁”推理失效的地方。
下一组对照使用独立的 stable.lock。所有写者约定,先锁住这个稳定对象,再替换数据路径。竞争者在替换后仍被阻止,释放后才成功。此协议成立的前提是参与者保持锁文件身份稳定;如果运行中删除再创建锁文件,就可能重现原来不同进程锁住不同对象的问题,不能把独立文件名本身当作保证。
最后通过 lockf 验证传统 POSIX 记录锁。锁住第零个字节,会阻止请求相同字节的子进程,却允许另一个子进程锁第一个字节。父进程关闭第二份独立打开的描述符后,第零字节的锁被释放,最初用于加锁的描述符此时仍然打开。最终竞争成功,直接展示了进程级记录锁的关闭语义。
明确所有参与者遵守的协议
各参与者必须使用一致的锁种类和对象。在 Linux 本地文件系统上,传统记录锁与 flock 通常是相互独立的机制,混用不能建立互斥。NFS、SMB 可能采用不同模拟方式及交互规则;把本地结果用于远程存储前,应核对实际文件系统和挂载行为,不能从函数名字相同就推断所有语义相同。
锁范围要覆盖整条需要保持的约束。只有操作真正独立时,不同记录区间才允许安全并行;共享头部或索引可能要求更广协调。与关键数据无关的慢操作应放在临界区外,需要同时取得多把锁时使用一致顺序,避免彼此等待成环。范围越小并不自动越正确,关键在于完整覆盖更新关系。
正常返回、异常和描述符转移都要定义清理规则。进程终止会按所有权规则释放内核锁,但复制或继承的引用可能延长某些锁的寿命。锁也不会让写入自动持久化,更不会修复已完成一半的更新。因此互斥还应与文件格式所需的更新、恢复协议配合,分别验证并发一致性和失败后的数据状态。
来源
- flock(2),建议锁、打开文件描述与远程文件系统差异,已核验 2026-09-12。
- fcntl_locking(2),POSIX 记录锁范围与进程所有权释放规则,已核验 2026-09-12。
- rename(2),路径替换与既有打开引用的存续,已核验 2026-09-12。