有一类问题,第一次看见时很像清理问题:临时目录越来越多,里面却没有文件,程序本身也没有明显报错。我的直觉一开始也是找一个定时任务,把空目录扫掉。后来真正把调用链拆开,才发现这不是“清理做得不够勤快”,而是每次启动工具时,都在制造新的残留。
这次遇到的场景很具体。一个系统脚本会频繁调用 Swift 的小段代码,任务本身很短,完成后只留下一个空的 TemporaryDirectory。单次看,它几乎没有影响;但长期运行后,残留累计到了 71,686 个。目录里没有业务数据,反而更容易让人低估它,因为没有一个文件能直接指向责任方。
我后来把这件事当成一次资源生命周期排查,而不是一次目录清理。当资源在每次调用中被创建,却没有随着调用边界稳定地回收时,真正要修的是创建路径,而不是清理动作。
先证明泄漏发生在解释器启动
排查这类问题,最怕一上来就改主流程。临时目录可能来自系统框架,也可能来自某个工具自身;如果只凭目录名称猜原因,很容易把无关的清理逻辑改得越来越复杂。我的第一步是做一个最小、可重复的单次调用:让 Swift 以解释模式启动,记录调用前后的临时目录数量,再退出并重复检查。
这个实验不需要覆盖完整业务。它只回答一个问题:一次解释模式启动,是否稳定地新增一个目录。如果答案是否定的,就应该转向别的创建者;如果答案是肯定的,问题就已经从“哪个任务留下了垃圾”缩小成“解释器启动为什么会留下资源”。
这一步的重要性在于,它把相关性变成了可重复的因果线索。目录数量不是偶尔变化,而是随着解释器启动次数增长。任务越频繁,残留越多;任务停止,新增也停止。这样的现象比“目录看起来像某个组件创建的”可靠得多。
表面是空目录,实际是启动成本
确认来源之后,我重新看了那个空目录。它确实没有业务文件,也不影响当前任务的返回值,但这不代表它没有代价。目录数量长期增长,会让临时空间变得难以观察;排查磁盘占用时,空目录会掩盖真正需要关注的异常;更重要的是,每次调用都要付出一次解释器启动和运行时初始化的成本。
如果不处理,后果不会只是一串难看的目录名。高频任务会继续制造残留,清理任务需要不断扩大扫描范围,运行环境的状态也会越来越依赖“上一次有没有扫干净”。一旦清理程序失败,或者未来某次调用在目录里留下了有价值的中间文件,粗暴清理还可能误删仍在使用的内容。
表面上我是在处理临时目录,其实我是在处理一次调用的资源边界。一个短任务如果每次都把解释器当成临时工具重新启动,就很难保证它的副作用只停留在调用期间。问题不一定表现为内存泄漏,也可能表现为目录、缓存、锁文件或其他运行时资源逐次累积。
盘点每个调用点,而不是只修第一个
最容易误判的地方,是看到一个明确的解释执行调用后,直接把它改掉,然后认为问题解决了。实际系统里往往不止一个入口:主流程可能有一处,异常处理、状态检查和辅助任务里还各有一处。只改最先找到的调用点,计数增长可能只是变慢,并不会停止。
所以我先盘点所有通过子进程启动 Swift 的位置,把它们按用途和调用频率分开看。这里的目的不是做一份漂亮的清单,而是确认每一个调用点都属于哪种生命周期:它是一次性的交互操作,还是每轮任务都会重复执行的固定 helper;它是否依赖临时输入;它的输出是否可以被统一入口接收。
这个盘点还帮助我区分哪些地方值得预编译。不是所有解释执行都必须改掉。临时试验、需要即时修改源码的调试场景,解释模式仍然有价值;但对输入和行为稳定、被反复调用的短工具,每次重新解释一遍就没有必要。预编译的边界应该由调用频率和代码稳定性决定,而不是由“能不能编译”决定。
把高频 helper 编译成可缓存的工具
我选择把高频、稳定的 Swift helper 交给 swiftc 预编译,并缓存生成的二进制。调用方仍然保留统一的工具入口,变化的是入口内部不再逐次启动解释器,而是优先使用已经存在且可复用的编译产物。这样做的关键不是把命令换成另一条命令,而是把编译和执行放进不同的生命周期。
第一次需要时,入口负责生成或更新二进制;后续调用直接执行缓存结果。缓存是否需要更新,应由源码或构建输入的变化来决定,而不是每次任务运行都重新编译。这样既避免了反复解释,也避免了把编译成本错误地挪到每一次业务调用上。
我没有为每个 helper 再造一套特殊调用协议,是因为这会把修复变成新的维护负担。统一入口保留参数传递和错误处理,调用方只关心工具是否成功;缓存层只负责判断产物是否可用。一次好的修复应该让调用者少知道一层细节,同时让资源生命周期变得更短、更可见。
这里还有一个容易忽略的边界:缓存二进制不能只看“文件存在”。如果源码已经变化,继续执行旧产物会把资源问题变成功能问题。实际判断至少要考虑构建输入是否更新、产物是否可执行,以及编译失败时是否保留一个能被发现的错误。缓存的目的,是减少重复工作,不是绕过校验。
不把定时清理当成修复
最容易走的错误捷径,是保留所有解释执行调用,只增加一个定时清理脚本。它看起来见效快,因为历史目录会暂时减少;但下一轮高频任务仍会创建新的目录,清理程序也会变成系统里另一个需要监控的任务。
我没有选择这个方案,是因为它只处理了结果,没有改变原因。清理仍然可能和正在运行的任务竞争,扫描范围会随时间变大,失败时还不一定有明显告警。更糟的是,它会让团队误以为资源问题已经解决,直到残留再次积累到无法忽略的规模。
清理历史残留当然仍然必要,但它应该排在创建路径修复之后。先让新调用不再产生残留,再清理已经确认无用的旧目录,最后观察一段时间确认计数保持稳定。顺序反过来,就算清理得很干净,也只是把问题的证据暂时擦掉。
用双重验证确认新旧问题都结束
改完以后,我没有只检查缓存二进制能不能运行。第一项验证是清理历史残留,再重新执行相关任务,观察临时目录计数是否继续增长;第二项验证是回看所有调用点,确认它们都经过缓存的编译产物入口,而不是仍然以解释模式启动 Swift。
这两项检查分别回答不同的问题。计数为 0,说明当前可见的历史残留已经清掉;源码调用路径经过缓存入口,才说明下一次运行不会沿着旧路径继续制造残留。前者是状态验证,后者是机制验证,缺一个都不能说明问题真正结束。
复核时,临时目录残留计数为 0,改动涉及的调用点也都通过缓存的编译产物执行。这个结果让我更确定,资源问题的验收不能只看一次清理后的目录状态。一个流程是否真的修好,要同时证明旧资源已经处理,并且新路径不会继续制造同一种残留。
这件事让我重新意识到,很多“泄漏”并不以崩溃的形式出现。它们可能只是每次多一个目录、多一次启动、多一个没人负责回收的句柄。因为单次代价太小,系统会在很长时间里保持“还能用”的假象。等到数量变大,修复成本已经包含了定位、清理和风险判断。
下次我会怎么做
下次再看到临时目录持续增加,我会按一个固定流程处理。先做单次、可重复的最小调用,确认目录是在什么动作之后出现;再盘点所有创建该动作的调用点,按频率和稳定性划分;对高频且稳定的短工具,优先考虑预编译和缓存;最后先修创建路径,再清理历史残留,并分别验证旧状态和新机制。
这个方法也适用于 Swift 之外的解释器、临时编译器和命令行 helper。判断重点不在于某个工具名字,而在于它是否被重复启动、是否产生可缓存的稳定产物,以及它的运行时资源是否跨过了本应结束的调用边界。
我更愿意把它看成一种工作习惯,而不只是一次性能优化:看到残留时,不先问“怎样清掉”,而先问“是谁在每次重复创建”。真正难的不是写一个清理脚本,真正难的是承认资源生命周期属于调用设计的一部分。只要把高频的解释执行改成可验证、可缓存的预编译路径,清理才会从反复补洞,变成一次有终点的收尾。