! 本篇文章將會介紹 為什麼是 launchd 而不是 cron:macOS 排程的正確姿勢,期望大家都能搞懂 macOS 排程的取捨邏輯,看懂 plist 每個欄位在忙什麼,把「準時叫醒 agent」這件事交給作業系統 :D
昨天接上 hooks 之後,我們的 agent 已經會「做完事自己喊一聲」了。但系列跑到今天,還有一個最根本的問題沒正式交代:是誰、在什麼時候、把它叫起來開工的?答案就是今天的主角——launchd。第 1 天開場時我留了一道謎題:「Mac 睡著而錯過排程時間,launchd 的補跑行為跟 cron 有什麼不同?」今天兌現支票,順便把「為什麼本系列選 launchd 而不是 cron」的完整取捨邏輯攤開給你看 :D
順帶一提,你現在讀的這篇文章,就是今天傍晚 launchd 準時叫起 generate.sh、交給 opencode 寫出來的——文章在講 launchd,文章本身也被 launchd 排程,理論與實作互相印證,這是本系列的傳統。
讀完這篇你會學到:
# launchd 是 macOS 的 PID 1,開機第一秒就在崗位上
ps -p 1 -o comm=
# ---- 預期輸出 ----
# /sbin/launchd
# 看看你機器上已經有誰住在使用者排程目錄
ls ~/Library/LaunchAgents/ 2>/dev/null
第一個問題:睡著錯過,就永遠錯過。 先公佈第 1 天的謎題解答。cron 的世界觀很樸素:它每分鐘醒來一次,看看「現在時間有沒有對到排程表」,對到就跑。壞就壞在這個「醒來」——Mac 睡覺時 cron 也在睡,等你打開電腦,那個時間點早就過了,它不會回頭補跑,錯過就是錯過。launchd 不一樣:Apple 官方文件白紙黑字寫著,用 StartCalendarInterval 排的任務,如果排程時間電腦正在睡覺,喚醒時會補跑一次;如果睡過頭好幾個週期,也只合併成一次執行,不會連環轟炸。對「每天 19:00 生成文章」這種一天只有一次機會的任務,這就是斷更與不斷更的差別。
第二個問題:權限關卡。 macOS 把桌面、文件、下載這類資料夾納入隱私保護(TCC),cron 是背景 daemon,預設沒有完整磁碟存取權,讀這些位置會直接被系統打槍,得手動把 /usr/sbin/cron 加進「隱私權與安全性 → 完整磁碟存取權」。LaunchAgents 則是以你的身份跑在 GUI session 裡,權限模型跟著使用者走,麻煩少一截。
第三個問題:官方態度。 Apple 文件明講 cron 是「支援的」,但推薦的排程方式就是 launchd。macOS 內建的 cron 版本老舊、多年沒有實質維護;launchd 則是整個 macOS 服務管理的正宮——標準輸出導向、逾時控制、資源限制都是一等公民。
結論不是「cron 不能用」,而是「在 macOS 上它是次等公民」。耍廢組的原則很簡單:要自動化,就把地基蓋在官方主推、行為最穩的那套上。
launchd 不用 crontab -e 那種一行一任務的文字檔,而是每個任務一份 plist(XML property list)。直接拿本系統的真實 plist 來解剖——這是 install-launchd.sh 依 config.sh 產出的 com.benben.ironman.generate.plist:
<?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>com.benben.ironman.generate</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>/Users/benben/ai/automations/ironman/scripts/generate.sh</string>
</array>
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>19</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<key>ExitTimeOut</key>
<integer>1800</integer>
<key>StandardOutPath</key>
<string>/Users/benben/ai/automations/ironman/logs/launchd-generate.out</string>
<key>StandardErrorPath</key>
<string>/Users/benben/ai/automations/ironman/logs/launchd-generate.err</string>
</dict>
</plist>
逐個欄位看它們在忙什麼:
launchctl list 點名、手動觸發、卸載,全靠這個名字。.zshrc 更是完全不會被載入——這是「手動跑會動、launchd 跑就掛」的萬惡根源,第 11 天專文展開。Hour 加 Minute 就是每天這個時刻。想只在週幾跑,還能加 Weekday。剛剛揭曉的「睡眠補跑」技能,就長在這個欄位上。>> log 2>&1,launchd 一個 key 搞定,而且 log 檔就是自動化系統的黑盒子(第 25 天的主角)。小小小測驗:你知道把 plist 丟進
~/Library/LaunchAgents/它並不會自動生效,必須請 launchd 正式「載入」嗎?而且現代做法是launchctl bootstrap gui/$(id -u)——老教學裡常出現的launchctl load早已是 legacy 指令。實際操作實錄,明天揭曉。
觀念武裝完畢,來看本系統實際怎麼用。排程一共三個任務:19:00 generate(生成當天文章)、20:00 publish(agent-browser 發文)、20:15 republish(補發檢查——重跑 publish.sh,靠 flag 冪等,已發佈就跳過)。
設計上有個堅持:時間不寫死在 plist 裡。三個時間點全部集中在 config.sh(GENERATE_TIME、PUBLISH_TIME、REPUBLISH_TIME),install-launchd.sh 負責讀設定、動態產生三份 plist、再逐一下令載入:
# scripts/install-launchd.sh(節選)
source "$ROOT/config.sh"
GEN_H=${GENERATE_TIME%%:*}; GEN_M=${GENERATE_TIME##*:} # "19:00" 拆成 19 和 0
# 三個任務各一份 plist,寫進 ~/Library/LaunchAgents/
make_plist generate generate.sh "$GEN_H" "$GEN_M" > "$HOME/Library/LaunchAgents/com.benben.ironman.generate.plist"
# ...publish、republish 同理,最後逐一 launchctl bootstrap
# 安裝完點名,確認三個任務都在編制內
launchctl list | grep ironman
# ---- 預期輸出(三欄:PID、上次結束狀態、Label)----
# - 0 com.benben.ironman.generate
# - 0 com.benben.ironman.publish
# - 0 com.benben.ironman.republish
單一事實來源的好處:想改生成時間,只動 config.sh 一行,重跑一次安裝腳本,三份 plist 自動重建。至於 launchctl bootstrap 長什麼樣、kickstart 怎麼手動點火、plist 的 Hello World 怎麼從零寫——這些實戰操作是明天的重頭戲,這裡先不搶戲。
Q:launchd 跑 publish 時炸出 read:88: bad option: -a,錯誤風格看起來像 zsh?
A:真實事件,logs/launchd-publish.err 留有案底。當時的排程組態讓腳本被 zsh 解讀,而腳本裡 IFS=',' read -ra 的 -a 是 bash 專屬語法,zsh 的 read 根本不認,直接暴斃。解法是兩支腳本開頭都加守衛:偵測到自己不是跑在 bash,就 exec /bin/bash "$0" "$@" 把自己交給 bash 重跑。教訓:在 launchd 的世界,別假設「登入 shell 是什麼,排程就用什麼」,腳本要自己顧好自己。
Q:crontab 的任務一直 Operation not permitted,讀不到桌面或文件裡的檔案?
A:macOS 的隱私保護(TCC)擋的。cron 是背景 daemon,預設沒有完整磁碟存取權,要手動把 /usr/sbin/cron 加進「系統設定 → 隱私權與安全性 → 完整磁碟存取權」。LaunchAgents 跑在你的 GUI session、以你的身份執行,這類問題少得多——這也列入本系列棄 cron 投 launchd 的理由清單。
Q:改了 plist 內容,排程卻還是照舊的跑?
A:launchd 只在「載入」那一刻讀 plist,之後改檔案不會熱重載。正確流程:launchctl bootout gui/$(id -u)/<label> 卸載 → 改檔 → launchctl bootstrap gui/$(id -u) <plist> 重新載入。本系統的 install-launchd.sh 每次安裝都先整輪 remove 就是為此——而且順序有講究:先卸載、刪舊檔,再寫新檔、載入,順序顛倒會把剛寫好的新 plist 刪掉(腳本註解「順序錯會把新 plist 刪掉」親自認證的坑)。
下一篇我們要介紹「寫第一個 launchd plist:排程 Hello World」,今天看懂了取捨與結構,明天實際手刻 plist、bootstrap 載入、kickstart 點火,把第一個每日排程真的跑起來,敬請期待!
參考資料:
man launchd.plist
有任何疑問但沒有 iT 邦幫忙帳號,或是想匿名提問?
歡迎到 https://dev.benben.me/q/Z5442T 提問或加油打氣,沒意外的話會在完賽之後一起回答 :D