更跟手,反而更省 CPU:把监控面板空闲占用从 3% 压到 0.4%

More Responsive, Less CPU: Replacing a HID Poll Loop with a Blocking Read

我在这台 8 核 ARM 小主机上挂了一块 Stream Deck Mini,当常驻系统监控面板:6 个按键,实时显示 CPU、内存、温度、磁盘,翻页键切到每核占用,长按熄屏。它已经默默跑了很久。

直到某天我 top 了一下。排在最前面的不是哪个服务,而是它自己——python 进程占着约 3% 的 CPU,比它监控的大部分服务都费电。

一个监控工具,把自己变成了最该被监控的对象。

这篇讲我怎么把它的空闲占用压到 0.4%,而且按键 反而更跟手了。中间踩了一个坑:在 SDK 里把「非阻塞读」改成「阻塞读」会死锁。这个坑值得单独讲,因为它不只属于 Stream Deck——任何「轮询 → 事件驱动」的改造都会遇到。

More Responsive, Less CPU: Replacing a HID Poll Loop with a Blocking Read

一、它在「画」,还是在「看」?

先做归因。把进程按线程拆开看 CPU,会发现两个来源:

  • 渲染:每次刷新把变化的按键重新编码成未压缩 BMP,通过 USB 写下去。Mini 一个按键约 19 KB、要走约 20 次 USB 写,6 个键全变就是一次可观的 USB 流量;
  • 输入:一个后台线程以固定频率问设备「有按键吗」。15Hz 就是每秒 15 次,30Hz 就是 30 次,永远不停。

那段时间我刚做过一轮渲染优化:只重绘真正变化的按键(进度条按像素量化)、背景和图标缓存、温度改直读 sysfs(一次采样从 44ms 降到 2ms)。空闲占用从最初的约 3% 降到了约 1.9%。

但剩下的 1.9% 里,大头是那个永不停歇的输入轮询。

这解释了一个反直觉的现象:那块设备 99.9% 的时间都不会有按键事件。也就是说,每秒 15~30 次轮询里,绝大多数得到的回答是「没有」。我们在为「什么都没发生」付 CPU。

监控类常驻程序的成本,往往不在「看一眼」,而在「不停地看」。

二、第一反应是调低频率——但这是治标

最直觉的省法是把轮询频率降下来。我实测过每一档(同一台机器、无按键空闲):

空闲轮询频率额外 CPU
30 Hz~0.5%
15 Hz~0.3%
5 Hz~0.13%

代价是延迟。读取线程每 1/poll-hz 秒采样一次;如果一次「按下 + 松开」整段都落在两次采样之间,这次轻点就 完全丢失。右下角翻页键用的是短按,低频下就表现为「翻页键不灵 / 只有长按熄屏有反应」——因为长按持续时间长、总会被采到,于是看起来像「和长按冲突」。

一旦你把频率降到 10Hz 以下,就会开始漏掉正常轻点。这本质是把 CPU 换成可靠性,交易并不划算。

真正的问题不是「轮询太快」,而是「根本不该轮询」。

轮询(Poll):  线程 ──每 66ms──▶ 问一次「有按键吗?」──▶ 没有,睡 66ms
                       │
                       └─ 99.9% 的唤醒都扑空,但设备只在变化时才有数据

阻塞(Block): 线程 ──hid_read_timeout()──▶ 睡在 USB 内核传输里
                       │
                       └─ 设备一有变化,内核立刻唤醒它:零轮询、零等待
# generated by hugo AI

设备只在按键变化时才上报——这意味着我们 不需要 问,只需要 等。等,就是阻塞读。

三、第二个反直觉:更跟手,反而更省 CPU

把「每 66ms 问一次」换成「睡到有事件为止」,同时得到两个好处:

  • CPU:空闲时线程睡在内核的 USB 中断传输里,不消耗 CPU;
  • 延迟:一次轻点在按下的一瞬间就被内核唤醒处理,不用等下一个采样窗口。

省 CPU 和低延迟,在这里不是取舍,而是一起变好。真正被牺牲的,是「框架的简单性」——这也就是下一节要讲的坑。

四、最深的坑:在 SDK 里阻塞读会死锁

我一开始想得很简单:SDK 里那个 read 用的是非阻塞 hid_read,我在外面循环调用、加个 sleep,不就是轮询吗?那我把它换成阻塞的 hid_read(无超时)不就行了?

答案是不行。Stream Deck 的 Python SDK 里,读和写共用同一把锁:

class Device(Transport.Device):
    def read(self, length: int) -> bytes | None:
        """Performs a read of a HID In report from an open HID device."""
        with self.mutex:                          # 与 write 共用同一把锁
            return self.hidapi.read(self.device_handle, length)

    def write(self, payload: bytes) -> int:
        """Writes a HID Out report to an open HID device."""
        with self.mutex:                          # 同一把锁
            return self.hidapi.write(self.device_handle, payload)
# generated by hugo AI

而且更底层还有一把类级别的锁:Library.read() 在调用 hid_read 时也持有 Library.mutex。

如果我在这条路径上做 无超时 的阻塞读,会同时出两件事:

  1. 读取线程抱着 Device.mutex 不放 → 主线程想写画面时卡死,屏幕不再更新;
  2. 退出时 SDK 的 _setup_reader(None) 会 join() 读取线程 → 但读取线程正阻塞在 hid_read 里,永远不会返回 → 进程挂住,只能 kill -9。

所以「把 SDK 的读改阻塞」是死路。正确的做法是 绕过这两把锁,直接对 HID 句柄做带超时的阻塞读。

五、改造三问

这次踩坑之后,我总结成一个可复用的检查清单。凡是把「轮询」改成「事件驱动阻塞等待」的改造(HID、串口、socket、GPIO、轮询文件),动手前都先问三件事:

问题为什么致命本例答案
① 设备是不是 事件驱动 的?如果设备不会主动上报(只能轮询状态寄存器),阻塞会永远等不到是。只在按键变化时上报,可阻塞
② 读路径是否与写 共享锁?共享锁下阻塞读会拖死写操作,甚至死锁是。Device.mutex / Library.mutex,必须绕开
③ 阻塞调用是否 可中断?无超时的阻塞会让退出 / 取消永远挂起用 hid_read_timeout,超时只作收尾,不是采样周期

三问里只要有一个「否」,就别急着改——先把条件补齐(换传输、拆锁、加超时)。顺序很关键:先确认能不能,再动手改。这和我之前在 AI 干完了 8 成的活,我才发现需求提错了 里学到的教训一样——动手前先把问题定义清楚,比事后返工便宜得多。

六、实现:把读循环接到自己手里

改造的目标是把输入从 SDK 手里接过来,其余逻辑一行不改。核心就是直接拿 device_handle,绕开 SDK 的锁,用带超时的 hid_read_timeout:

import ctypes
import threading
from typing import TYPE_CHECKING, Callable

if TYPE_CHECKING:
    from StreamDeck.Devices.StreamDeck import StreamDeck

READER_TIMEOUT_MS = 250  # 仅用于退出时能醒来,不是采样周期


class BlockingReader:
    """Reads HID reports with hid_read_timeout, bypassing the SDK's mutexes.

    The SDK calls hid_read while holding Device.mutex and Library.mutex, so a
    blocking read there would stall image writes and deadlock shutdown. We read
    the raw handle directly instead: the device only raises a report on a key
    change, so the thread sleeps in the kernel while idle (~0 CPU).
    """

    def __init__(
        self, deck: "StreamDeck", on_key: Callable, log: Callable[[str], None]
    ) -> None:
        self._deck = deck
        self._on_key = on_key
        self._log = log
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None

    def start(self) -> bool:
        """Takes over the reader; returns False if the transport can't do it."""
        device = self._deck.device
        library = getattr(device, "hidapi", None)
        handle = getattr(device, "device_handle", None)
        hidapi = getattr(library, "hidapi", None)
        if handle is None or hidapi is None or not hasattr(hidapi, "hid_read_timeout"):
            return False

        hidapi.hid_read_timeout.argtypes = [
            ctypes.c_void_p,
            ctypes.POINTER(ctypes.c_char),
            ctypes.c_size_t,
            ctypes.c_int,
        ]
        hidapi.hid_read_timeout.restype = ctypes.c_int
        self._library, self._handle = hidapi, handle

        # Stop the SDK poller only AFTER we know we can replace it, so the
        # polling fallback still works if this transport is unsupported.
        self._deck._setup_reader(None)
        self._thread = threading.Thread(target=self._loop, name="sd-reader", daemon=True)
        self._thread.start()
        return True

    def _loop(self) -> None:
        """Block on the handle until a key changes, then dispatch it."""
        key_count = self._deck.key_count()
        buffer = ctypes.create_string_buffer(1 + key_count)
        # Seed from the state the SDK poller last saw, so a key held across the
        # hand-over is still tracked and its release is delivered.
        last = list(self._deck.key_states()[:key_count])

        while not self._stop.is_set():
            result = self._library.hid_read_timeout(
                self._handle, buffer, len(buffer), READER_TIMEOUT_MS
            )
            if self._stop.is_set() or result < 0:
                break
            if result == 0:
                continue
            raw = buffer.raw[:result]
            for key in range(min(key_count, len(raw) - 1)):
                pressed = bool(raw[1 + key])
                if pressed == last[key]:
                    continue
                last[key] = pressed
                self._deck.last_key_states[key] = pressed  # keep SDK-side checks consistent
                self._on_key(self._deck, key, pressed)

    def stop(self) -> None:
        """Wake the blocking read with a timeout and join the thread."""
        self._stop.set()
        if self._thread is not None:
            self._thread.join(timeout=READER_TIMEOUT_MS / 1000.0 + 1.0)
# generated by hugo AI

有几个细节是「踩过才知道」的:

  • 超时不是采样周期。250ms 超时只在退出时让线程醒来一次;空闲时它一直睡在内核里等,不产生轮询成本。有人会反驳「带超时的阻塞读还是轮询」——区别在于:轮询是没事也要主动去查设备,而超时是设备没事时内核让你睡着,超时只是兜底退出。
  • 绕开锁,就要接受读写并发。框架加锁是为了状态一致,但 HID 的读走中断 IN 端点、写走 OUT 端点,本身不是同一份状态,加锁更多是为了省心。我在本机实测:读取线程长时间阻塞期间,主线程持续写画面无任何异常。前提是传输后端确实支持这一点——对不确定的后端,就用 --no-blocking-read 回退。
  • 要镜像键状态。SDK 的长按判定、熄屏记账都在读 last_key_states。你接管了读之后,必须同步维护它,否则手势逻辑会失忆。
  • 要在启动时播种初始状态。如果你接管时某个键正被按住,直接开始读会把它当成「新按下」或漏掉它的松开。用切换前 key_states() 的快照做基线。
  • 要保留回退。我给命令行加了 --no-blocking-read:遇到不支持的传输后端或异常设备,就退回原来的轮询实现。改造不能把原有的可靠性一夜清零。

手势逻辑(短按翻页、长按熄屏、唤醒冷却、幽灵键过滤)全部留在原来的 _on_key 里,改造只换了「谁来读取、怎么读取」。这也是它能稳定上线的原因——只动数据来源,不动业务判断。

七、实测:同机对比

方法:在同一台 8 核 ARM 主机上,读取 /proc/<pid>/task/*/stat 逐线程累计 CPU 时间,跑 25 秒取平均,两次测量期间都不碰设备。

配置空闲 CPU
最初版本(全量重绘 + psutil 取温度,--interval 1 --poll-hz 30)~3%
渲染优化后(只重绘变化 + sysfs 温度,--interval 1 --poll-hz 30)~1.85%
--interval 1 --poll-hz 20~1.55%
--interval 2 --poll-hz 20~1.20%
--interval 5 + 15Hz 轮询(--no-blocking-read)~0.64%
默认:--interval 5 + 阻塞读~0.44%

同样 5 秒的刷新间隔,阻塞读比 15Hz 轮询又省了约三分之一的空闲 CPU,而且按键是「立即」而不是「最多等 66ms」。

这些数字是 我这一台 ARM 小主机的实测,不是普适基准;换成 x86 或别的 HID 设备,绝对值会变,但「消除周期轮询」带来的量级差异应当是一致的。

最后

这次改造真正的收获,不是那个 0.44% 的数字,而是一个更一般的判断:

给常驻程序省 CPU,先别问「哪个参数能调小」,先问「它凭什么一直在醒着」。

轮询、心跳、定时刷新、Agent 的定时循环——它们的成本不在单次执行有多重,而在 唤醒次数 本身。把「每隔 N 秒主动问一次」换成「等事件发生再醒」,往往能同时拿到更低占用和更快响应。

但在动手之前,请先过一遍「改造三问」:设备是否事件驱动、读路径是否与写共享锁、阻塞调用是否可中断。我在 Stream Deck 上差点因为第二问栽进去——在 SDK 里改阻塞读,会死锁。

我在 StreamPi - 低成本快速响应系统监控工具 里第一次把 Stream Deck 用作监控面板;这次是在它的基础上,把「输入循环」这一层重新做了一遍。如果你也在用常驻小硬件跑服务,299 美金的个人知识库开发板 那篇里「常驻不关机」的成本账,其实和这里的空闲 CPU 是同一笔账。

你手上有没有那种「跑了很久、从没人看过它到底占多少 CPU」的常驻脚本?欢迎留言,我们算算它一年醒了几亿次。


See also