外观
用 launchd 让任务按时间和事件运行
macOS 上的定时任务由 launchd 负责。从别处搬来的 crontab 教程在 Mac 上通常也能跑,但那是兼容层:它不理解休眠唤醒,不能按文件变化触发,出错时也没有统一的日志。原生方案是写一个 LaunchAgent。
本文用一个真实但无害的例子走完全流程:每天把可用磁盘空间记一笔到日志文件。
开始之前
需要能编辑文本文件并在终端执行命令。本文全部操作在当前用户范围内,不需要 sudo,也不改动系统目录。
先明确两个词:Agent 在你登录后以你的身份运行,Daemon 在系统启动时以系统身份运行。日常自动化几乎都该用 Agent——Daemon 权限高、影响面大、调试麻烦。
三个存放位置:
| 路径 | 谁的 | 什么时候运行 |
|---|---|---|
~/Library/LaunchAgents | 当前用户 | 该用户登录后 |
/Library/LaunchAgents | 所有用户 | 任一用户登录后 |
/Library/LaunchDaemons | 系统 | 开机,无需登录 |
本文只用第一个。
写一个任务
任务用 plist 文件描述,扩展名 .plist,内容是 XML。文件名和里面的 Label 保持一致是惯例,方便排查。
先建脚本:
sh
mkdir -p ~/bin
cat > ~/bin/log-disk-space.sh <<'EOF'
#!/bin/bash
df -h / >> "$HOME/disk-space.log"
EOF
chmod +x ~/bin/log-disk-space.shcat > 文件 <<'EOF' 把随后几行写进文件,'EOF' 加引号表示内容原样写入、不做变量替换。df -h / 显示根卷的磁盘使用情况(-h 表示用人类易读的单位),>> 追加到文件末尾。chmod +x 给脚本加上可执行权限——少了这一步,launchd 会报权限错误。
先手工跑一次确认它能工作:
sh
~/bin/log-disk-space.sh && cat ~/disk-space.log再写任务描述:
sh
cat > ~/Library/LaunchAgents/local.diskspace.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>local.diskspace</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>$HOME/bin/log-disk-space.sh</string>
</array>
<key>StartInterval</key>
<integer>3600</integer>
<key>StandardErrorPath</key>
<string>/tmp/local.diskspace.err</string>
</dict>
</plist>
EOF关键字段:
Label:任务的唯一标识,之后所有命令都用它来指代这个任务。用local.开头可以和系统、软件的任务区分开。ProgramArguments:要执行的命令,数组的第一项是程序路径,后面是参数。必须用绝对路径——launchd 运行时的环境变量很少,PATH和你终端里的不一样。StartInterval:每隔多少秒运行一次,这里是 3600 秒即一小时。需要在固定时刻运行时改用StartCalendarInterval,它接受Hour、Minute、Weekday等字段。StandardErrorPath:把错误输出写到指定文件。这一项是本文最重要的字段,没有它,任务失败时你什么都看不到。
加载与卸载
网上大量教程还在用 launchctl load 和 unload。这两个子命令已经废弃,在较新的 macOS 上会打印废弃警告,有时直接失败。现在用 bootstrap 和 bootout:
sh
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.diskspace.plistbootstrap 把任务载入指定的域。gui/$(id -u) 是当前用户的图形登录会话域,id -u 输出你的数字用户 ID。注意参数形式:bootstrap 接收「域 + plist 路径」。
卸载时形式不同:
sh
launchctl bootout gui/$(id -u)/local.diskspacebootout 接收「域/标识」,用的是 Label 而不是文件路径。这个不对称是最常见的报错来源。
立即触发一次,不用等到下个周期:
sh
launchctl kickstart -k gui/$(id -u)/local.diskspacekickstart 立刻运行该任务,-k 表示如果正在运行就先杀掉再启动。调试时用它,比改时间间隔等着快得多。
确认它真的在跑
这是本文的重点。查看任务当前状态:
sh
launchctl print gui/$(id -u)/local.diskspaceprint 输出该任务的完整信息。重点看两行:state 显示当前状态,last exit code 显示上次退出码。退出码 0 是正常结束;非零表示失败,具体含义取决于你的脚本。
只想快速扫一眼所有任务,用性能调优一篇里介绍的 launchctl list,输出的第二列就是上次退出状态。
然后看你自己留的两条线索:
sh
cat /tmp/local.diskspace.err
tail -5 ~/disk-space.log第一条是错误输出,正常情况下应当是空文件。第二条 tail -5 显示文件最后 5 行,确认记录确实在增加。
系统日志里也有记录,查询方法见统一日志。
彻底停用
三步,顺序不能乱:
sh
launchctl bootout gui/$(id -u)/local.diskspace
rm ~/Library/LaunchAgents/local.diskspace.plist
rm ~/bin/log-disk-space.sh ~/disk-space.log /tmp/local.diskspace.err先卸载再删文件。反过来做——先删 plist 再 bootout——会让 launchd 找不到定义,任务可能残留到下次重启。
只是想暂时停掉、以后还要用,用 launchctl disable gui/$(id -u)/local.diskspace。disable 的效果跨重启保留,bootout 不保留;对应的恢复命令是 enable。
常见问题
任务不执行,也没有错误文件。 多半是 plist 格式有误导致根本没加载成功。用 plutil -lint 文件路径 检查语法,plutil 是系统自带的 plist 工具,-lint 表示只检查不修改。
手工跑脚本正常,launchd 跑就失败。 几乎总是路径问题。脚本里所有命令都写绝对路径(用 which 命令名 查),或者在脚本开头显式设置 PATH。
脚本要访问文稿、桌面或外置磁盘时被拒绝。 这不是 launchd 的问题,是隐私授权。系统会把权限归给实际执行的程序(这里是 /bin/bash),处理方式见TCC 隐私授权。
动手练习
把上面的例子完整走一遍,然后故意制造一次失败:
- 按前文创建脚本和 plist,
bootstrap加载。 kickstart -k立即触发,确认日志文件有内容。- 把脚本里的
df改成/bin/df-typo(一个不存在的命令),再kickstart -k。 - 用
launchctl print看退出码,用cat看错误文件。 - 按「彻底停用」三步清理干净,
launchctl print应当报告找不到该任务。
成功标准是第 4 步你能同时从退出码和错误文件看到这次失败。这正是自动化任务最该具备的性质——失败时你能发现。第 5 步之后机器回到原样,没有残留。