☰
拆解fsearch磁盘遍历:getattrlistbulk+rayon如何20秒枚举800万文件而零stat
2026/10/10 17:36:06 网站建设 项目流程

【免费下载链接】fsearch

Whole-disk file search for macOS: fuzzy names, typo tolerance, indexed content grep. ~1 ms over 8M files.

项目地址:https://gitcode.com/gh_mirrors/fsea/fsearch
点击查看免费下载

fsearch是一款面向 macOS 的全盘文件搜索工具:首次用约 20 秒建好全磁盘索引,之后按文件名找文件只要约 1 毫秒,还能容忍拼写错误、搜索文件内容。它的核心模块 src/walk.rs 里藏着一套组合拳——macOS 批量系统调用getattrlistbulk+ Rust 并行库rayon——一次调用带回几百个目录项、每个文件零 stat,让 800 万个文件的磁盘枚举成为可能。这篇文章带你拆开看它是怎么做到的。


为什么"逐个 stat"是 800 万文件的天堑

先看看最朴素的思路:拿到一个目录,读出里面的名字,再对每一个文件调用一次stat()获取类型、大小、修改时间……

传统做法代价(770 万个条目)
每个文件 1 次 stat约 770 万次系统调用
每次调用 ~2-5 μs(不含安全框架开销)光 syscall 就 20~40 秒

而且这只是"列目录"这一步,还没开始搜索。fsearch 的解法一句话概括:把"每个文件一次查询"改成"每次调用查一批发"。

getattrlistbulk:一次 syscall 带回几百个目录项 🚀

getattrlistbulk(2)是 macOS/APFS 原生的批量列目录系统调用:给它一个目录 fd,它一次性返回几百个条目,并且每个条目的名字、类型、大小、mtime、flags 都直接附在数据里——所以整个流程里根本不需要为任何文件单独发 stat。

fsearch 在 walk.rs 的list_fd中做了三件关键事:

  • 精确点菜:先构造一个attrlist位图(walk.rs#L165-L170),只索取名字、对象类型、修改时间、文件 flags、挂载状态、文件大小——内核只返回你要的字段;
  • 循环批量取:往一个线程私有的 256KB 缓冲区(walk.rs#L61-L63)里反复灌数据,直到系统调用返回 ≤ 0,期间逐条解析、直接填进内存结构 RawEnt;
  • 零拷贝解析:返回的缓冲区本身就是"条目长度 + 条目数据"的序列,parse_entry 直接在原地按偏移读取,不产生中间字符串。

rayon 并行遍历:目录树如何"炸开"给 8 个工人

一个目录列完后,里面的子目录怎么分给多个线程?finish_dir 的实现非常直白:

  1. 列完当前目录后,把每个子目录包装成一个任务,丢给 rayon 的任务作用域s.spawn(...)(walk.rs#L129-L136);
  2. 子目录用openat()相对父目录的 fd打开(walk.rs#L132),路径永远不用重建,PATH_MAX这类坑也绕开了;
  3. 每个线程的结果写进自己的Mutex<Vec<Listing>>槽位(walk.rs#L139-L142),避免 8 个线程抢同一把锁。

这实际上是一个"深度优先的工作队列":哪个工人空闲就处理下一个子目录,天然负载均衡,代码却不到 30 行。

8 个线程的甜点位,以及那些隐藏的工程细节

线程数不是拍脑袋定的。作者在 walk.rs#L104-L107 的注释里留下了这台 Mac 上的实测:打开+关闭一个目录约 19 μs(因为 Endpoint Security 客户端会对每次 open 征税),getattrlistbulk 约 14 μs,超过 ~8 线程后内核侧就不再加速了。所以 engine.rs 里写死了SCAN_THREADS: usize = 8。

细节同样讲究:

  • fd 上限:raise_fd_limit 把进程 fd 上限抬到 65536,避免深层目录树撞穿EMFILE;
  • 不越界:遇到挂载点打上FLAG_MOUNT标记、不再深入(walk.rs#L224-L229),firmlink 则正常穿过,/Users在数据卷下恰好只出现一次;
  • 不下载 iCloud:每个线程启动时调用 no_materialize,让"无数据占位文件"快速失败,而不是触发几十 GB 的云端下载。

20 秒是怎么算出来的?📊

把上面的数字乘起来:一个目录 ≈ 一次openat(~19 μs)+ 若干次 getattrlistbulk(~14 μs 起)+ 一次close。8 个线程并行下,单个目录的有效成本约 0.2~0.5 ms,全盘 4~5 万个目录正好落在~20 秒。

建完索引后,/下的每个条目都被排进一个 mmap 的扁平文件 index.bin(按文件夹块深序排列,in:过滤因此只是一个范围边界),随后 full_build 把它落盘并重新映射。日常维护则交给 FSEvents 事件流(src/fsevents.rs):只有变化的目录会被单独重列、做幂等 diff(src/live.rs),重启也只回放增量,从不重扫全盘。

指标(M4 Max,7.7M 文件)数值
首次全盘扫描~20 秒(一次性)
按名找文件p501.3 ms
文件内容搜索p50 9 ms
新文件/改名/删除生效~0.1 s
守护进程内存30–135 MB

小结:零 stat 的三步组合拳

  1. 批量取:getattrlistbulk 一次带回几百个"自带元数据"的条目,消灭逐文件 stat;
  2. 并行分发:rayon 作用域把子目录当任务炸开,openat 相对 fd 避免路径重建,每线程独立输出槽避免锁竞争;
  3. 守住内核的脾气:8 线程甜点位、抬高 fd 上限、跳过挂载点、屏蔽 iCloud 占位文件。

想看它和 fff 的正面交锋,可以跑一下 demo/vs_fff_demo.py,配套对比数据在 demo/vs_fff_chromium.json 里。想自己动手改,核心就两个文件:src/walk.rs(遍历)和 src/index.rs(索引布局)。

【免费下载链接】fsearch

Whole-disk file search for macOS: fuzzy names, typo tolerance, indexed content grep. ~1 ms over 8M files.

项目地址:https://gitcode.com/gh_mirrors/fsea/fsearch
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询