如果你想建立公司自己的APM监控平台,卡顿检测这项来说,微信Matrix是一个很好的参考;如果想进一步监控卡顿的实际时长,当卡顿时长达到8秒时升级为卡死级别(ANR)上报,可以在这个基础上增加判断逻辑。
- 代码是开源的:微信Matrix
- 进一步判定8秒卡死:字节-iOS 稳定性问题治理:卡死崩溃监控原理及最佳实践
下文是根据源码梳理的图解实现原理
WCBlockMonitorMgr 是 微信Matrix 中用于监控 iOS 主线程卡顿(ANR / Hang)的核心模块。其设计思路是:通过 RunLoop Observer 感知主线程状态变化,再通过独立的后台线程周期性检查主线程是否"卡死"超过阈值,若卡顿则收集堆栈、CPU 等信息并生成 dump 文件上报。同时它还集成了 CPU 高负载检测、耗电堆栈采集、内存监控等功能。
下面从五个方面详细讲解实现原理,并辅以图解。
一、核心监控流程概览
text
+----------------+ +-------------------+ +-------------------+
| 主线程 RunLoop | ---> | RunLoop Observer | ---> | 全局状态变量更新 |
| (UI/事件处理) | | (开始/结束回调) | | g_bRun, g_tvRun |
+----------------+ +-------------------+ +-------------------+
|
v
+----------------+ +-------------------+ +-------------------+
| 监控线程 | <--- | 定期检查 | ---> | 计算卡顿时长 diff |
| (独立 thread) | | (每轮 50ms 堆栈采样)| | diff > 阈值? |
+----------------+ +-------------------+ +-------------------+
|
+-------------------------+
| |
v v
+-------------------+ +-------------------+
| 卡顿触发 | | 无卡顿,继续采样 |
| 收集堆栈/CPU/快照 | | 退火算法调整间隔 |
| 生成 dump 文件 | +-------------------+
+-------------------+
关键参数 (单位均为微秒,但代码中常用 BM_MicroFormat_Second 等宏):
g_RunLoopTimeOut:卡顿判定阈值,默认 2 秒(可动态调整)。g_CheckPeriodTime:无论是否卡顿,监控线程的单次检查周期,通常为g_RunLoopTimeOut / 2。g_PerStackInterval:连续采样堆栈的间隔,固定 50ms(代码中BM_MicroFormat_FrameMillSecond)。
二、RunLoop 观测与状态记录
RunLoop 在每次循环的不同阶段(Entry、BeforeTimers、BeforeSources、AfterWaiting、BeforeWaiting、Exit)会触发 Observer 回调。WCBlockMonitorMgr 添加了两个 Observer:
- beginObserver (优先级
LONG_MIN):在 RunLoop 开始处理 事件时(Entry、BeforeTimers、BeforeSources、AfterWaiting)将g_bRun置为YES,并记录开始时间g_tvRun。 - endObserver (优先级
LONG_MAX):在 RunLoop 即将休眠 (BeforeWaiting)或 退出 (Exit)时将g_bRun置为NO。
另外还针对启动阶段(UIInitializationRunLoopMode)添加了单独的 Observer,用于处理启动卡顿。
状态图:
text
[RunLoop 启动]
│
▼
kCFRunLoopEntry ──▶ g_bRun = YES; 记录开始时间
│
▼
BeforeTimers / BeforeSources / AfterWaiting
│
▼
(主线程忙碌处理事件)
│
▼
BeforeWaiting (即将休眠) ──▶ g_bRun = NO; 可选敏感检测
│
▼
(休眠等待事件)
│
▼
唤醒后回到 AfterWaiting,重复
监控线程读取 g_bRun 和 g_tvRun,若当前 g_bRun == YES 且距离 g_tvRun 的时长超过 g_RunLoopTimeOut,则认为主线程发生了卡顿。
注意:
g_bRun在 Observer 中更新,是跨线程共享的原子变量(实际未加锁,但因访问简单且时序不苛刻,可接受)。
三、监控线程的工作机制
监控线程在 threadProc 中无限循环(内部有休眠),每次循环执行以下步骤:
-
检查卡顿 (调用
check方法):- 计算当前时间与
g_tvRun的差值diff。 - 若
g_bRun == YES且diff > g_RunLoopTimeOut,则判定卡顿。 - 同时还会检查 CPU 使用率是否超过阈值(
g_CPUUsagePercent,默认 1000 即忽略,实际可配置)。 - 返回对应的
EDumpType(如MainThreadBlock、BackgroundMainThreadBlock、CPUBlock等)。
- 计算当前时间与
-
卡顿处理:
- 调用
needFilter进行退火过滤:对比当前卡顿的堆栈与上次卡顿堆栈是否相同,若相同则放大检查间隔(退火算法),避免重复上报相同的堆栈。 - 若未过滤,则收集主线程堆栈(来自循环队列
WCMainThreadHandler中保存的多次采样结果)或 CPU 高负载堆栈。 - 调用
dumpFileWithType生成 dump 文件(同时可选择挂起所有线程并生成快照)。 - 通过 delegate 回调通知上层。
- 调用
-
记录当前堆栈 (
recordCurrentStack):- 无论是否卡顿,每一轮都会以
g_CheckPeriodTime为周期,内部按g_PerStackInterval多次获取主线程调用栈,存入WCMainThreadHandler的循环队列中。 - 这样在卡顿发生时,可以拿到卡顿期间的多组堆栈,而非仅某一瞬间的堆栈。
- 无论是否卡顿,每一轮都会以
-
重置状态 :若无卡顿,调用
resetStatus恢复监控参数。
监控线程时序图:
text
监控线程 主线程 (RunLoop)
│ │
├─ 等待 g_CheckPeriodTime ──→│ (忙碌)
│ │
├─ 检查卡顿 ←────────────────┤ (g_bRun = YES; )
│ (diff > 阈值) │
├─ 卡顿触发 │
│ ├─ 收集堆栈 (队列中已有) │
│ ├─ 生成 dump │
│ └─ 回调 │
│ │
├─ 继续下一轮监控 │
│ │
四、堆栈采集与退火算法
1. 堆栈采集
- 通过
WCGetMainThreadUtil获取主线程调用栈(内部使用kscrash的stacktrace或thread_get_state)。 - 也是在上述监控线程中进行,每
g_PerStackInterval(50ms)采集一次,每次最多保存g_StackMaxCount个地址。采集后休眠。 - 这些地址被存入
WCMainThreadHandler的循环队列,队列长度由g_MainThreadCount = g_CheckPeriodTime / g_PerStackInterval决定(例如 500ms / 50ms = 10 个槽位)。
2. 退火算法(needFilter)
- 将当前卡顿时的堆栈与上一次卡顿的堆栈逐帧比较。
- 若完全相同(表明可能是同样的代码循环卡顿),则:
- 将检查间隔
m_nIntervalTime扩大为m_nLastTimeInterval + m_nIntervalTime(退火)。 - 返回
EFilterType_Annealing,跳过本次上报。
- 将检查间隔
- 若不同,则恢复
m_nIntervalTime = g_CheckPeriodTime,并更新保存的堆栈副本。 - 同时还会检查今日已上报次数是否超过限额(
getDumpDailyLimit)。
算法示意图:
text
首次卡顿 Stack A
│
▼
第二次卡顿 Stack A' ──→ 比对相同 ──→ 退火: 等待间隔加倍 (例如 1s → 2s)
│ │
│ ▼
│ 再次卡顿仍相同 → 间隔继续扩大
│ │
│ ▼
│ 跳过上报,避免日志泛滥
│
▼
第三次卡顿 Stack B (不同) ──→ 重置间隔,正常上报
五、辅助功能与扩展
- CPU 监控 :通过
WCCPUHandler累计平均 CPU 使用率,若持续高则触发CPUBlock类型 dump。 - 耗电堆栈 :
WCPowerConsumeStackCollector在 CPU 长时间高于阈值时,采集消耗 CPU 的热点堆栈树并异步保存。 - 内存监控:定期打印 footprint,超过阈值(如 400MB)回调 delegate。
- RunLoop 敏感检测 :当
g_bSensitiveRunloopHangDetection开启时,在每次BeforeWaiting前立即检查主线程当前已执行时长,若超过 250ms 则异步回调(类似苹果 HangTracer)。 - 动态阈值调整 :提供
lowerRunloopThreshold/recoverRunloopThreshold方法,可动态降低卡顿阈值(如进入页面后降低到 400ms),退出后恢复。
六、完整流程图(文字版)
text
+-----------------------------+
| App 启动 |
+-----------------------------+
│
▼
+-----------------------------+
| 添加 RunLoop Observers |
| (begin/end, 两种 mode) |
+-----------------------------+
│
▼
+-----------------------------+
| 启动监控线程 (threadProc) |
+-----------------------------+
│
┌────────┴────────┐
│ │
▼ ▼
+------------+ +------------+
| 主线程 | | 监控线程 |
| RunLoop | | 循环: |
| 触发回调 | | 1. 检查卡顿 |
| 更新 | | 2. 若卡顿 |
| g_bRun/ | | 过滤重复 |
| g_tvRun | | 生成dump |
+------------+ | 3. 采样堆栈 |
| 4. 休眠 |
+------------+
│
▼
+----------+
| 卡顿上报 |
| (delegate)|
+----------+
状态转换细节(卡顿检测部分):
text
[RunLoop 开始处理]
│
▼
(g_bRun=YES, 记录开始)
│
│ 主线程长时间不进入休眠
│ (例如死循环、大量IO)
▼
监控线程计算 diff > 阈值
│
├─ 是 ──→ 判定卡顿
│ │
│ ├─ 堆栈是否重复?
│ │ ├─ 是 → 退火,不上报
│ │ └─ 否 → 生成 dump
│ │
│ └─ 回调通知
│
└─ 否 ──→ 继续监控
七、关键代码片段注释(对应原理)
| 原理部分 | 代码位置(函数/行) |
|---|---|
| 添加 RunLoop Observer | addRunLoopObserver,使用 CFRunLoopAddObserver |
| 状态变量更新 | myRunLoopBeginCallback / myRunLoopEndCallback |
| 监控线程循环 | threadProc 中的 while(YES) |
| 卡顿判定 | check 方法中计算 diff > g_RunLoopTimeOut |
| 堆栈采集 | recordCurrentStack 内循环调用 WCGetMainThreadUtil |
| 退火过滤 | needFilter 方法 |
| 动态阈值调整 | setRunloopThreshold: |
通过以上机制,WCBlockMonitorMgr 能够在不显著影响性能的前提下,高效准确地捕获主线程卡顿,并提供丰富的辅助信息用于定位问题。