macOS · Pier · 复盘

一个功能的生与死:Pier 每进程网速的四次重写

Pier 复盘:块缓冲、CRLF 字素、spin-loop 的 nettop、必崩的私有框架——以及怎么算清一笔「删功能」的账。

Pier 的 1.2.0 上过一个功能:每进程网速——像活动监视器那样,给每个进程显示实时上下行。三个版本后(1.2.3),我把它连根删了。这篇是完整的尸检报告:四次重写,每一次在当时都是「合理的下一步」,串起来看却是一条越陷越深的路。

第一版:nettop + Pipe,数字 30 秒才动一次

思路最朴素:常驻一个 nettop 子进程,解析它的输出。上线后数字大约 30 秒才跳一次。

原因是 C 标准库的一个老规矩:stdout 连接到管道时走块缓冲(通常 4KB 起),攒满一块才 flush;只有连接到终端时才是行缓冲。nettop 每秒都在产出数据,但都憋在它自己进程的缓冲区里。

解法也是老 Unix 技巧:openpty 造一对伪终端,把子进程的 stdout 接到 slave 端。nettopisatty() 一探——是终端,于是切回行缓冲,逐帧立即输出。

但 pty 立刻带来第二个 bug,而且藏得更深:pty 模式下行尾是 \r\n,而 Swift 的 String 遵循 Unicode 的扩展字素簇(extended grapheme cluster)规则——CRLF 两个字节算一个 Character。于是 firstIndex(of: "\n") 在整段输出里一个换行都找不到("\r\n" != "\n"),解析循环每次消费 0 行。症状和块缓冲一模一样:数据不动。两个 bug 症状相同、成因无关,排查时互相打掩护。修复是改用谓词匹配:

// firstIndex(of: "\n")            // CRLF 是单个字素,永远匹配不上
buffer.firstIndex(where: { $0.isNewline })  // \n、\r\n、\r 全覆盖

第二版:nettop 自己吃掉 1.4 个核

行缓冲修好了,数字实时了。然后活动监视器给了我一记耳光:那个常驻的 nettop -L 0 自己在 spin-loop——它有个已知 bug,持续占用约 1.4 个核。一个资源监视器,自己成了机器上最大的资源消耗者。这个功能要么换实现,要么死。

第三版:dlopen 私有框架,精致而短命

活动监视器凭什么又准又省?它用的是系统私有框架 NetworkStatistics。于是走上经典歧路:

dlopen("/System/Library/PrivateFrameworks/NetworkStatistics.framework/NetworkStatistics")
→ dlsym: NStatManagerCreate / NStatManagerAddAllTCP / NStatManagerAddAllUDP / ...

订阅全部 TCP/UDP socket,按 source UUID 维护累计字节;每 2 秒主动 query 一次触发回调,与上次快照做差得到速率;再过一层 EWMA 平滑(rate = 0.5 × new + 0.5 × old),避免脉冲流量让数字上蹿下跳。工程上它近乎完美:闲时 0 CPU,数据和活动监视器逐位一致。

值得诚实记录的是:这一步的每个局部判断都说得通——「系统自己在用,说明能力存在」「dlopen 失败可以优雅降级」。当时唯一没有称重的变量是:私有框架的行为随 OS 版本漂移时,我没有任何手段

死刑判决:macOS 15 上必崩

崩溃报告进来:macOS 15.0/15.1 上,框架内部的查询调用在 objc_retain 处踩野指针——框架自己的内存管理 bug,对象已释放仍被 retain,典型 use-after-free,触发即崩,无法在调用方规避(崩溃点在人家框架内部)。这是 Apple 的已知问题,但私有框架没有 API 契约,没人欠你修。再往前看一层:未来版本连 dlopen/dlsym 私有框架这条路本身是否继续放行,都没有承诺。

当时列的选项和判分:

  • 按 OS 版本关功能——留下一个「有的系统有、有的系统没有」的幽灵开关,支持成本最高的一种存在方式;
  • 等 Apple 修——把发布节奏抵押给一个不认识你的团队;
  • 降级回 nettop——回到 1.4 个核,等于没修;
  • 删除功能——唯一让维护面收敛的选项。

选了删除,而且删干净:ProcessNetMonitor 整个移除,连同它顺路带出来的 VPN 状态横幅(读隧道接口流量)一起,进程列表回到 CPU/内存两键排序,网络指标只保留整机上下行(这条走公开接口,无风险)。

复盘

完整路径:公开工具的缓冲问题 → pty 修缓冲 → 工具自身的性能 bug → 私有 API 修性能 → 私有 API 稳定性无解 → 删除功能。逐步局部最优,整体是沉没成本的滑梯。事后看,第二版发现 nettop spin-loop 的那一刻就该开这个会:这个功能的价值,配得上它接下来只会上涨的维护成本吗?

三条带走的结论:

  • 私有 API 不是有风险的捷径,是负债。它的「利息」是每次 OS 大版本升级时你产品的稳定性都要重新过堂,而你没有还价能力;
  • 功能去留的标准不是能不能做出来,是故障预算。做出来了,还很精致——但一个 $9 的菜单栏工具,为一个锦上添花的数字背上「新系统必崩」,这笔账怎么算都是亏的;
  • 删功能要删成外科手术。按版本降级、藏进高级设置、留一半代码「以后再说」,都是把决策成本转嫁给未来的自己。git 的历史里什么都在,需要时随时能考古。

Pier 现在的网络指标只有整机上下行。上线以来,没有一个用户问过那个功能去哪了。