上一篇 人应该主动去适配 AI 任务执行范式 里,我把一块 Elgato Stream Deck Mini 的输入循环从轮询改成了阻塞读,空闲 CPU 从 3% 掉到 0.4%。合上电脑前我给自己留了一道题:这个结论,是那块板子的特例,还是普适的?
第二天我桌上换了一块设备。Mirabox StreamDock 293,15 个按键(5 列 × 3 行),在 USB 上就是个标准 HID 设备(5500:1001)。和 Mini 最大的不同是:它用的是 厂商自己的 StreamDock Device SDK。我本来只想复刻一遍那个改造——把输入从轮询改成阻塞读。
结果第一步就卡住了:这版 SDK 的读取线程,本来就是阻塞读。
我拿 AI 十几分钟搭出来的 mybox 面板(把一块 Arduino VENTUNO Q 开发板的 CPU / GPU / NPU / DDR / WiFi 温度、风扇、功耗、内存、负载、网络速率打在 15 个键上,5 秒刷一次),跑起来后 top 依旧把最费 CPU 的位置留给了它自己。一个监控工具,又一次成了最该被监控的对象。
这逼我修正了上一篇的结论:「轮询 vs 阻塞」不是省 CPU 的分界线,「空闲时到底有几个东西会把你叫醒」才是。 换掉轮询只是删掉了其中一个唤醒源;只要还有定时器、超时、周期重扫,常驻程序就一样在空转。
一、先归因:一块常驻面板,空闲时在等谁?
一块监控面板的逻辑很简单:每 5 秒采一次数,只在数值变化时重画。理想情况下,两帧之间它应该彻底睡着,CPU 应该趋近于 0。
但机器不会无缘无故耗电,一定有东西在定时醒来。把 mybox 的线程和调用点摊开,空闲时能把进程叫醒的其实只有四处:
一块常驻面板,空闲时谁在叫醒它?
┌──────────────────────────────────────────────────────────────┐
│ ① 读按键 reader 线程每个超时周期从内核醒来(SDK 固定 100ms) │
│ ② 发现设备 每 1s 把所有 USB / HID 设备重新枚举一遍(SDK listen)│
│ ③ 刷新画面 每次采样全量重绘 + 写临时文件 + 全量 refresh() │
│ ④ 取远端 每个周期重新起进程 / 重连 SSH(早期版本) │
└──────────────────────────────────────────────────────────────┘
↑ 省 CPU == 把这张表逐行清零
# generated by hugo AI
上一篇我只盯着 ①。这次拆完才发现,真正的大头在 ②,而它 跟「轮询还是阻塞」毫无关系。
二、唤醒源审计:先列清单,再谈优化
我把这次用到的排查动作总结成一个可复用的检查,叫 唤醒源审计:优化任何常驻程序之前,先列出它 空闲时所有可能醒来的来源,再对每一个问三个问题——它为什么会醒?醒来的频率是多少?能不能改成事件驱动,或者干脆删掉?
| 唤醒源 | 默认实现 | 空闲唤醒频率 | mybox 的做法 |
|---|---|---|---|
| ① 读设备 | 读取线程带 100ms 超时的阻塞读 | 10 次 / 秒 | 自管 reader,超时 1000ms → 1 次 / 秒 |
| ② 发现设备 | DeviceManager.listen() 每秒重枚举 HID | 1 次 / 秒(但每次很重) | udev 事件驱动,空闲 ≈ 0 |
| ③ 刷新画面 | 每周期全量重绘 + 落临时 JPEG | 每采样周期 | 只重绘变化 + 内存内编码 + 批量 refresh |
| ④ 取远端 | 每周期 shell 循环 cat(约 100 次 fork) | 每采样周期 | 单个 awk + SSH ControlMaster 复用 |
这张表里最重要的不是数字,而是一个反直觉的事实:「唤醒频率低」不等于「代价低」。 ② 每秒只醒一次,但每次都要把全部 VID / PID 枚举一遍,比 ① 每秒醒十次还贵。只盯读线程,就会把最肥的一头漏掉。
三、第一个反直觉:SDK 已经阻塞读了,却还在 10Hz 醒来
先看 SDK 的读取线程。它在 StreamDock.py 里是个朴素的循环:
def _read(self):
while self.run_read_thread:
arr = self.read() # -> transport.read_(1024)
if arr is not None:
self._handle_raw_read(arr)
if self._is_input_event_packet(arr):
event = self.decode_input_event(arr[9], arr[10])
...
# generated by hugo AI
而 read_() 最终落到 C transport 的 transport_read(..., 100)——注意那个 100:
result = _transport_lib.transport_read(
handle,
buffer,
ctypes.byref(length),
100, # Use a 100ms timeout for polling to avoid long blocking
)
# generated by hugo AI
这不是轮询——线程睡在 内核的 USB 传输里,不消耗 CPU。但它的超时是 100ms,意味着 哪怕设备一整晚没有任何按键,它也会每秒醒来 10 次。设备 99.9% 的时间都是静默的,我们却为「什么都没发生」付了 10 次 / 秒的唤醒。
把超时从 100ms 拉到 1000ms,语义完全不变,只是把闹钟调稀:
SDK reader : transport_read(..., 100) 醒来 10 次 / 秒
mybox reader: transport_read(..., 1000) 醒来 1 次 / 秒
│
└─ 按键变化时内核立刻返回 —— 延迟不变,只是「没事时少醒几次」
# generated by hugo AI
轮询和带超时的阻塞读,区别在于:轮询是 没事也主动去查设备,超时阻塞是 设备没事时内核让你睡着,超时只是兜底醒来。前者该删掉,后者只是把兜底闹钟调稀一点。
实测这一项:SDK 的 100ms reader 约 0.6% 单核,自管 1000ms reader 约 0.4% 单核。
四、最大的唤醒源不在读取,而在「发现设备」
mybox 一开始照 SDK 示例用 DeviceManager.listen() 做热插拔。看它的 Linux 实现(DeviceManager.py):
def _listen_linux(self, products):
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by(subsystem="usb")
while True:
device = monitor.poll(timeout=1) # 1 秒超时
if device is None:
self._remove_missing_devices(products) # 超时也全量重扫
self._add_missing_devices(products) # 每秒枚举所有 USB/HID
continue
self._handle_device_event(device.action, device, products)
# generated by hugo AI
它虽然用了 pyudev,但 没有利用事件:poll(timeout=1) 一旦超时(也就是每秒),就调用 _add_missing_devices() / _remove_missing_devices(),把所有 VID / PID 的设备重新枚举一遍。本机实测,这一项空转约 4% 单核——比这个面板显示的温度、负载、功耗任何一项都费。
改成真正的 udev 事件驱动(mybox/hotplug.py):阻塞等事件、只在 真有 add / remove 时才重扫,空闲成本几乎为零。
while not self._stop.is_set():
event = monitor.poll(timeout=30) # 30s 兜底,不在超时时做任何枚举
if event is None:
continue
action = getattr(event, "action", "")
time.sleep(0.4) # 等 HID 节点出现/消失
changed = self._rescan() # 只有真事件才枚举
# generated by hugo AI
这是整次改造里 最大的一笔,而且它和「读按键用轮询还是阻塞」一点关系都没有。如果我只盯着读线程,会永远看不到这 4%。
五、最深的坑:这个 C transport 的「-1 = 阻塞」是假的
既然要自管 reader,最直觉的做法是:既然要阻塞,就用 SDK 文档写的无超时阻塞呗。 翻开 LibUSBHIDAPI.py:
def read(self, timeout_ms: int = -1) -> Optional[bytes]:
"""
Args:
timeout_ms: Timeout in milliseconds. -1 means blocking read.
"""
# generated by hugo AI
文档白纸黑字:-1 表示阻塞读。但在 这个 C transport 上,transport_read(..., -1) 并不会阻塞——它 立即返回 TRANSPORT_ERROR_COMMUNICATION_READ_FAILED。真正会阻塞的是 正数 超时:睡到有报告、或睡到超时为止。
这和上一篇 《人应该主动去适配 AI 任务执行范式》 里的坑不是同一个:那次是 SDK 里读写共用一把锁,阻塞读会死锁;这次是 文档和底层语义对不上,-1 是个会把你带沟里的默认值。两次的教训却一致:动手前先验证框架的真实行为,别信文档的字面。
自管 reader 的核心就一个循环(mybox/main.py),关键是 复用 SDK 的解码路径,只换「谁来读、超时多长」:
def _blocking_read_loop(self, device, stop: threading.Event) -> None:
transport = device.transport
timeout = self.args.read_timeout_ms # 默认 1000
while not stop.is_set():
data = transport.read(timeout_ms=timeout) # 正数超时才会阻塞
if not data:
continue
device._handle_raw_read(data) # 复用 SDK 的原始回调
if not device._is_input_event_packet(data):
continue
event = device.decode_input_event(data[9], data[10]) # 复用 SDK 的解码
if event.event_type == EventType.UNKNOWN:
continue
callback = device.key_callback
if callback is not None:
callback(device, event)
# generated by hugo AI
接管别人的 reader 之前,有三件事必须先确认:
| 问题 | 为什么 | 本例答案 |
|---|---|---|
| ① 能不能 安全停下 原 reader? | 停下时它可能正卡在一次读里,无超时 join() 会挂死 | 设 run_read_thread = False 再 join(timeout=1.0) |
| ② 能不能 复用它的解码,而不是重写协议? | HID 报文头 / 键号映射 / 事件结构重写一遍就是引入 bug | 复用 _handle_raw_read、_is_input_event_packet、decode_input_event |
| ③ 有没有 回退开关? | 换设备 / 换固件 / 换 transport 时不能一夜清零可靠性 | 保留 --no-blocking-read,退回 SDK 的 100ms reader |
手势逻辑(右下角按键开关屏幕、防抖)全部留在原来的 key_callback 里。改造只换了「谁来读、怎么读」,只动数据来源,不动业务判断——这也是它能稳定上线的原因。
六、顺手清掉的另外两个唤醒源
③ 和 ④ 不是「唤醒」问题,而是 IO 与计算问题,但既然做了审计就一起清掉:
- 刷新画面:每个键缓存上次的(数值, 颜色, 单位),完全一致就跳过渲染与写入;表头(背景卡片 + 图标 + 标题)按
(标题, 图标, 颜色)缓存成整张底图;渲染尺寸已等于设备原生尺寸时,用一次无损transpose(ROTATE_180)代替 SDK 的「旋转 + 缩放 + RGBA 合成」。整条「渲染 → 变换 → 编码」从约 1.93ms 降到约 0.66ms(约 2.9 倍)。 - 取远端:早期版本在开发板上用 shell 循环
cat48 个 thermal / hwmon 节点(约 100 次 fork),单次采样要 3.5–4.3 秒;改成 单个awk一次性读回全部文件,再用 OpenSSHControlMaster复用连接,整轮 SSH 采样约 100ms。CPU 使用率则只回传/proc/stat的计数,本地做两次差分,省掉远端sleep。
七、实测:同机对比
方法:在插着这块 StreamDock 293 的主机上,读取 /proc/<pid>/task/*/stat 逐线程累计 CPU 时间,空闲期间不碰设备。
| 配置 | 空闲 CPU(单核) |
|---|---|
SDK 示例式:DeviceManager.listen() + SDK reader(100ms) | ≥ 约 4%(listen 单项实测约 4%,其余按组成估算) |
udev hotplug + SDK reader(100ms) | 约 0.6%(实测) |
udev hotplug + 自管 reader(1000ms) | 约 0.4%(实测) |
这些是 我这一台 Linux 主机 + 这块 293 固件(fw=V2.293V2.00.001)的实测,不是普适基准;换成别的 HID 设备、别的 transport,绝对值会变。但「先数清唤醒源、再逐个清零」带来的量级差异应当是一致的。
这里我先替反方说一句:为了这 0.2%,自己去维护一个 reader 线程、绕开官方封装,值吗? 单看这一项,不值。我保留它的理由有两个:一是它只是把 SDK 已有的超时从 100ms 调成 1000ms,不是重写协议,解码、回调、手势逻辑全部复用;二是它带 --no-blocking-read 回退,换设备或换固件时不会一夜清零。真正值钱的不是这 0.2%,而是 审计这个动作本身——它让我发现了占 4% 的热插拔重扫。如果只做「读者超时优化」,我可能还在为 0.4% 自喜。
最后
上一篇的收获是「把轮询换成阻塞读」。这一篇我想把它推进半步:
给常驻程序省 CPU,先别问「我用的是 poll 还是 block」,先问「空闲时到底有几个东西会把我叫醒」。
读线程、热插拔、定时刷新、心跳、采样循环——它们的成本不在单次执行有多重,而在 唤醒次数 本身。这次最大的那一笔(每秒重枚举设备)甚至根本不在读取路径上;如果我把「换阻塞读」当成万能答案,就会与它擦肩而过。
有意思的是,这也修正了我对 AI 的一点判断。上一篇我把「它想到了我没想到的阻塞读」当成范式转变的证据;但换到这块用官方 SDK 的设备上,「换阻塞读」恰恰是 AI 的第一反应,而这个 SDK 早就做过了——照搬上一轮的结论,本身就是一种不校准。真正管用的,是我后来配合它做的那个动作:先测量、列出唤醒源、再逐个清理。人适配 AI 的任务范式,不是记住它上一次的答案,而是对 每个结论的适用边界 保持怀疑。(这也呼应了 StreamPi - 低成本快速响应系统监控工具 里第一次把这类设备当监控面板时的思路——设备在换,账是同一笔账。)
你手上有没有那种「跑了很久、从没人看过它到底占多少 CPU」的常驻脚本?它空闲时,每秒会被叫醒几次?欢迎留言,我们把它一年醒了几亿次算出来。