☰
零轮询事件驱动架构:Insomnia 用 IOKit 通知实现合盖检测的性能设计解析
2026/10/15 18:06:08 网站建设 项目流程

【免费下载链接】Insomnia

A macOS menu bar app that safely keeps your MacBook awake with the lid closed, for timed agent sessions.

项目地址:https://gitcode.com/gh_mirrors/insomnia6/Insomnia
点击查看免费下载

Insomnia 是一款 macOS 菜单栏应用,它让 MacBook 在合上盖子后继续保持工作,专为定时 Agent 会话设计。它的合盖检测采用零轮询的事件驱动设计:不每隔一秒去查询盖子状态,而是向系统注册 IOKit 兴趣通知,由内核在盖子真正变化时才唤醒应用。下面按「为什么轮询浪费、通知如何实现、事件之后做什么」的顺序,解析这套性能设计。

为什么"每秒查一次"是菜单栏应用的反模式

Insomnia 是常驻菜单栏的小应用:没有 Dock 图标、没有主窗口,大部分时间安安静静地待在那里。如果用它来检测合盖,直觉做法是开个定时器,每秒读一次盖子状态。问题在于:

  • 合盖会话可以持续数小时,而盖子状态 99% 的时间不变——定时器却每秒空转一次;
  • 轮询间隔是"延迟"和"开销"的权衡:1 秒间隔意味着最坏 1 秒的响应延迟,想缩短间隔只会让空转更严重;
  • 菜单栏应用对 CPU 占用极其敏感,持续轮询会放大电量消耗。

项目的设计文档把这个原则写得很直白:

Prefer OS events for live observations; recovery retries and countdown redraws require bounded recurring work.

也就是说:实时观测优先用操作系统事件;只有恢复重试和倒计时重绘这类工作,才允许"有上界"的周期性轮询。这是全文所有设计决策的总纲,见 docs/spec.md。

合盖检测第一步:让内核主动"打电话"

Insomnia 的合盖观察者 LidObserver 的核心思路,可以用一句话概括:应用睡在 RunLoop 里等消息,盖子一变,内核把它叫醒。启动过程分三步(LidObserver.swift):

  1. 找到电源管理域。通过IOServiceMatching("IOPMrootDomain")定位内核中的电源管理根服务——合盖状态就记录在这个服务的属性里;
  2. 创建通知端口并挂到主线程。用IONotificationPortCreate创建通知端口,取出它的 RunLoop Source 后加入主 RunLoop(.commonModes),这样内核消息到来时会自然地切回主线程;
  3. 注册兴趣通知。调用IOServiceAddInterestNotification订阅kIOGeneralInterest——含义是"这个服务的任何属性变了都叫我",而盖子的开合正对应AppleClamshellState这个属性的翻转。

注册完成后,两个事件之间应用内没有任何代码在运行:没有定时器、没有后台线程空转。设计文档的原话是 "Zero cost between events"(事件之间零成本)。

消息到达后,观察者并不会直接相信消息本身,而是重新读取一次 I/O Registry 里的真实值:readClamshellState 是一个纯读取函数——盖关返回true,盖开返回false,读不到(比如台式机没有盖子)返回nil。这种"通知触发 + 以实际读数为准"的组合,让事件只当闹钟用,状态判断始终基于事实。

第二步:2 秒防抖,把"盖子抖动"压成一次事件

物理盖子是会抖的:用户抬到一半又放下、合盖瞬间的机械回弹,都可能在几百毫秒内产生"开→关→开"的多次状态翻转。如果每次翻转都触发动作,用户会把屏幕暗掉、应用冻结这套动作反复执行几遍。

Insomnia 的对策是2 秒防抖(handleInterest):

  • 每次内核通知到达,先重读当前盖子状态;
  • 只有当"新状态 ≠ 上次已交付的状态"时,才记为一个待确认的变更,并启动 2 秒计时器;
  • 2 秒后状态仍然不同,才真正把这次变更交付给上层(settle);
  • 如果状态在 2 秒内又翻回了原来的值,这次变更被当作"抖动"丢弃,只记一条change flapped within 2 s, ignored日志。

还有一个容易被忽略的细节:whenSettled 机制。它允许后续要基于盖子状态做事的调用方"等一等"——如果有变更还在 2 秒防抖窗口里,就等它落定再行动。这保证了业务逻辑永远不会基于一个即将被替换的状态做决策(比如盖子正在合到一半,不该提前触发"恢复亮度")。

不止盖子:一套统一的零轮询事件源

防抖只是合盖链路的一环。Insomnia 把同一套"能靠事件就绝不轮询"的哲学应用到了所有系统信号上,由 PowerMonitor 等模块各自订阅:

信号事件源如何避免轮询
盖子开合IOKit 兴趣通知(IOPMrootDomain/AppleClamshellState)内核主动上报,事件之间零成本
电池电量 / 充电状态IOPSNotificationCreateRunLoopSource电量每变化一次上报一次
热状态(thermal state)ProcessInfo.thermalStateDidChangeNotification系统广播变化通知
Wi-Fi 链路状态NWPathMonitor事件驱动,掉线/恢复各触发一次
瞬时功率(瓦数)仅在菜单打开时读一次instantWatts()源码注释明确写着 "never polled"(永不轮询)

设计文档第 6 节(docs/spec.md)列出了电池与热状态规则的完整事件源清单,而瓦数只在你打开菜单那一瞬间读取——PowerMonitor.swift 顶部的注释把这层意图写得非常清楚。

什么时候才允许轮询:30 秒兜底重读

零轮询架构里留了一个刻意的"例外":当电池读数持续失败时,每 30 秒重读一次电源列表(scheduleRecheckIfUnreadable)。

原因很实际:IOKit 只会在"状态发生变化"时发事件,对于"一直读不到"这种失败,内核根本不会发任何事件。如果不主动重读,一台电池传感器故障的笔记本将永远收不到"读数不可用"的第二次确认,也就无法触发"连续两次不可读就结束会话"的安全规则。

注意这个轮询的三重约束:只在异常状态下开启、有 30 秒的明确间隔、电池恢复可读立即停止。这就是总纲里"bounded recurring work"(有上界的周期性工作)的标准写法。

同类受控轮询还有两处:

  • 倒计时屏幕重绘:1 Hz 的定时器,但盖子合上后重绘定时器立即停掉——计时器本身继续走,只是不再刷屏幕(见 docs/lid-close.md);
  • 亮度采样:每 30 秒采一次屏幕/键盘亮度,且只在盖子打开时采样(合盖后的读数是压暗后的 0,没有参考价值),见 AppServices.swift 的sampleTimer与 sampleBrightnessIfLidOpen。

事件到达之后:编号的、可逆的动作链

检测到了"盖子确实合上了",接下来做什么?Insomnia 的答案是一条固定、可逆、带编号的动作链(LidActions):

合盖时(默认行为,详见 docs/lid-close.md):

  1. 保存屏幕与键盘亮度,然后调为 0(省电);
  2. 保存音量并静音音频输出;
  3. 冻结冻结清单里的应用(SIGSTOP),Agent 应用保持运行;
  4. 若开启 Docker 规则且容器空闲,暂停 Docker Desktop;
  5. 停掉倒计时重绘。

开盖时:按相反顺序、从 journal(磁盘上的state.json)逐条撤销——恢复亮度、恢复音频、SIGCONT恢复进程。注意撤销永远读磁盘 journal 而不是内存,这样即使应用中途崩溃过,重启后也能正确回滚。

另一个精巧的设计是事件编号:每个盖子事件在到达瞬间就取一个序号(lidEventArrived),动作真正执行前会再核对一次——如果排队期间盖子已经开盖了,这条"合盖动作"就作废(onClose 里的lidEvents == event检查)。这防止了"盖在 2 秒防抖窗口内合上又打开"时,残留的合盖任务还在执行冻结和调暗。

还有一条重要边界:没有会话时,盖子事件什么都不做。观察者的启停与生命周期严格绑定会话——会话开始时 lid.start(),会话结束时 lid.stop() 拆掉通知端口和 RunLoop Source。零成本不只是"事件之间不轮询",更是"不需要时根本不监听"。

如何验证:可注入的读取函数 + 模拟合盖脚本

这套设计最可贵的地方是可验证性,两个层面:

单元测试不依赖真硬件。LidObserver的 registry 读取函数和防抖时长都是构造参数注入的,测试用一个假读取函数模拟内核消息:LidObserverTests 覆盖防抖交付、抖动丢弃、whenSettled等待语义、stop()丢弃残留等待者等全部时序分支——全程不碰 IOKit。

真机上可以"假装合盖"。scripts/simulate-lid.sh 往支持目录写一个触发文件,文件观察者收到后走与真实盖子完全相同的动作路径,方便在盖子不便打开的机器上跑发布验证。而 LidSimulation 用编译开关保证这个后门只存在于 debug 构建(或显式带INSOMNIA_LID_SIMULATION=1安装的构建)——普通发布版本里连文件观察者都不存在,普通用户的机器上没有任何程序能靠写文件重放合盖动作。

总结:一份事件驱动性能设计清单

把 Insomnia 的合盖检测抽象出来,就是一张可以迁移到任何常驻应用的设计清单:

  1. 先问"系统能不能主动通知我"——IOKit 兴趣通知、电源事件、热状态通知、NWPathMonitor,能订阅就不轮询;
  2. 物理信号加防抖——2 秒窗口确认状态稳定,抖动直接丢弃;
  3. 用"落定"接口隔离时序——whenSettled让下游永远不基于即将过期的状态做决策;
  4. 动作链编号 + 可回滚——事件到达即编号,执行前核对序号,所有副作用先写 journal 再落盘;
  5. 轮询只留给异常兜底——30 秒电池重读、1 Hz 倒计时重绘、30 秒亮度采样,每一项都有明确的上界和停止条件;
  6. 观测器按需启停——无会话不监听,把零成本做到极致;
  7. 注入 + 编译开关保证可测性——假读取函数跑单测,模拟脚本验真机,后门不进发布版。

这套"零轮询事件驱动"的设计,让一个要安静待数小时的菜单栏应用,在事件之间真正做到零开销——这正是 Insomnia 能把合盖会话撑到最长 30 天,而不被自己的检测机制拖垮电池的原因。

【免费下载链接】Insomnia

A macOS menu bar app that safely keeps your MacBook awake with the lid closed, for timed agent sessions.

项目地址:https://gitcode.com/gh_mirrors/insomnia6/Insomnia
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询