從 Linux 轉到 macOS 工作,多半會在某個瞬間卡住:想開機自動跑一個同步腳本,反射性地打 systemctl enable,結果 macOS 根本沒有 systemctl;想排程一個每天的備份,習慣性地 crontab -e,雖然還能用,但會發現 Apple 文件一直叫大家別再用 cron。macOS 背後其實有一套完整、而且設計得相當早的服務管理系統,核心就是 launchd 這一個 process。理解它,幾乎等於理解整個 macOS 是怎麼開機、怎麼把背景服務管起來的。
這篇用三張圖解,從 launchd 的多重身分講起,依序走過 plist 宣告式設計、system/gui/user 三種 Domain 的差別、On-demand 隨需啟動機制、KeepAlive 條件式重啟、launchctl 新舊語法與除錯,最後整理 2026 年這個系統有哪些新變化,並實際寫一個會自己定時跑的 LaunchAgent。對熟悉 Linux 的開發者們,文章會盡量用 systemd、inetd、cron 來對照,建立心智模型最快。
launchd 是什麼:一個 process 扮演四種身分
在 Linux 上,「開機啟動服務」「有連線才拉起服務」「定時排程」「程序掛掉自動重啟」這四件事,分別由 init/systemd、inetd/xinetd、cron、supervisor 這幾套獨立工具負責。macOS 的選擇剛好相反:把這四種職責收斂進同一個 daemon。launchd 是 kernel 開機後啟動的第一個使用者空間程序,PID 永遠是 1,之後所有服務都是它的子孫。

這個設計帶來一個關鍵差異:在 launchd 的世界裡,服務不負責「怎麼啟動自己」,只負責「描述自己該在什麼條件下被啟動」。要不要常駐、要不要開機跑、被連線打到才起來、還是每天三點跑一次,全部寫成設定丟給 launchd,由 launchd 統一決定何時 fork、何時回收。這跟 systemd 的哲學其實很接近,但 launchd 早了好幾年——它在 2005 年的 Mac OS X 10.4 就登場,那時 systemd 連影子都還沒有。
一切都是 plist:宣告式設計
launchd 沒有 SysV 那種開機 shell 腳本,也沒有 systemd unit 那種半宣告半指令的混合語法。每個服務就是一個 property list(plist)檔案,副檔名 .plist,內容是一份 XML(系統內部會轉成 binary plist 加速讀取)。這份檔案描述「這個服務是什麼、執行什麼程式、什麼條件下該被拉起來」,啟動邏輯完全不寫在裡面。
一個最小的 LaunchAgent 大概長這樣:
<?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>tw.klab.backup</string>
<!-- 實際要執行的程式與參數,第一個元素是執行檔絕對路徑 -->
<key>ProgramArguments</key>
<array>
<string>/Users/kyle/bin/backup.sh</string>
<string>--full</string>
</array>
<!-- 載入後立刻跑一次 -->
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
幾個最常用到的 key 整理如下,其餘都可以查 man launchd.plist,這份 man page 是目前最權威、也最該優先讀的來源:
| key | 用途 |
|---|---|
Label | 服務唯一識別名,必填,慣例用反向網域(如 tw.klab.backup) |
ProgramArguments | 要執行的指令與參數陣列,第一個元素必須是絕對路徑 |
RunAtLoad | 載入時是否立刻跑一次 |
KeepAlive | 是否(以及在什麼條件下)保持常駐、掛掉自動重啟 |
StartCalendarInterval | 定時排程,取代 cron |
StartInterval | 每隔幾秒跑一次 |
WatchPaths | 監看路徑,內容變動就觸發 |
Sockets | 宣告 socket,有連線才拉起服務(取代 inetd) |
StandardOutPath / StandardErrorPath | 把 stdout/stderr 導去檔案,除錯必備 |
這裡有個 Linux 使用者很容易踩的坑值得先講:ProgramArguments 的第一個元素一定要是絕對路徑,而且 launchd 跑服務時的環境變數極度精簡——沒有平常 shell 的 PATH、沒有 ~/.zshrc 載入的那些設定。很多人寫好腳本手動跑沒事,丟給 launchd 就「沒反應」,十之八九是腳本裡某個指令找不到,因為 PATH 不是想像中的那個。所以實務上腳本內最好都用絕對路徑,或在腳本開頭自己 export PATH。
Domain:服務跑在哪裡,決定它能做什麼
launchd 最容易讓人混淆的概念是「Domain(網域)」。同一份 plist,放在不同位置、載入到不同 domain,權限與生命週期完全不同。這也是「LaunchDaemon 跟 LaunchAgent 到底差在哪」這個老問題的答案來源。

三種 domain 的差別可以這樣理解。system domain 的服務以 root 執行、開機就在、沒有使用者介面,這類服務叫 LaunchDaemon,典型用途是網路服務、系統層背景任務。gui/<uid> domain 綁定「某個使用者已經登入圖形桌面」這個前提,能存取視窗環境,這類服務叫 LaunchAgent,典型用途是選單列小工具、雲端同步程式。user/<uid> domain 則綁定使用者身分但不需要圖形桌面,概念上最接近 Linux 後來才補上的 systemd --user。
plist 放在哪個目錄,就決定它屬於哪個 domain:
| 目錄 | 類型 | 身分/時機 | 誰放的 |
|---|---|---|---|
/Library/LaunchDaemons/ | LaunchDaemon | root,開機即起,無 GUI | 第三方軟體、管理者 |
/System/Library/LaunchDaemons/ | LaunchDaemon | 同上 | Apple 內建,唯讀 |
/Library/LaunchAgents/ | LaunchAgent | 任一使用者登入後 | 第三方軟體、管理者 |
~/Library/LaunchAgents/ | LaunchAgent | 該使用者登入後 | 使用者自己 |
/System/Library/LaunchAgents/ | LaunchAgent | 同上 | Apple 內建,唯讀 |
所以 Daemon 與 Agent 的差別,重點其實不在「是不是常駐」——兩者都可以常駐、也都可以隨需啟動——而在「跑在哪個 domain、有沒有綁定使用者登入」。需要在沒人登入時就運作(例如開機後立刻要起的網路服務),就得是 system domain 的 LaunchDaemon;需要碰到使用者的視窗、剪貼簿、通知中心,就只能是 LaunchAgent。這個判斷選錯,常見症狀是「Agent 寫成 Daemon,結果完全碰不到 GUI」或「Daemon 寫成 Agent,結果使用者沒登入時服務根本沒在跑」。
On-demand:launchd 最核心的設計
如果只能記 launchd 一個設計,那就是 On-demand(隨需啟動)。服務不一定要在開機時就跑,而是「被需要時才被拉起來」,這在 launchd 是一等公民,不是事後補的功能。觸發條件可以是 socket 有連線、某個路徑的檔案變動、到了排程時間,或其他程式透過 IPC 點名要它。
其中最經典的是 socket activation。它的運作方式是這樣:

關鍵在第一步:launchd 在服務還沒跑的情況下,就先替它把 socket 開好並 listen。閒置服務因此完全不占記憶體、開機也不會被一堆「其實沒人用」的服務拖慢。等真的有連線打進來,launchd 才 fork/exec 出實際的服務程序,並把那個已經建立好的 socket file descriptor 透過繼承交給它,服務一啟動就能直接收這條連線。systemd 後來的 socket activation,本質上就是這套設計的再實作。
定時排程則用 StartCalendarInterval,這是 macOS 上取代 cron 的官方做法。它比 cron 多一個重要特性:如果排程時間點電腦正在睡眠,cron 會直接錯過那一次,StartCalendarInterval 則會在電腦醒來後補跑一次。對筆電這種經常闔上蓋子的裝置,這個差別很實際——這也是 Apple 一直建議別再用 cron 的主因之一。
KeepAlive:比 systemd Restart= 更細的條件式重啟
常駐與自動重啟由 KeepAlive 控制。最簡單的寫法是布林值 true,意思是「只要這服務不在,就把它拉起來」,等同一個永遠重啟的 daemon。但 KeepAlive 真正強的地方是它可以是一組條件字典,表達力比 systemd 的 Restart= 那幾個列舉值細很多:
<key>KeepAlive</key>
<dict>
<!-- 只有上一次以非 0 退出(失敗)才重啟;正常結束就放它走 -->
<key>SuccessfulExit</key>
<false/>
<!-- 只有這個檔案存在時才保持服務存活 -->
<key>PathState</key>
<dict>
<key>/var/run/myservice.enabled</key>
<true/>
</dict>
</dict>
上面這個例子的語意是:服務正常結束(exit 0)就讓它結束、不重啟;只有它失敗(非 0 退出)時才拉回來;而且整段重啟邏輯只在 /var/run/myservice.enabled 這個檔案存在時才生效。用一個檔案的存在與否當開關,是 launchd 圈子裡很常見的手法。其他還有 NetworkState(網路通才保持存活)等條件。這種「條件式存活」的細緻度,是 launchd 設計上明顯比早期 systemd 強的地方。
launchctl:新舊語法與除錯
操作 launchd 的指令是 launchctl。這裡有個必須講清楚的歷史包袱:launchctl 有新舊兩套語法,網路上的教學文章混雜得很嚴重,照舊文章操作常常會遇到「指令還能用但行為跟說明不一樣」的情況。
| 動作 | 舊式語法 | 新式語法(建議) |
|---|---|---|
| 載入服務 | launchctl load <plist> | launchctl bootstrap <domain> <plist> |
| 卸載服務 | launchctl unload <plist> | launchctl bootout <domain> <plist> |
| 立刻觸發一次 | launchctl start <label> | launchctl kickstart <domain>/<label> |
| 查看狀態 | launchctl list | launchctl print <domain> |
新式語法強制要指明 domain,例如使用者層級的服務是 gui/501(501 是 id -u 看到的 uid),系統層級是 system。概念上比舊語法乾淨——舊式 load 會自己「猜」要載到哪個 domain,猜錯的後果很難 debug;新式逼開發者把 domain 寫明白。常見的新式操作長這樣:
# 取得自己的 uid(後面的 gui/<uid> 會用到)
id -u
# 假設輸出 501
# 載入並啟動一個使用者層級 LaunchAgent
launchctl bootstrap gui/501 ~/Library/LaunchAgents/tw.klab.backup.plist
# 查看這個服務目前狀態、上次 exit code、為什麼沒被拉起來
launchctl print gui/501/tw.klab.backup
# 不等觸發條件,手動踹它跑一次(-k 代表先砍掉再重跑)
launchctl kickstart -k gui/501/tw.klab.backup
# 改完 plist 後要重載:先 bootout 再 bootstrap,不能只 bootstrap
launchctl bootout gui/501 ~/Library/LaunchAgents/tw.klab.backup.plist
launchctl bootstrap gui/501 ~/Library/LaunchAgents/tw.klab.backup.plist
除錯時 launchctl print 是最該先打的指令,它會印出服務當下狀態、最後一次的 exit code、以及 launchd 認為它為什麼該或不該在跑。配合 plist 裡設好的 StandardOutPath 與 StandardErrorPath,九成的「服務沒反應」都查得出來。注意:改完 plist 不會自動生效,一定要 bootout 再 bootstrap 重載一次,這是另一個從舊 load 習慣過來的人很常忘的點。
2026 年的最新變化
launchd 的核心模型十幾年沒變,但周邊這幾年有幾個方向性的調整,2026 年寫的程式應該知道:
SMAppService 取代手動丟 plist。對 App 開發者來說,Apple 從 macOS 13 起推的 SMAppService API(取代更早的 SMJobBless 與舊式 login items),到 2026 年已是註冊背景服務與開機項目的標準做法。它的本質還是 launchd——plist 仍在,只是改成打包進 App bundle 裡,由系統代為註冊與管理,使用者也能在「系統設定 → 一般 → 登入項目」直接看到並關掉。對單純自己用的腳本,手寫 plist 丟進 ~/Library/LaunchAgents/ 仍然完全可行;但如果是要散佈給別人的 App,2026 年的正解是 SMAppService。
唯讀系統卷與 cryptexes。從 macOS Big Sur 的 Signed System Volume 開始,/System/Library/LaunchDaemons/ 這類路徑位於加密簽章的唯讀卷上,無法手動塞東西進去;Apple 自家的部分系統元件還會透過 cryptex(可加密掛載的系統擴充)在開機時動態疊上來。實務上的結論很簡單:自訂服務一律放 /Library/...(沒有 System)或家目錄底下的對應目錄,/System/... 是 Apple 的地盤、碰不得也不該碰。
舊語法持續被冷處理。launchctl load/unload 到 2026 年仍可用,沒有被移除,但 Apple 文件與 man 已全面改用 bootstrap/bootout 描述,新功能(如精細的 domain target)也只在新語法上。換句話說舊語法不會馬上壞,但新寫的東西沒有理由再用舊的。
動手做:一個每天定時跑的 LaunchAgent
把前面的概念串起來,做一個實際的例子:每天凌晨 3 點 30 分跑一次備份腳本,跑失敗才重試,輸出寫到 log 方便事後查。先寫 plist,存成 ~/Library/LaunchAgents/tw.klab.backup.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>tw.klab.backup</string>
<key>ProgramArguments</key>
<array>
<string>/Users/kyle/bin/backup.sh</string>
</array>
<!-- 每天 03:30 觸發;睡眠中錯過會在喚醒後補跑 -->
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>30</integer>
</dict>
<!-- 只有失敗(非 0 退出)才重啟,正常跑完就放它結束 -->
<key>KeepAlive</key>
<dict>
<key>SuccessfulExit</key>
<false/>
</dict>
<!-- 把輸出導去 log,除錯靠這兩行 -->
<key>StandardOutPath</key>
<string>/Users/kyle/Library/Logs/klab-backup.log</string>
<key>StandardErrorPath</key>
<string>/Users/kyle/Library/Logs/klab-backup.err.log</string>
</dict>
</plist>
接著載入並驗證:
# plist 語法檢查,有錯會直接指出哪一行
plutil -lint ~/Library/LaunchAgents/tw.klab.backup.plist
# 載入到自己的 gui domain(501 換成 id -u 的結果)
launchctl bootstrap gui/501 ~/Library/LaunchAgents/tw.klab.backup.plist
# 不等到凌晨,先手動踹一次確認腳本本身沒問題
launchctl kickstart -k gui/501/tw.klab.backup
# 看狀態與上次 exit code
launchctl print gui/501/tw.klab.backup | grep -E "state|last exit"
# 確認沒問題後,看 log 驗證輸出
tail -f ~/Library/Logs/klab-backup.log
如果 kickstart 後服務沒動靜,依序檢查三件事:plutil -lint 有沒有過、backup.sh 有沒有 chmod +x 且第一行 shebang 正確、以及 .err.log 裡是不是有「command not found」——後者幾乎都是前面提過的 PATH 問題,在腳本開頭補一行 export PATH=/usr/local/bin:/usr/bin:/bin 之類的就解決了。
該用哪一種,以及什麼時候不該用 launchd
把判斷收斂成幾條實用建議。要散佈給別人的 App 背景服務,2026 年直接用 SMAppService,別自己手動丟 plist。需要在沒人登入時就運作的系統服務,用 /Library/LaunchDaemons/ 下的 LaunchDaemon。只有自己這台機器要定時跑的腳本,手寫一個 plist 丟進 ~/Library/LaunchAgents/ 最簡單直接,沒必要為它包一個 App——之前寫的 macOS CapsLock 延遲解法 就是這種用法的實例。
反過來說,也有不該動用 launchd 的情況。如果只是想「現在跑一個一次性的長時間任務」,caffeinate 加一個背景行程就夠了,寫 plist 反而是過度設計。如果排程邏輯很單純、又只是個人臨時用途,crontab 在 macOS 其實還沒被拿掉,老實用也沒什麼不可以——只是要清楚它在睡眠時會漏跑,這個限制能接受就行。launchd 的價值在「需要被系統穩定託管、有條件式啟動或重啟需求」的場景;任務夠簡單時,硬套一份 plist 只是徒增維護成本。把工具用在它真正擅長的地方,比什麼都套 launchd 更實際。