watchfiles 是 Rust 写的文件监视库,给 Python 提供「目录里一有变化就通知我」的能力。热重载、增量构建、配置文件自动生效这些功能,底层都是它这一类库在干活。

它对外只有一个 watch() 函数和一个 awatch() 异步版本,接口极简。这篇量的是最实际的一个指标:手动改一个文件,多久能收到通知。

最小可用写法

from watchfiles import watch

for changes in watch("./src"):
    for action, path in changes:
        print(action.name, path)

watch() 是个生成器,每次循环拿到的是一批变化。默认 step=50,意思是收集 50 毫秒内的变化打包成一批,再交给你。

五种动作的延迟与事件数

空目录,step=50,每个动作只做一次,等 600 毫秒再看收到了什么:

动作事件数事件类型首个延迟
新建文件1added97 毫秒
改内容1modified102 毫秒
改名2deleted + added57 毫秒
新建目录2added × 260 毫秒
删除文件2modified + deleted468 毫秒

两个反直觉的地方。一是改名在文件系统层面就是「删掉旧的、加个新的」,所以收到两个事件,写重命名逻辑时不能只处理 added。二是删除文件报了一个 468 毫秒的延迟,比其他动作高一个量级,单次采样可能有抖动,但事件数 2 个(modified 加 deleted)是稳定的。

延迟下限由 step 决定

同一个动作重复 10 次,每次新建一个文件再删掉:

10 次新建:中位 75 ms / 最快 62 ms / 最慢 99 ms

分布集中在 60 到 100 毫秒之间。这个区间的下限就是 step=50:库要等 50 毫秒的防抖窗口关闭才把批次交出来,再加上系统通知本身的延迟。

所以调 step 就是在延迟和事件合并之间取舍:调到 20 毫秒反应更快但事件更碎,调到 500 毫秒一批能收齐更多变化,代价是半秒的滞后。

一次写入可能产生多个事件

打开文件 → 写 line1 → 等 250 ms → 写 line2 → 关闭
收到的事件:2 个(两次 modified)

间隔 250 毫秒超过防抖窗口,所以分成两批。很多编辑器保存时会先写临时文件再改名,实际产生的事件数是 3 个以上。做自动重启服务这类动作时不能「收到事件就重启」,得再加一层自己的延迟合并,否则一次保存会触发多次重启。

路径不存在直接报错

>>> list(watch(r"D:\no\such\dir"))
FileNotFoundError: Input watch path is neither a file nor a directory.

它不会帮你建目录,也不会静默返回空。监听配置里那些可能还没生成的路径(日志目录、构建产物目录),要么先检查存在,要么在异常里等目录出现再重试。

另外要注意它是阻塞的。放在主线程里 for changes in watch(...) 会一直占着,正常写法是丢到一个后台线程,或者用异步版本 awatch 配 async for。

什么时候不该用它

只是想知道某个配置改了没。每次读一遍文件、比一下哈希,几十微秒就够,不需要常驻一个监视器。

监视网络盘或者容器挂载的目录。系统级文件通知在这些路径上经常失灵,这是文件系统通知机制的限制,不是库的问题。用轮询更可靠。

要精确的事件顺序和完整历史。防抖会把一批变化合并,同一文件的多次修改可能只留最后一条,做审计日志不够用。

没测的部分列一下,3 项:监视上万文件目录的 CPU 占用 / step 参数在不同负载下的最优值 / Linux 上 inotify 的延迟对比。