我需要我的机器对变化做出反应,而不是不停地去问有没有变化。答案是一个用了二十年、几乎没人用过的 macOS 功能:它在每一个重要的地方都赢过了 cron。
上周我搭了一套系统,能自动提交我机器的配置:装一个 Homebrew 包,八秒后 GitHub 上就有一条记录这件事的 git 提交。
刚开始动手时,我的肌肉记忆说的是每个开发者的肌肉记忆都会说的话:
丢进 cron 里就完了。
我没有这么做。而背后的原因,变成了我这些年学到的最有用的一课 macOS 知识。
⏰ 轮询的问题
下面是我这套自动化如果用 cron 来做会是什么样子:
*/5 * * * * /Users/me/sync.sh
每五分钟,永无止境地,我的机器都要唤醒一个进程,问一句:有什么变化吗?
几乎每一次,答案都是没有。
这就是 cron 的本质形状:轮询。它不知道什么是事件,它只知道时间。所以你被迫在两个糟糕选项之间选:
- 轮询得频繁 → 每天几百次毫无意义的执行,以防万一
- 轮询得稀疏 → 改动可能会在未提交的状态下晾上一小时,失去了自动化的意义
而在笔记本上用 cron 还有第二个更隐蔽的问题。cron 假设机器是开着的。11 点 58 分合上盖子,中午的任务就根本不会发生。没有重试,没有补跑,什么都没有。服务器是常开的,笔记本会睡眠。cron 是为服务器设计的。
我真正想要的东西说起来很简单:
文件系统发生变化时,运行我的脚本。否则,什么都不做。
这不是一个日程表。这是一个事件。
⚙️ 你早就有的那个守护进程
macOS 自带 launchd,它就是进程 1,启动系统上每一个服务的那个东西。苹果多年前就在内部用它替换了 cron(你 Mac 上的 cron 任务,其实本身就是被 launchd 运行的)。
大多数开发者都知道 launchd 存在。几乎没人用过它最好的功能:
WatchPaths:launchd 会在操作系统层面监视一个文件系统路径,一旦它变化,就立刻触发你的任务。
<key>WatchPaths</key>
<array>
<string>/opt/homebrew/Cellar</string>
<string>/opt/homebrew/Caskroom</string>
</array>这就是整个把戏。Homebrew 把每个包都装进 /opt/homebrew/Cellar,所以任何安装或卸载,不管来自哪个 shell、哪个脚本、哪个工具,都会改变这个目录。操作系统会注意到。我的脚本就会运行。
没有轮询,没有浪费的执行,空闲时 CPU 占用为零。这个任务以一种已注册的意图的形式存在,监视工作交给内核去做。
完整的任务定义就是一个 plist 文件:
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.paurushrai.dotfiles-sync</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/paurushrai/Developer/infra/dotfiles/sync.sh</string>
</array>
<key>WatchPaths</key>
<array>
<string>/opt/homebrew/Cellar</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>12</integer>
<key>Minute</key><integer>0</integer>
</dict>
<key>ThrottleInterval</key>
<integer>60</integer>
<key>StandardOutPath</key>
<string>/Users/paurushrai/Library/Logs/dotfiles-sync.log</string>
</dict>
</plist>丢进 ~/Library/LaunchAgents/,注册一次:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.paurushrai.dotfiles-sync.plist它扛得住重启。不管终端开不开着,它都会运行。它现在是操作系统的一部分了。
我用最老实的方式测试了它,装一个包,别的什么都不碰:
$ brew install jq
... 8 seconds later, zero shell involvement:
67ba27e chore: auto-sync .Brewfile → pushed to GitHub ✅
🧩 承担主要工作的三个键
上面这份 plist 里藏着三个设计决定,每一个都替我省下了本来得自己写的代码。

ThrottleInterval: 60 是一个免费的防抖器。 连续装五个包,文件系统会触发五个事件,但 launchd 不会让任务在 60 秒内运行超过一次,于是我只得到一次覆盖全部五个包的提交。在 cron 的世界里,这种批处理逻辑得自己写、自己测。这里只是一个整数。
StartCalendarInterval 是 cron 的调度能力,却没有 cron 的笔记本问题。 它给了我每天中午一次的兜底运行,捕捉那些监视漏掉的东西。而且跟 cron 不同,如果机器在中午时是睡眠状态,launchd 会在它醒来时补跑这个任务。错过的日程会晚执行,而不是永远不执行。对笔记本来说,光这一点行为就已经结束了这场对比。
StandardOutPath 是免费的可观测性。 每次运行的输出都会追加进我指定的日志文件。当我想知道某件事为什么没触发时,答案早就写在那儿了。
🔁 每个人都会踩的坑
现在轮到坦白了。我的脚本会提交进一个 git 仓库,我的第一反应是直接监视仓库目录本身。一条监视规则,覆盖所有文件。看起来很优雅。
它同时也是一个死循环。

每一次 git 提交都会改动 .git/。于是:一次改动触发代理 → 代理提交 → 提交改变了 .git/ → 而它就在被监视的目录里 → 于是再次触发代理。无限循环。
解决办法是一条值得背下来的规则:
绝不要监视一个你的任务自己会写入的目录。
我改成监视单个文件:每一个被跟踪的 dotfile 都有自己的 WatchPaths 条目,仓库根目录被刻意排除在外。代价是:某个深层子目录里冒出来的全新文件不会被立刻发现;每天中午的例行运行会把它扫进来。对一个不会追着自己尾巴转的系统来说,这是个公平的代价。
同一个下午还踩到的另外两个坑,分享出来免得你重付一次学费:
- 别对 plist 用软链接。 我把 plist 放在 dotfiles 仓库里,但拷贝一份到
~/Library/LaunchAgents/,在现代 macOS 上,launchd 对软链接的任务定义文件并不可靠。仓库保存唯一真相,安装脚本负责部署一份拷贝。 - launchd 任务只拿到一个极简的环境。 没有你 shell 里的
PATH,没有别名,没有你的.zshrc。我的脚本在第一行就自己设置了PATH。如果你的任务"在终端里能跑,在 launchd 里不行",几乎总是这个原因。
🔔 先让它说话,再让它等待
一个你看不见的后台任务,是一个你会逐渐不再信任的后台任务。
我的脚本用一行原生 macOS 通知来播报每次提交:
osascript -e 'display notification "Committed & pushed: .Brewfile" with title "dotfiles sync"'第一次测试:我根本没看见。横幅通知五秒就淡出了。
解决办法不是代码,是一个几乎没人改过的设置:系统设置 → 通知 → Script Editor → 提醒式。提醒式通知会一直停留在屏幕上,直到被手动关闭。现在每次同步都会在角落等我确认,消息里带着改动的文件名。
一个值得注意的设计细节:脚本只在真正提交了东西时才通知。一次空跑保持沉默。一个不停响的通知,是一个你早就学会无视的通知,通知疲劳杀死可观测性的速度,比完全没有通知还快。
🛠 速查表
搭建你自己这套系统所需的一切:
# register (once, or after editing the plist)
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.example.myjob.plist# unregister
launchctl bootout gui/$(id -u)/com.example.myjob# is it loaded?
launchctl list | grep myjob# what happened?
tail -f ~/Library/Logs/myjob.log还有一张我当初希望自己早点拥有的决策表:
- 任务需要对文件或目录变化做出反应 →
WatchPaths - 任务需要按日程运行,且对笔记本安全 →
StartCalendarInterval - 任务会成批触发,需要合并 →
ThrottleInterval - 任务需要在注册的那一刻立刻运行 →
RunAtLoad - 你正要在 Mac 上敲
crontab -e→ 停下来,写个 plist 吧
🎯 这堂课背后的那堂课
我搭的这套自动化很小。但这个原则不小。
我差点因为 cron 是我熟悉的东西而选它,也差点因为搜索结果都推荐它,而去装一个文件监视的 npm 包。两个都能凑合works,但都是糟糕的方式:一个靠定时轮询,另一个则是为了做操作系统原生就能做的事,额外装一个依赖。
这个又笨、又内置、用了二十年的工具,比两者都更好。事件驱动而不是轮询,对笔记本友好而不是假设常开,零依赖而不是下一台机器上又多一个要装的东西。
在添加一个工具之前,先问问操作系统本身已经自带了什么。
答案往往比你想的更多:你需要的一切,早就待在进程 1 里,从 2005 年起就在等你。
你的 LaunchAgents 文件夹里跑着什么?
我很好奇其他人在操作系统层面自动化了些什么,以及你们踩过什么坑。WatchPaths 的这个反馈循环,应该不会是唯一一个。


