FreeBSD 中的原生 inotify
摘要
本文介绍了在 FreeBSD 中实现原生 inotify 支持的过程,通过将 inotify 移入内核,解决了现有用户空间库在处理文件监控时的问题,从而提升了性能和正确性。
<p><a href="https://lobste.rs/s/b55q8b/native_inotify_freebsd">评论</a></p>
查看缓存全文
缓存时间: 2026/07/14 22:21
# FreeBSD 中的原生 inotify - Klara Systems
来源:https://klarasystems.com/articles/native-inotify-in-freebsd/
## 一个简单的文件监控问题
不久前,一位客户向我们提出了一个看似简单的问题。他们的工作流程是通过 `scp` 将文件上传到服务器,并希望在上传完成后在服务器上执行某个操作。他们最初的解决方案是使用 `inotifywait` 工具来监控上传目录,等待 `CLOSE` 通知,然后在收到通知后执行操作。`inotifywait` 可以监控文件层次结构的访问或修改,例如文件创建、写入、删除等。这个解决方案在 99% 的情况下都能正常工作,但偶尔在上传完成后,`inotifywait` 会无法发出 `CLOSE` 通知。
通知缺失的根本原因在于 `inotifywait` 的底层实现方式。它使用了一个 FreeBSD 特有的库 `libinotify.so`,该库在用户空间利用 `EVFILT_VNODE` kqueue 过滤器(https://man.freebsd.org/cgi/man.cgi?kqueue)实现了 Linux 的 inotify(https://www.man7.org/linux/man-pages/man7/inotify.7.html)接口。Linux 的 inotify 和 FreeBSD 的 `EVFILT_VNODE` 都是系统调用接口,允许应用程序被动监控文件或目录的变化。
这两个接口本身差异很大;下面是一个示例函数,用于监控指定目录中所有文件的关闭事件,并在事件发生时打印出来,实际上与我们的客户所需功能相同。首先是基于 inotify 的版本:
```
void watchdir_inotify(const char *dirname)
{
struct inotify_event *ev;
size_t evsz;
int ifd, wd;
ifd = inotify_init();
if (ifd < 0)
err(1, "inotify_init");
wd = inotify_add_watch(ifd, dirname, IN_CLOSE);
if (wd < 0)
err(1, "inotify_add_watch(%s)", dirname);
evsz = sizeof(*ev) + NAME_MAX + 1;
ev = malloc(evsz);
if (ev == NULL)
err(1, "malloc");
/* 主循环 */
while (true) {
ssize_t n;
n = read(ifd, ev, evsz);
if (n < 0)
err(1, "read");
if (ev->len > 0)
printf("CLOSE: %s\n", ev->name);
}
}
```
这段代码很直接:我们创建一个 inotify 描述符,让它监控指定目录的 `IN_CLOSE` 事件,然后循环读取描述符中的事件描述。通过使用 `kevent()` 或 `epoll()` 等待 inotify 描述符上有数据到达,可以轻松地将此集成到更大的事件循环中。
以下是使用 `kqueue()` 和 `EVFILT_VNODE` 尝试实现相同功能的版本:
```
struct entry {
LIST_ENTRY(entry) link;
char name[NAME_MAX + 1];
int fd;
bool seen;
};
LIST_HEAD(entry_list, entry);
static void
scan(int kq, DIR *d, struct entry_list *entries)
{
struct dirent *dp;
struct kevent kev;
struct entry *e, *tmp;
int error;
LIST_FOREACH(e, entries, link)
e->seen = false;
rewinddir(d);
while ((dp = readdir(d)) != NULL) {
int flags;
if (strcmp(dp->d_name, ".") == 0 ||
strcmp(dp->d_name, "..") == 0)
continue;
/* 查找现有条目 */
LIST_FOREACH(e, entries, link) {
if (strcmp(e->name, dp->d_name) == 0) {
e->seen = true;
break;
}
}
if (e == NULL) {
int fd;
/* 未找到,创建新条目 */
fd = openat(dirfd(d), dp->d_name, O_RDONLY);
if (fd < 0) {
if (errno == EOPNOTSUPP)
continue;
err(1, "openat(%s)", dp->d_name);
}
e = malloc(sizeof(*e));
if (e == NULL)
err(1, "malloc");
(void)strlcpy(e->name, dp->d_name, sizeof(e->name));
e->fd = fd;
e->seen = true;
LIST_INSERT_HEAD(entries, e, link);
flags = EV_ADD | EV_CLEAR;
} else {
flags = EV_CLEAR;
}
/* 监控文件的关闭事件 */
EV_SET(&kev, e->fd, EVFILT_VNODE, flags,
NOTE_CLOSE | NOTE_CLOSE_WRITE |
NOTE_RENAME | NOTE_DELETE,
0, e);
error = kevent(kq, &kev, 1, NULL, 0, NULL);
if (error < 0)
err(1, "kevent");
}
/* 移除未看到的条目 */
LIST_FOREACH_SAFE(e, entries, link, tmp) {
if (e->seen)
continue;
LIST_REMOVE(e, link);
close(e->fd);
free(e);
}
/* 将目录本身加入队列 */
EV_SET(&kev, dirfd(d), EVFILT_VNODE,
EV_ADD | EV_CLEAR,
NOTE_WRITE,
0, 0);
error = kevent(kq, &kev, 1, NULL, 0, NULL);
if (error < 0)
err(1, "kevent");
}
void
watchdir_kqueue(const char *dirname)
{
struct kevent kev;
struct entry_list entries;
DIR *d;
int error, kq;
LIST_INIT(&entries);
kq = kqueue();
if (kq < 0)
err(1, "kqueue");
d = opendir(dirname);
if (d == NULL)
err(1, "opendir(%s)", dirname);
/* 监控目录中已有的所有文件 */
scan(kq, d, &entries);
/* 主循环 */
while (true) {
error = kevent(kq, NULL, 0, &kev, 1, NULL);
if (error < 0)
err(1, "kevent");
if (kev.udata == NULL) {
/* 目录本身发生变化,重新扫描 */
scan(kq, d, &entries);
} else {
struct entry *e;
e = (struct entry *)kev.udata;
if ((kev.fflags &
(NOTE_RENAME | NOTE_DELETE)) != 0) {
/* 文件被重命名,重新扫描 */
scan(kq, d, &entries);
}
if ((kev.fflags &
(NOTE_CLOSE | NOTE_CLOSE_WRITE)) != 0)
printf("CLOSE: %s\n", e->name);
}
}
}
```
我们立刻能看出这复杂得多。`EVFILT_VNODE` 操作于文件描述符,只能监控该文件描述符引用的文件。这意味着要监控整个目录,我们必须:
1. 遍历目录并打开每个文件,为其分配跟踪结构体并注册到 kqueue;
2. 监控目录本身的变化,以便在文件添加或删除时重新扫描;
3. 跟踪每个文件在目录中的名称,包括监控每个文件的重命名事件。
所有这些工作都非常繁杂。此外,对于大型目录,由于需要在条目添加或删除时扫描目录,性能很差。而且,由于目录中的每个文件都需要打开,应用程序可能需要使用大量文件描述符,增加内核内存使用量,并可能触及描述符限制。最后,kqueue 版本至少存在一个竞态条件:如果 `scp`(或任何其他工具)正在向被监控目录添加新文件,底层操作大致如下:
1. 创建文件并打开以写入;
2. 使用步骤 1 获得的文件描述符将数据写入文件;
3. 关闭文件描述符。
你发现问题了吗?步骤 1 会导致 `watchdir_kqueue()` 函数重新扫描目录,打开新文件,并将其注册到 kqueue。然而,与此同时上传服务正在执行步骤 2 和 3。如果它在文件注册之前完成了步骤 3,那么 `watchdir_kqueue()` 将不会报告 `CLOSE` 事件,因为该事件发生在文件注册之前。这正是我们的客户遇到的问题。
通过运行以下命令在监控目录中创建新文件,可以在实践中观察到这种情况:
```
$ echo a > foo
```
对比:
```
$ dd if=/dev/random of=foo bs=1M count=128
```
前者运行非常快,很容易触发竞态条件;而后者在生成 `CLOSE` 通知方面应该更可靠。无论如何,在这两种解决方案中,使用 inotify 的方案明显更优:它更简单、更高效,并且在 Linux 上至少不存在这种竞态条件。只有一个问题:直到最近,FreeBSD 还没有原生的 inotify 实现。上面提到的 `libinotify.so` 由于设计原因同样存在竞态条件,因此即使我们可以在使用它的软件中实现 inotify 接口,这些 bug 也是不可避免的。
## 寻求解决方案
面对 `EVFILT_VNODE` 的这些限制,很明显 FreeBSD 的文件监控接口不够完善,但前进的道路并不明朗。我们研究了其他操作系统是如何处理文件监控的。
其他 BSD(OpenBSD、NetBSD 和 DragonflyBSD)也实现了 `EVFILT_VNODE`,但同样必然遭受相同的问题,因为 `EVFILT_VNODE` 要求每个被监控的文件都必须打开。NetBSD 在内核中作为 Linux 系统调用兼容层的一部分实现了 inotify 接口,但同样是基于 `EVFILT_VNODE` 实现的,而不是直接集成到 VFS 中,因此继承了大部分相同的限制和复杂性。
另一方面,Linux 拥有 inotify 接口。inotify 引入了一种新的文件描述符类型,允许用户用它来监控文件和目录的文件系统事件。关键在于,监控一个目录会自动导致该目录中的所有文件以及监控开始后添加到该目录的任何文件都被监控。与 `EVFILT_VNODE` 不同,应用程序无需打开每个文件或跟踪文件名。
除了 inotify,Linux 还有 fanotify 接口,它提供类似但更丰富的功能,例如拦截文件访问的能力。在实践中,inotify 似乎在开源软件中更常用,可能是因为它更古老且更易于使用。
在 MacOS 上,可以使用 `FSEvents`(https://developer.apple.com/documentation/coreservices/file_system_events)API 进行文件监控。它由一个内核组件(https://github.com/ale-oss-distributions/xnu/blob/main/bsd/vfs/vfs_fsevents.c)和一个在用户空间运行的系统守护进程组成,该守护进程使用 `/dev/fsevents` 获取文件系统访问事件信息,并将其发布给消费者。特别地,消费者并非直接从内核获取事件流。这种设计允许系统在用户空间实现事件流策略(例如,如何处理丢弃的事件)和状态管理,提供了灵活性,同时保持内核端相对简单,但对于我们这种简单的用例来说也相当复杂和通用。
受 `FSEvents` 启发,我们花了一些时间实现了一个新的 kqueue 过滤器 `EVFILT_FSWATCH`,它可以在没有用户空间守护进程的情况下提供一些相同的功能。其想法是提供一种监控挂载点上所有文件的所有事件的方法,因为这在内核中实现起来相当容易。注册过滤器时,应用程序还提供一个指向结构体数组的指针,每个结构体描述特定路径上的一个或多个事件。内核知道这些结构体的布局,并在应用程序通过调用 `kevent()` 轮询新事件时填充它们。它避免了 `EVFILT_VNODE` 的缺点,但我们只实现了概念验证,尚未解决各种实际问题,例如处理丢弃的事件、有效的路径过滤或访问限制。
## 为什么选择 inotify?
最终,我们决定在 FreeBSD 中实现原生的 inotify 接口。尽管 inotify 有其自身的缺点——例如无法递归监控文件层次结构、对背压和事件丢弃的控制较差等——但它在 FreeBSD 上运行的软件中被广泛使用(因此需要 `libinotify.so` 兼容垫片),而且实现相对简单。此外,一个原生的兼容实现让我们可以“免费”在 Linuxulator 中使用 inotify,这有助于许多 Linux 二进制文件直接在 FreeBSD 上运行。
事实上,缺乏原生 inotify 实现一直是许多 FreeBSD 用户的痛点。有很多开源软件使用它,包括 GLib 和 Qt 等基础库(它们都在其上提供了更高级的接口);一些软件支持在编译时选择替代方案 `EVFILT_VNODE`,但相当多的应用程序不支持,而且直到最近,FreeBSD ports 树中有超过 100 个 ports 直接引用了用户空间的 libinotify。
虽然 `libinotify.so` 非常有用,并且是在 FreeBSD 上启用标准桌面软件的关键部分,但上述与 `EVFILT_VNODE` 相关的问题导致了许多挫败感。除了最初促使我们进行这项研究的竞态条件外,`EVFILT_VNODE` 的资源开销在某些情况下也令人望而却步。例如,流行文件同步服务 syncthing 在使用 kqueue 监控大型目录时会记录警告,因为在这种情况下 CPU 和内存使用率频繁过高。有了我们的原生 inotify 实现,这些问题将消失,尽管还需要进一步工作才能使 FreeBSD 的 inotify 符号在 `golang.org/x/sys/unix` 包中可用,然后在 syncthing 基于 inotify 的文件事件通知包中使用它们。
与其在 FreeBSD 中拥有两个相互竞争的接口,不如以某种方式扩展 `EVFILT_VNODE` 以支持我们所需的功能,但要实现这一点,我们需要一种让 `kevent()` 从内核导出文件名的方法——从而消除用户空间扫描目录和跟踪文件名的需求——这意味着它根本不适合我们的问题。因此,我们将目标设定为原生实现 inotify,特别是与 Linux 保持源代码兼容,以确保现有代码能在 FreeBSD 上正常工作。
## inotify 接口
前面 `watchdir_inotify()` 的例子已经展示了接口的大部分内容,它由三个系统调用组成:`inotify_init()`、`inotify_add_watch()` 和 `inotify_rm_watch()`。它们分别创建 inotify 描述符、开始监控文件或目录、停止监控文件或目录。在 inotify 描述符上调用 `close(2)` 会隐式停止与其关联的所有监控。
`inotify_add_watch()` 系统调用接受一个 inotify 描述符、一个路径和一个事件掩码,并返回一个监控描述符,它实际上是一个可以传递给 `inotify_rm_watch()` 的伪文件描述符。特别地,每个被监控对象都会获得一个与 inotify 描述符关联的唯一监控描述符。这些监控描述符不受文件描述符资源限制的影响,从而可以同时监控许多目录。但是,它们受 `vfs.inotify.max_user_watches` sysctl 定义的全局限制。
在 FreeBSD 中,我们额外提供了一个系统调用 `inotify_add_watch_at(2)`,类似于 `openat(2)`,它接受一个目录描述符(或 `AT_FDCWD` 常量)和一个相对路径。这是为了能够在 Capsicum 沙箱(https://man.freebsd.org/cgi/man.cgi?capsicum)中使用 inotify,因为沙箱中的程序在没有目录描述符的情况下不允许访问文件系统。
位于 `/usr/share/examples/inotify/inotify.c`(https://cgit.freebsd.org/src/tree/share/examples/inotify/inotify.c)的示例程序提供了一个更完整的例子,演示了 inotify 可以报告的不同事件。它所做的是为命令行上列出的每个路径添加一个监控,并报告它收到的所有事件。inotify-to-
相似文章
验证 /proc
作者提出了一种验证 /proc 是否为真正的 proc 文件系统的方法,解决了 inotify 在检测挂载点方面的局限性,从而实现可靠的文件系统监控。
FreeBSD 的 AI 审计
一次 AI 辅助的安全审计揭示了 FreeBSD 内核中的 15 个漏洞,包括权限提升和虚拟机逃逸,并详细描述了与 FreeBSD 团队协作报告和修补漏洞的过程。
FreeBSD 吃掉我的内存
一篇解释为什么 FreeBSD 看起来占用大量内存的文章,将其归因于磁盘缓存和虚拟内存管理,类似于 Linux 的“吃掉我的内存”现象。
探索NetBSD磁盘标签
这篇文章通过十六进制编辑和C编程示例,详细介绍了NetBSD 11上BSD磁盘标签的格式、文档来源和结构。
FreeBSD 在 Aoostar WTR Pro NAS 上
对 AOOSTAR WTR Pro NAS 的全面评测,涵盖其构建质量、BIOS 以及在 FreeBSD 和其他 BSD 操作系统下的性能。