A file lock coordinates participants that agree on what to lock, when to acquire it, and which operations belong inside the critical section. Linux offers several lock interfaces with different ownership and release rules. Choosing a filename and calling something named lock is insufficient: the protocol must identify the underlying object, the lock family, and the lifetime of every reference involved.
When it breaks
On the local Linux filesystem used here, flock provides advisory exclusion. Another process that requests an incompatible lock waits or fails, while an ordinary write that never requests the lock can proceed. The lock therefore does not grant content privacy or automatically constrain every program capable of opening the file.
Locking a data file becomes subtle when updates replace its pathname with another inode. The existing lock remains associated with the old open file description. A later opener reaches the replacement and can acquire a different lock. Both writers may believe they have exclusive access to the same logical document while actually locking different objects.
Traditional POSIX record locks have another surprising lifetime rule: they belong to the process, and closing any descriptor in that process referring to the file releases its record locks on that file. A library that independently opens and closes the same inode can therefore invalidate a lock held elsewhere in the process. This differs from flock ownership through an open file description.
Check it quickly
Save this as locking.py and run python3 locking.py in a disposable Linux environment. The recorded run used Linux 6.12.76, Python 3.11.2, and a local tmpfs. Each competing lock request runs in a separate process and uses nonblocking acquisition, so an expected conflict produces a result instead of hanging:
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)
First, the parent holds an exclusive flock, and a cooperating child reports blocked. Another child writes without requesting any lock, and the parent reads the changed content. These two outcomes establish both the useful exclusion and its advisory boundary using the same file and permissions.
The parent then duplicates its descriptor and closes the original wrapper. A contender remains blocked because the duplicate still references the locked open file description. Closing that final duplicate releases the lock, and the next contender succeeds. Counting wrapper objects or relying on the first close would describe the lifetime incorrectly.
Next, the parent locks the data file and atomically replaces its pathname with a newly created file. The descriptor’s inode differs from the inode now found by pathname. A child opens that pathname and acquires its own lock. Atomic replacement keeps the name available, but it does not move the old lock onto the replacement inode.
The following case uses a separate stable.lock file. Writers agree to lock that stable object before replacing the data pathname. The contender remains blocked across replacement and succeeds after release. This works only while participants keep the lock file’s identity stable; deleting and recreating that file can reproduce the original split-object failure.
Finally, lockf exercises traditional POSIX record locking. Locking byte zero blocks a child requesting that byte but allows a child requesting byte one. Closing the parent’s second, independently opened descriptor releases the byte-zero lock, even though the descriptor used to acquire it remains open. The successful final probe makes this release rule observable.
Make the protocol explicit
Use the same lock family and object across all participants. On local Linux filesystems, traditional record locks and flock are normally separate mechanisms, so mixing them does not establish mutual exclusion. NFS and SMB can have different emulation and interaction rules; verify the deployed filesystem and mount behavior before applying local results there.
Choose a range that covers the entire invariant being updated. Separate record ranges only permit useful concurrency when operations really are independent; a shared header or index can require broader coordination. Keep unrelated slow work outside the critical section, and use a consistent acquisition order when an operation needs multiple locks.
Define cleanup for normal return, exceptions, and descriptor transfer. Process termination releases kernel-held locks according to their ownership rules, but duplicated or inherited references can extend some lifetimes. Locks also do not make writes durable or repair partially completed updates. Pair exclusion with an update and recovery protocol appropriate to the file’s data format.
Sources
- flock(2), advisory locks, open file descriptions, and remote-filesystem caveats, checked 2026-09-12.
- fcntl_locking(2), POSIX record ranges and process-owned lock release rules, checked 2026-09-12.
- rename(2), pathname replacement and the persistence of open references, checked 2026-09-12.