如何监听文件变化?

解读

在国内后端/基础架构岗位的面试中,**“监听文件变化”**并不是考察你是否会调用某个 API,而是考察三点:

  1. 是否知道 Linux 内核提供的 inotify 机制(Windows 对应 ReadDirectoryChangesW,macOS 有 FSEvents);
  2. 能否在 不引入 GC 语言 的前提下,用 Rust 封装出线程安全、零拷贝、无阻塞的事件流;
  3. 是否理解 事件丢失、缓存溢出、inode 复用 等真实工程陷阱,并给出兜底策略。
    面试官通常以“如果配置中心需要热加载,如何做?”为引子,期望你给出可落地、可维护、可测试的 Rust 方案,而非简单跑通 demo。

知识点

  1. inotify 系统调用:inotify_init1、inotify_add_watch、inotify_rm_watch,事件掩码 IN_MODIFY | IN_MOVED_TO | IN_CREATE | IN_DELETE_SELF。
  2. Rust 封装层
    • 底层:libc crate 直接调用 syscall,避免 std::process::Command 额外 fork
    • 高层:notify crate(5.x 版本)提供 EventFn 流式回调,内部用 mpsc 通道解耦,支持 debounce 防抖
  3. 异步集成:tokio 生态有 tokio-inotify(底层 mio 封装),可将 inotify fd 注册到 tokio::io::unix::AsyncFd,实现 非阻塞 + 零线程 监听。
  4. 内存与并发
    • 事件缓冲池用 Vec<u8> with_capacity(4096),避免 read 时频繁 realloc。
    • 多线程场景下,Arc<Mutex<HashMap<WatchToken, PathBuf>>> 维护 wd→path 映射,防止 rename 后路径失效
  5. 可靠性兜底
    • 队列溢出(IN_Q_OVERFLOW)时,回退到全量扫描并重新注册 watch;
    • 软链接、nfs、docker overlayfs 等不支持 inotify 的文件系统,降级为轮询(notify::PollWatcher),并暴露 feature = "poll" 开关。

答案

给出一个可在 Linux 服务器上线 的最小生产级实现,编译通过即线程安全、无阻塞、可异步

use std::path::Path;
use tokio::sync::mpsc;
use notify::{Event, RecommendedWatcher, Watcher, Config};

/// 返回异步事件流,主线程仅做业务逻辑
pub fn async_watch<P: AsRef<Path>>(path: P) -> anyhow::Result<mpsc::UnboundedReceiver<Event>> {
    let (tx, rx) = mpsc::unbounded_channel();
    let mut watcher = RecommendedWatcher::new(
        move |res: Result<Event, notify::Error>| {
            if let Ok(evt) = res {
                let _ = tx.send(evt);
            }
        },
        Config::default()
            .with_poll_interval(std::time::Duration::from_secs(2)) // 兜底轮询
            .with_compare_contents(true), // 防止编辑器“重写”文件触发多次
    )?;
    watcher.watch(path.as_ref(), notify::RecursiveMode::Recursive)?;
    // watcher 必须 move 到异步任务,否则会被 drop
    tokio::spawn(async move {
        let _watcher = watcher; // 持有 ownership,防止 fd 被关闭
        futures::future::pending::<()>().await;
    });
    Ok(rx)
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let mut rx = async_watch("/etc/myapp")?;
    while let Some(evt) = rx.recv().await {
        // 只关心文件内容变化
        if evt.kind.is_modify() || evt.kind.is_create() {
            for p in evt.paths {
                println!("reload {:?}", p);
                // 热加载逻辑,推荐用 std::fs::read_to_string + toml::from_str
            }
        }
    }
    Ok(())
}

关键点

  • RecommendedWatcher 在 Linux 下自动选 inotify,其他平台自动降级;
  • tokio::spawn 持有 watcher ownership,避免 fd 提前关闭;
  • mpsc::unbounded_channel 解耦,业务线程永远不会阻塞监听线程;
  • with_compare_contents(true) 解决 vim “swap 文件” 导致的重复事件,国内云主机常见

拓展思考

  1. 云原生场景:在 Kubernetes ConfigMap/Secret 挂载目录 中,kubelet 采用 符号链接轮转(..data → ..2023_05_01_12_00_00),此时 inotify 监听原目录会失效。正确做法是 监听父目录的 IN_MOVED_TO 事件,发现新的 ..data 目录后,重新注册 watch 并原子切换指针。
  2. 高并发配置中心:如果监听对象超过 5 万个文件,inotify 的 wd 上限(/proc/sys/fs/inotify/max_user_watches)默认 8192,需 echo fs.inotify.max_user_watches=524288 >> /etc/sysctl.conf热加载 sysctl -p。Rust 侧需封装 批量注册 + 错误重试 逻辑,防止 EMFILE 导致服务启动失败。
  3. 安全沙箱:在 seccomp 容器 内,inotify 可能被禁用。此时需 feature flag 编译出 纯轮询版本,并通过 cargo deb 打两个包:
    • myapp-default.deb(默认 inotify)
    • myapp-restricted.deb(poll only)
      运维根据 容器 runtime 自动选择,国内金融云常见要求