0%

解决 macOS 上 lsd 与 syspolicyd 大量消耗 CPU 资源的问题

手头这台 2020 款 MacBook Pro 用的是 Intel 的 i5-1038NG7,4 核 8 线程,16 GB 内存,系统是 macOS 15.7.3。最近发现,手上的活并不重,风扇却一直在狂转。用 top 看了一眼,load average 在 10 到 17 之间,排在前面的是 lsd、syspolicyd、trustd、duetexpertd 这几个系统进程,没有一个是我自己打开的应用。其中 lsd 长时间稳定在 50% 上下。

这台机器已经连续运行了 74 天,我不太想重启它;Setapp 也还在用,不打算卸载。所以下面的处理都没有重启系统,也没有卸载任何软件。把 CPU 压下来以后,我又回头翻了一遍导出的数据,想弄清楚三件事:问题是怎么来的,下次怎么一眼认出来,以及能不能让它不再发生。

前置知识

LaunchServices 与 lsd

LaunchServices 是 macOS 里负责登记应用程序的服务。系统要知道每个 App 装在哪里、bundle ID 是什么,以及它能打开哪些类型的文件,查的都是 LaunchServices 维护的这份数据库。Dock、Finder 这些组件,以及 Siri 建议之类的后台服务,都会向它查询。lsd 是这份数据库的守护进程,条目的增删都由它完成。

从这次的数据来看,一个 App 被注册进去以后,即便它所在的目录后来被删掉或者被推出,条目也会一直留在数据库里。系统的各个服务在查询时才会发现文件不在了,此时返回的错误码是 -43,即 fnfErr(文件不存在)。

lsd 注册 bundle 的那段时间里,日志中夹杂着大量 SecTrustEvaluateIfNecessary 与 SecKeyVerifySignature 调用,也就是在校验代码签名。syspolicyd 的日志里是同样的调用,trustd 则是负责证书信任评估的守护进程。后文可以看到,lsd 停下来之后,syspolicyd 与 trustd 也一起从 CPU 排行里消失了。

lsregister

操作这份数据库的命令行工具是 lsregister。它不在 PATH 里,位置很深:

1
LSREG=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister

下文会用到它的这几个参数:

  • -dump:导出数据库的全部内容
  • -u:注销指定路径
  • -f:强制重新注册,即使修改时间没有变化
  • -gc:回收旧数据,并压缩数据库
  • -kill:先清空数据库,再做后续动作

排查与处理

两种异常

top 只能告诉我们谁在忙,告诉不了它在忙什么。要知道 lsd 在忙什么,得去翻统一日志(unified log)。日志里有两种反复出现的东西。

第一种是 lsd 自己的注册记录(删去了时间戳与线程号):

1
2
3
4
lsd[412] [com.apple.appinstallation:TransactionLog] (null)/Unknown Persona:5:4:1:0:_LSServerRegisterItemInfo begin
lsd[412] [com.apple.appinstallation:TransactionLog] com.setapp.DesktopClient/Unknown Persona:5:5:1:1:Building bundle record for app
lsd[412] [com.apple.launchservices:default] registered <private> (status 0, old id 0x2dee84) as unit 0x2dee88 on behalf of pid 415
lsd[412] [com.apple.launchservices:default] client 75950 will unregister unit 2dee88 options 0

lsd 先替 PID 为 415 的进程注册了 Setapp(com.setapp.DesktopClient),记成 unit 0x2dee88;7 毫秒之后,PID 为 75950 的进程又要求注销这个 unit。415 是 Dock,75950 是 SetappAgent。两分钟里,这样的注册有 693 次,平均每秒接近 6 次。SetappAgent 自己的日志里则不断刷出 Failed to get unit … from store,后面的编号一直在增长。

第二种是别的进程报出的 -43,例如 siriknowledged:

1
siriknowledged[645] [com.apple.launchservices:default] _LSBundleCopyOrCheckNode: cached node not found, _LSBundleCreateNode for bundleID 60316 returned -43

按进程统计,10 秒内 siriknowledged 报了 1488 条,duetexpertd 454 条,swcd 262 条,iCloud Drive 的守护进程 bird 163 条。此前一次 30 秒的统计是 6098 条,涉及 265 个不同的 bundleID。

这两种异常的统计命令,放在后面「识别」一节里。

死条目

为了知道 -43 指向哪些 bundle,先把数据库导出来:

1
"$LSREG" -dump > /tmp/lsdump.txt

导出的文件有 23 MB、26 万多行。而后逐条检查每个 path: 还在不在,具体命令放在「识别」一节。需要注意,path: 行的末尾跟着条目的 ID,形如 /Applications/Setapp.app (0x2f9f30),要先去掉,否则所有路径都会被判为不存在。

1455 个不重复的路径里,有 337 个已经不存在。下文把这类指向不存在路径的条目称作死条目。

我先试了最温和的 -gc,10 秒内的 -43 从 2924 条降到 1749 条,只清掉了一部分。而后对着死条目清单逐条执行 lsregister -u,337 条里有 312 条返回成功。15 秒后再统计,10 秒内的 -43 只剩 1 条。

注册循环

-43 没了,lsd 却还在以 10 秒 73 次的频率注册,CPU 依然在 50% 上下,syspolicyd 也还在 40%。最大的两个来源仍是 Dock 和 SetappAgent。排查之初我退出过一次 Setapp,可 SetappAgent 换了一个 PID 又跑了起来,它的 LaunchAgent 里配置了 KeepAlive,退出 Setapp 拦不住它。

用 -f 强制重新注册 /Applications/Setapp.app 之后,循环没有立刻停下。几分钟后又重启了 Dock:

1
2
# Dock 会被 launchd 立刻拉起
killall Dock

十秒之后,lsd 的注册次数是 0,SetappAgent 的 Failed to get unit 也是 0。lsd、syspolicyd、trustd 从 CPU 排行里消失了,load average 从最高时的 20.37 降到了 4.27。几个小时后再看,5 分钟内 lsd 一次注册也没有,-43 只有 1 次,没有复发。

成因

回头看,这次其实是两个问题叠在了一起:死条目带来的 -43,以及 Setapp 引起的注册循环。前者把 siriknowledged、duetexpertd 这些进程拖进来,后者让 lsd、syspolicyd、trustd 一直空转。两者要分开解释。

死条目从哪来

先看死条目是什么时候注册进去的。lsregister -dump 给每个条目都记了一个 reg date 字段,把 337 个死条目按注册月份统计:

1
2
3
4
5
2025-07: 43    2025-08: 28    2025-09:  9    2025-10: 13
2025-11: 17 2025-12: 18 2026-01: 35 2026-02: 5
2026-03: 20 2026-04: 16 2026-05: 13 2026-06: 15
2026-07: 18 2026-08: 27 2026-09: 15 2026-10: 23
2026-11: 2 无 reg date: 20

从 2025 年 7 月到 2026 年 11 月,几乎每个月都有十几到几十个。也就是说,死条目是在一年多里慢慢攒下来的,不是哪一次操作一下子造出来的。

再按路径归类,可以看出它们来自几种很平常的日常操作:

  • Chrome 自动更新:93 个。Chrome 把每个版本放在 Google Chrome Framework.framework/Versions/<版本号>/ 下,更新后删掉旧版本目录。死条目里有 41 个不同的版本号,从 137 一直到 153,而目录里现在只剩 155 与 156
  • Sparkle 的临时目录:66 个。Sparkle 是很多 macOS 应用用来自动更新的框架,它把下载、解压出来的新版本先放在 ~/Library/Caches/<应用>/org.sparkle-project.Sparkle/ 下面一个随机命名的子目录里,装完就删。钉钉占 26 个,Setapp 占 35 个
  • 安装盘:57 个。安装 App 时双击 DMG,挂载出来的卷里的 App 也会被注册,推出卷之后条目还在。像素蛋糕从 8.0.1 开始的每一个版本都留下了几个,最早的一个注册于 2025-08-17
  • Homebrew 升级:46 个,基本都是 /usr/local/Cellar/python@3.x 的旧版本
  • 手工备份:16 个,例如 pixcake.app.bak-10.0.5-156

这几类有一个共同点:App 所在的目录本来就是临时的,用完即删,而 LaunchServices 并不跟着清理。条目上的标记也印证了这一点。数据库里一共 1460 个条目,按路径在不在分成 339 个死条目和 1121 个有效条目(339 比前面的 337 多两个,是因为有两个路径各对应两个条目)。quarantined 标记在死条目里占 30%,在有效条目里只占 1%;launch-disabled 是 49% 对 10%。前者表示文件来自网络下载、还没有被放行过,后者表示不允许从这个位置启动,安装盘与更新包里的 App 多半带着这两个标记。

单个死条目查询到的时候,返回一个 -43 就结束了。可有些服务会把整个数据库枚举一遍,从 siriknowledged 这个名字看,它要知道系统里装了哪些 App。死条目一多,每次枚举都要报几百个 -43;如果枚举又被频繁地触发,CPU 就降不下来。

枚举为什么被频繁触发

至于是什么在反复触发枚举,我手上只有一条间接的线索。SetappAgent 的日志里,夹在 Failed to get unit 之间反复出现的是这样一行:

1
SetappAgent[75950] [com.apple.launchservices:db] NotifyToken::RegisterDispatch(user.uid.501.com.apple.LaunchServices.database) fired for session key <private>

从名字看,这是 LaunchServices 数据库发生变化时发出的通知,日志里每秒好几次。数据库每变一次,订阅了这个通知的进程就可能重新查询一遍,而那个每秒 6 次的注册循环会让数据库不停地变。所以这两个问题可能是相互放大的:注册循环让数据库不停地发通知,通知让其他服务不停地枚举,枚举时又撞上几百个死条目。不过我没有直接验证这一点,siriknowledged 是不是因为这个通知才去枚举,并没有证据。

注册循环从哪来

注册循环的起点比较清楚。/Applications/Setapp.app 与 ~/Library/LaunchAgents/ 下 Setapp 的四个 plist,修改时间都是前一天晚上 21:12,Setapp 当时更新过自己。

关键的证据在两份导出的数据库里。一份是排查死条目时,上午 11:24 导出的 /tmp/lsdump.txt;另一份是 11:37 用 -f 补注册之后,我又导出的 /tmp/lsdump2.txt。前一份里,/Applications/Setapp.app 根本不在数据库里:

1
2
grep -c 'path:[[:space:]]*/Applications/Setapp.app (' /tmp/lsdump.txt    # 11:24 导出
grep -c 'path:[[:space:]]*/Applications/Setapp.app (' /tmp/lsdump2.txt # 11:37 导出,-f 之后

前者是 0,后者是 1。也就是说,Setapp 更新之后,主程序的注册丢了。数据库里倒是还有 ~/Library/Application Support/Setapp/LaunchAgents/ 下的 SetappAgent 等几个辅助程序。SetappAgent 自己的日志里写着,bundle 的修改时间「changed from Fri Sep 25 10:31:27 2026 to Fri Nov 6 21:12:41 2026」,随后报 Failed to get unit。

据此可以推测出这样一个过程:Dock 要找 com.setapp.DesktopClient,在数据库里找不到,于是请 lsd 注册一次;紧接着 SetappAgent 把这个 unit 注销掉;Dock 再次找不到,再请 lsd 注册。SetappAgent 为什么要注销,日志里没有交代。这段时间里 lsd 的签名校验没有停过,syspolicyd 与 trustd 也一直在忙。这个循环是从什么时候开始的,我不知道,日志里只能确认 Setapp 前一天 21:12 改写过自己的文件。

这个推测能解释为什么单靠 -f 不够:-f 把主程序补回了数据库,但 Dock 可能还留着旧的状态,所以要重启 Dock 才停。不过 -f 与重启 Dock 之间隔了几分钟,我只做了这一次,没法分开验证两者各自起了多大作用。至于 Setapp 更新时为什么会丢掉主程序的注册,我没有查到原因。

识别

把这次的经验收拢一下,以后再遇到类似的情况,可以按下面的顺序判断。

首先看 CPU 排行的组合。占用高的如果是 lsd,同时 syspolicyd、trustd 也在前面,而用户自己打开的 App 都不怎么忙,那么就该怀疑 LaunchServices 在反复注册。如果排在前面的是 siriknowledged、duetexpertd、swcd 这类平时安静的后台服务,就要怀疑它们在枚举时撞上了死条目。

而后统计两个指标。一个是 lsd 的注册频率,一个是 -43 的频率:

1
2
3
4
5
6
7
# 指标 1:一分钟内 lsd 的注册次数
log show --last 1m --predicate 'process == "lsd" AND eventMessage CONTAINS "_LSServerRegisterItemInfo begin"' --style compact \
| grep -c begin

# 指标 2:一分钟内各进程报出的 -43 次数
log show --last 1m --predicate 'eventMessage CONTAINS "returned -43"' --style compact \
| awk 'NR>1 {print $4}' | sed 's/\[.*//' | sort | uniq -c | sort -rn

作为参照,这台机器在正常状态下,5 分钟内 lsd 一次注册也没有,-43 也只有 1 次。出问题的时候,前者是一分钟三百多次,后者是一分钟一万多条。两种状态差得很远,不需要精确的阈值也能分辨出来。

如果是注册频率高,就看是谁在发起注册。lsd 的日志里写着每次注册是替哪个进程做的:

1
2
log show --last 1m --predicate 'process == "lsd" AND eventMessage CONTAINS "on behalf of pid"' --style compact \
| grep -oE 'on behalf of pid [0-9]+' | sort | uniq -c | sort -rn

找出 PID 以后用 ps -p <PID> 看进程名。若同一个 App 被反复注册,就去检查它的主程序在不在数据库里:

1
"$LSREG" -dump | grep -c 'path:[[:space:]]*/Applications/Setapp.app ('

如果是 -43 多,就把死条目数出来:

1
2
3
4
5
6
7
8
# 导出数据库,取出全部路径,去掉行尾的 (0x…)
"$LSREG" -dump | grep '^[[:space:]]*path:' \
| sed -E 's/^[[:space:]]*path:[[:space:]]*//; s/[[:space:]]*\(0x[0-9a-fA-F]+\)[[:space:]]*$//' \
| sort -u > /tmp/all_paths.txt

# 筛出已经不存在的路径
while IFS= read -r p; do [ -e "$p" ] || echo "$p"; done < /tmp/all_paths.txt > /tmp/dead_paths.txt
wc -l < /tmp/dead_paths.txt

这次的数据是 1455 条里 337 条死掉,接近四分之一。这个比例在正常机器上应该是多少,我没有别的机器可以对照。

预防

死条目来自 Chrome 更新、Sparkle 更新、安装盘、Homebrew 这些日常操作。这些操作本身没法不做,能做的是让死条目少一点、发现得早一点。可以做的有三件事。

第一,装完 App 以后,顺手注销安装盘上的 App。推出安装盘的那一刻,盘上 App 的条目就变成了死条目,所以单是推出并不够。按道理,挂载期间先注销、再推出就可以,不过这个顺序我还没有实际试过:

1
2
3
# 推出之前,先把卷上的 App 从数据库里注销(未实测)
"$LSREG" -u "/Volumes/<卷名>/<App 名>.app"
diskutil unmount "/Volumes/<卷名>"

已经推出了的,可以按上一节的办法统一清理,这次实测有效:

1
2
# 注销 /Volumes 下所有已经不存在的条目
grep '^/Volumes/' /tmp/dead_paths.txt | while IFS= read -r p; do "$LSREG" -u "$p"; done

一个 App 往往不止一个条目,例如 Cursor 的安装盘里,除了 Cursor.app,还有四个 helper 被分别注册。所以用后一种办法清理更省事。

第二,定期跑一遍上一节的统计。按注册时间算,这台机器上每个月都有十几到几十个条目后来变成了死条目,几个月清一次应该就够了。清理时用 -u 逐条注销。-kill 会清空整个数据库再重新扫描,影响到所有的 App,而这次的问题只涉及几百个已经列出来的条目,用不着它。

第三,自动更新的 App 出问题时,先确认它的主程序还在不在数据库里。这次 Setapp 的问题就出在这里。若不在,用 lsregister -f 补注册;若补了以后循环还在,再重启 Dock。

注意,判断条目是否失效,依据只有「路径在不在」。外接硬盘上的 App 在硬盘没接上的时候,也会被判为不存在。批量注销之前,应当先把 dead_paths.txt 过一遍,把这类路径挑出来。

俗话说,投资效率是最好的投资。 如果您感觉我的文章质量不错,读后收获很大,预计能为您提高 10% 的工作效率,不妨小额捐助我一下,让我有动力继续写出更多好文章。