400 万个文件教我的事:Diskly 扫描器性能复盘
75 秒到 2.2 秒、6.46TB 的虚报、一行改动省 150MB、多开线程反而更慢——所有直觉都要过数字这一关。
Diskly 的活儿是把一块几百万文件的磁盘在几十秒内扫完,整棵目录树装进内存,画成矩形树图,还要在上面流畅地悬停、下钻、删除。这个量级有一条铁律:任何「每个文件多做一点点」的代码,都会被乘以四百万。这篇复盘几个代价最大的教训,全部带实测数字。
75 秒 → 2.2 秒:字符串是奢侈品
扫描完成后要做分类聚合:把占用按「应用 / 开发 / 系统 / 文档」等类别汇总。第一版对每个文件调 url.path 构造完整路径字符串,再跑十几次子串扫描判断它落在哪类目录下。400 万文件 × (一次字符串构造 + 十几次扫描)= 75 秒,用户的体感是「扫描到 99% 卡死了」。
修复来自一个说破不值钱的观察:位置类别是目录的属性,不是文件的属性。/Applications 下的所有东西都是「应用」,判一次就够了。重构成:
- 扫描上下文
ScanCtx在进入目录时解析一次类别,随遍历向下继承; - 文件级只剩「从
name取扩展名」这一个 O(短字符串) 操作; - 聚合桶是栈上的固定 8 槽结构体(64 字节整),
CategoryBuckets,累加零堆分配、零字典哈希。
75s → 2.2s,34 倍。没有黑科技,只是把 O(文件数 × 路径长度) 的工作降回了 O(目录数)。
6.46TB 的「2TB 磁盘」:firmlink 与 inode 去重
扫根目录 /,报告总占用 6.46TB——盘一共 2TB。这不是 bug 逼出来的抽象问题,是 macOS 卷布局的具体现实:Catalina 之后系统卷和数据卷是分开的,/Users、/Applications 这些路径通过 firmlink 指向数据卷,同时 /System/Volumes/Data 又把同一个数据卷整个再挂一遍。同一批文件,从根往下走有多条合法路径,朴素遍历每条都算一遍。
去重的正确姿势是认 inode 不认路径:lstat 取 (st_dev, st_ino) 二元组进已见集合。但全量做太贵——几百万次集合插入本身就是开销。实际策略分了层:
- 目录一律去重——firmlink 和重复挂载的入口都是目录,这里不能省;
- 文件只对
st_nlink > 1的去重——linkCount == 1的文件物理上不可能被第二条路径引用,直接计入,这是 99% 以上的情况,零额外开销; - 键用整数对,不用
URL或其他对象——整数哈希快,且省下几百万次装箱。
APFS 的 clone(写时复制的共享块)是另一码事:两个 clone 文件确实各自「拥有」全部逻辑字节,物理共享是文件系统内部的账,扫描器不做处理——这类边界,诚实比聪明重要。
顺带一个同族细节:文件大小取 totalFileAllocatedSize ?? fileAllocatedSize ?? fileSize ?? 0 三级回退——优先「磁盘实际占用」(含块对齐、透明压缩的影响),这才是「磁盘去哪了」这个问题的正确口径。
内存:三个互不相干的隐形大户
几百万节点常驻内存,每个节点省一字节就是几 MB。三处修复各自独立,叠加起来把峰值拉回了可控区间:
UUID→ObjectIdentifier:节点 id 原来是 UUID,每个 16 字节。900 万节点 = 150MB 纯开销。而FileNode是引用类型,对象地址天然唯一——ObjectIdentifier零成本。SwiftUI 的Identifiable并不在乎你的 id 是什么,只在乎它稳定;- 剥掉 URL 的隐藏缓存:
contentsOfDirectory(includingPropertiesForKeys:)预取的resourceValues会缓存在 URL 对象内部。不在入树前调removeAllCachedResourceValues(),每个 URL 都拖着一个元数据字典陪葬,常驻内存能多出数 GB。这个缓存对后续毫无用处——需要的字段早就抽走了; - 每目录一层
autoreleasepool:扫描 worker 是不返回的长循环,Foundation 调用产生的 autorelease 临时对象默认要等线程的池子排空——而这个池子在长循环里永远等不到排空点,一整场扫描的临时对象全程堆积。手动在每个目录的处理外包一层,及时释放。
并发数:直觉输给了 24% 和 40%
「磁盘扫描是 IO 密集,线程开越多越好」——听起来无懈可击。实测(8 核,本地 SSD):16 线程比 8 线程多耗 24% CPU、多占 40% 内存,耗时没有变快。
因为前提错了。NVMe 上的元数据操作快到瓶颈根本不在设备,而在系统调用和内核目录结构的锁。超出核数的线程不产生额外吞吐,只在共享工作队列的锁上空转,把 CPU 烧给调度器。最终默认并发 min(16, max(4, cores))。
唯一成立的例外是云同步目录:iCloud/OneDrive 的占位文件每次 stat 都是一次到 File Provider 进程的 IPC 往返,线程大部分时间真的在「等」,超配才有收益——所以只在用户勾选「包含云目录」时,worker 才拉到 32。同一个参数,两种负载下的正确值差一倍,并发度从来不是常量,是负载的函数。
(云目录还有一个更狠的坑:这些占位文件的 dev_t 和启动卷相同,想按卷黑名单跳过是拦不住的,只能按绝对路径黑名单处理,且读元数据绝不触发下载——否则一次「磁盘分析」能把用户的 iCloud 流量吃光。)
收尾与渲染:最后一米的两个陷阱
排序时的 COW 陷阱:树建完要按大小排序、把小项折叠成「其他 (N 项)」。折叠函数如果多持有一份 children 数组的引用,接下来的 removeLast 就会触发 copy-on-write 全量复制——原地排序省下的内存一夜回到解放前。Swift 的值语义是安全网,也是性能税,百万级数组上每一次「顺手多存个引用」都要过脑子。
Canvas 悬停掉帧:treemap 用 SwiftUI Canvas 绘制,第一版把结构和高亮画在同一层。后果:鼠标每移动一像素 → hovered 状态变化 → body 重算 → Canvas 闭包整个重跑——所有格子的 Path、填充,加上每格两行文本的二分截断测量(上百格 × 每格约 10 次字体测量 = 每帧上千次测量)。修复分两刀:
- 拆两层 Canvas:
StructureCanvas(结构 + 标签)不接收 hovered 参数,配.equatable()让 SwiftUI 在树引用不变时直接跳过重绘;HighlightCanvas只画选中和悬停的两条描边; - 文本测量全部前移到布局重建阶段,算好的
displayName/displaySize缓存在节点上,绘制路径零测量。
小结
这一圈下来,方法论只有一句:先数清楚这行代码会被执行多少次,再决定它配不配存在。被四百万放大之后,一次字符串拼接是 75 秒,16 个字节是 150MB,一个「多开线程」的直觉是 24% 的 CPU 白烧。性能工程不神秘——它只是不给直觉留面子。