inotify 通过一个带注册监视项和可读事件队列的实例,投递 Linux 文件系统通知。每条记录包含监视编号、事件掩码,也可能包含名字和移动 cookie。这能减少对未变化路径的反复检查,但事件流不是无限审计日志。正确消费者应把通知理解为相关状态可能需要重新检查的证据,而不能默认它保存了文件发生过的每一次操作。
坏掉的样子
监视目录不会递归监视后代。它报告直接目录项上的相关活动,因此需要观察更深变化时,新子目录也需要独立监视。目录树监视器必须处理初次枚举和之后加入的目录。假定只注册根目录就覆盖整棵树,会悄悄漏掉更深层变化;能收到子目录被创建,并不代表能收到它内部之后的全部操作。
文件监视跟随具体对象,不会永久绑定路径。编辑器可以先写完并关闭临时文件,再用 rename 替换目标。父目录会报告替换,旧文件监视却不会转移到新 inode。如果消费者等待目标名字之后再出现 close-write,可能永远等不到,因为替代文件在发布前已经关闭。不能把一种编辑器保存顺序当成所有更新方式的固定流程。
连续产生且尚未读取的相同事件可以合并,队列满后会丢弃事件并报告 IN_Q_OVERFLOW。因此,事件数量不是可靠写入计数。溢出后,消费者无法仅靠剩余记录重建全部缺失操作,必须重新核对当前监视状态,不能继续假定根据事件维护的模型完整。历史丢失与当前状态仍可读取,是需要分别理解的两件事。
快速验证
把脚本保存为 inotify-events.py,在一次性 Linux 环境中运行 python3 inotify-events.py。此前记录使用 Linux 6.12.76 和 ext4;本轮在 Linux 6.18.44、Python 3.12.14 环境重跑了两种语言的样本。样本创建 ASCII 名称的临时条目,并有意填满自己的队列。配置的队列上限必须不超过三万二千七百六十八;脚本只读取上限,不修改任何 sysctl:
from pathlib import Path
from tempfile import TemporaryDirectory
import ctypes,os,struct
MODIFY=0x2;ATTRIB=0x4;CLOSE_WRITE=0x8;MOVED_FROM=0x40;MOVED_TO=0x80
CREATE=0x100;DELETE=0x200;DELETE_SELF=0x400;MOVE_SELF=0x800;OVERFLOW=0x4000;IGNORED=0x8000
libc=ctypes.CDLL(None,use_errno=True)
libc.inotify_init1.argtypes=[ctypes.c_int];libc.inotify_init1.restype=ctypes.c_int
libc.inotify_add_watch.argtypes=[ctypes.c_int,ctypes.c_char_p,ctypes.c_uint32];libc.inotify_add_watch.restype=ctypes.c_int
header=struct.Struct('iIII')
with TemporaryDirectory(prefix='xbcatm-inotify-') as directory:
root=Path(directory);fd=libc.inotify_init1(os.O_NONBLOCK|os.O_CLOEXEC);assert fd>=0,os.strerror(ctypes.get_errno())
def watch(path,mask):
wd=libc.inotify_add_watch(fd,os.fsencode(path),mask);assert wd>=0,os.strerror(ctypes.get_errno());return wd
def drain():
events=[]
while True:
try:data=os.read(fd,65536)
except BlockingIOError:return events
offset=0
while offset<len(data):
wd,mask,cookie,length=header.unpack_from(data,offset);offset+=header.size
name=data[offset:offset+length].split(b'\0',1)[0].decode();offset+=length
events.append((wd,mask,cookie,name))
try:
parent=watch(root,MODIFY|CLOSE_WRITE|CREATE|DELETE|MOVED_FROM|MOVED_TO)
sub=root/'sub';sub.mkdir();drain();(sub/'unwatched').touch();assert drain()==[]
nested=watch(sub,CREATE);(sub/'watched').touch();assert any(wd==nested and mask&CREATE and name=='watched' for wd,mask,_,name in drain())
print('a directory watch is not recursive; adding a child-directory watch observes its new entries')
counter=root/'counter';stream=os.open(counter,os.O_CREAT|os.O_WRONLY,0o600);drain()
try:
for _ in range(3):os.write(stream,b'x')
finally:os.close(stream)
events=drain();assert sum(wd==parent and bool(mask&MODIFY) and name=='counter' for wd,mask,_,name in events)==1
moved=root/'moved';counter.rename(moved);events=drain()
before=next(e for e in events if e[1]&MOVED_FROM and e[3]=='counter');after=next(e for e in events if e[1]&MOVED_TO and e[3]=='moved')
assert before[2]==after[2]!=0
print('three unread identical modifications coalesce; rename FROM/TO events share a nonzero cookie')
target=root/'target';target.write_bytes(b'old');file_watch=watch(target,MODIFY|ATTRIB|DELETE_SELF|MOVE_SELF);drain()
replacement=root/'replacement';replacement.write_bytes(b'new');drain();replacement.replace(target);events=drain()
assert any(wd==file_watch and mask&IGNORED for wd,mask,_,_ in events)
assert any(wd==parent and mask&MOVED_TO and name=='target' for wd,mask,_,name in events)
assert not any(wd==parent and mask&CLOSE_WRITE and name=='target' for wd,mask,_,name in events)
target.write_bytes(b'newer');events=drain()
assert any(wd==parent and mask&MODIFY and name=='target' for wd,mask,_,name in events)
assert all(wd!=file_watch for wd,_,_,_ in events)
print('replacement invalidates the old file watch; the parent-directory watch continues to observe the pathname')
burst=root/'burst';burst.mkdir();drain();watch(burst,CREATE)
limit=int(Path('/proc/sys/fs/inotify/max_queued_events').read_text());assert 0<limit<=32768,'use a disposable environment with a bounded queue limit'
for number in range(limit+8):(burst/str(number)).touch()
events=drain();assert any(wd==-1 and mask&OVERFLOW for wd,mask,_,_ in events)
current=len(list(burst.iterdir()));assert current==limit+8
print('actual queue overflow:',len(events),'events retained for',current,'creates; rescan sees the current entries')
finally:os.close(fd)
第一组监视父目录,创建子目录,并读走父级事件。随后在子目录内部创建文件,不会向父监视项产生事件;显式增加子目录监视后,下一次创建才被看到。这在同一个 inotify 实例中区分直接目录项覆盖和递归监视,避免只检查注册调用成功就以为整棵树都已进入观察范围。
对同一文件连续写三次,期间不读取队列,最终只观察到一条对应 modify 事件。随后重命名该文件,得到 cookie 相同且非零的 FROM 与 TO 记录。前者证明合并,而非每次写入都有独立通知;后者帮助关联这两条移动记录,却不是文件整个后续生命周期的持久身份,不能把它当作不会变化的对象编号。
接着监视一个文件,再用已经写完并关闭的文件替换其路径。旧对象被删除时,原监视项收到 ignored;父目录监视收到目标名字的 MOVED_TO,这次发布没有伴随目标 close-write。之后写入替代文件,父目录监视能看到,已经移除的旧文件监视不能。这正好验证路径替换时两种监视范围的不同结果。
溢出实验在队列上限一万六千三百八十四时,创建一万六千三百九十二个不同条目。得到一万六千三百八十五条记录,其中包含监视编号为负一的溢出通知;重新扫描目录,则找到全部当前条目。这同时展示缺失的事件历史,以及恢复当前状态知识的实际方法,不能把两者混称为完整历史恢复。
全部监视都属于一个由样本拥有的实例,清理临时目录树前会先关闭它。实验没有制造系统级监视项短缺,也没有修改其他应用队列。有界突发验证了真实溢出行为,不需要让应用长期过载,更没有把从未观察到的丢失信号当成已经验证。环境前提不满足时会明确失败。
协调路径和对象寿命
两侧移动都可见时,可以按 cookie 配对,但要处理移入、移出观察集合,以及中间穿插其他记录的情况。行动前还应检查相关路径。事件里的名字描述被报告的变化,不保证消费者稍后打开时,那里仍然是同一对象。通知与读取之间的变化窗口,需要由应用当前状态校验来处理。
监视目录树时,应在建立监视与核对目录内容之间考虑枚举期间发生的活动。溢出后,要从文件系统重建受影响状态,并恢复必要的监视覆盖。溢出记录不会指定一个缺失文件名,因此不能按普通逐监视项事件处理它,也不能仅重读某个碰巧出现在队列末尾的路径就声称完整恢复。
适合由通知触发的工作,应能容忍重复或合并变化,例如校验当前内容后重新加载整份配置。如果每一次状态转换都必须保留,则需要应用日志或具有相应历史契约的接口。inotify 的价值建立在它的覆盖和丢失规则与需求相匹配,而不是把事件队列想象成不会漏项的永久记录。
来源
- inotify(7),事件格式、合并、溢出、重命名与非递归监视,已核验 2026-10-15。
- inotify_add_watch(2),监视注册与对象身份,已核验 2026-10-15。
- Linux 6.12 inotify UAPI,可执行样本使用的事件结构和标志值,已核验 2026-10-15。