fsnotify是首选,因其基于内核事件机制(Linux inotify/macOS FSEvents/Windows ReadDirectoryChangesW),避免轮询的CPU开销、变更遗漏和跨平台问题;但必须监听目录而非文件,并在事件中过滤路径。

为什么 fsnotify 是首选,而不是轮询
直接用 time.Ticker 定期读文件哈希或 os.Stat() 比较修改时间,看似简单,但会漏变更、耗 CPU、不跨平台。Linux 的 inotify、macOS 的 FSEvents、Windows 的 ReadDirectoryChangesW 都是内核级事件通知机制——fsnotify 就是它们的 Go 封装,开箱即用且行为一致。
注意:它监听的是「目录」,不是单个文件;想监控 config.yaml,得监听其所在目录,并在事件中过滤路径。
- 安装:
go get golang.org/x/exp/fsnotify(Go 1.22+ 推荐)或旧版github.com/fsnotify/fsnotify - 监听前确保目录存在,否则
Watch.Add()会返回no such file or directory - 事件类型包括
fsnotify.Write、fsnotify.Create、fsnotify.Remove,但Write不代表内容已落盘(尤其小文件写入可能合并触发一次)
如何避免重复触发和丢失事件
编辑器保存文件时常见「先写临时文件再原子重命名」,比如 VS Code 保存 main.go 会触发 Create + Remove(原文件)+ Rename(新文件),而 Vim 可能直接 Write 原文件。若只响应 Write,就收不到 Vim 的变更;若只响应 Rename,又漏掉 VS Code 的临时文件创建。
稳妥做法是:对所有写相关事件(Write、Create、Rename)都做处理,并加一层去重和延迟确认:
立即学习“go语言免费学习笔记(深入)”;
- 用
map[string]time.Time缓存最近触发的文件路径与时间,100ms 内相同路径的事件丢弃 - 收到事件后启动一个
time.AfterFunc(100 * time.Millisecond, func()),等写操作真正完成再读取内容 - 不要在事件回调里直接解析 YAML/JSON——出错会卡住整个监听器;应发到 channel 交给 worker goroutine 处理
fsnotify.Watcher 的生命周期管理要点
Watcher 不是线程安全的,不能多个 goroutine 同时调用 Add 或 Remove;更关键的是,它必须显式关闭,否则 fd 泄露,Linux 下最多打开 8192 个 inotify 实例,超限后新监听直接失败,报错:too many open files。
- 用
defer watcher.Close()仅在初始化函数里不够——程序长期运行时可能需要动态增删监听路径 - 推荐封装成结构体,带
mu sync.RWMutex保护watcher和路径映射表,AddPath/RemovePath方法内部加锁 - 监听失败(如权限不足)时
Eventschannel 不会关闭,但Errorschannel 会发一条 error 后关闭;务必 select 处理Errors,否则 goroutine 泄露
实际读取变更内容时的坑
事件来了不代表文件此刻可读:其他进程可能正写入中,或编辑器刚创建空文件就发了 Create。直接 os.ReadFile 可能报 text file busy 或读到截断内容。
- 用
os.Open+io.ReadAll替代os.ReadFile,便于加重试逻辑 - 首次读失败(
syscall.EBUSY、syscall.ENOENT)时,sleep 10ms 后重试,最多 3 次 - 读取后建议校验文件大小是否为 0,避免处理空配置文件
- 如果监控的是日志文件,注意
Write事件可能高频密集,此时更适合用tail -f类方案(如github.com/hpcloud/tail),而非 fsnotify
最常被忽略的是:不同编辑器对同一文件的保存行为差异极大,没有银弹过滤逻辑。上线前务必用目标环境的真实编辑器(Notepad++、VS Code、vim)逐个测试事件序列。


















