在 HarmonyOS 应用开发中,偶发 UI 卡死并由 WatchDog 上报 THREAD_BLOCK_6S 的 AppFreeze 事件,是典型的冻屏排障场景。某应用案例中,开发者拿到的 freeze 堆栈同时包含 3S 警告栈和 6S 错误栈,两份栈顶都停在同一个函数:- #00 ... libark_jsruntime.so(Builtins::JSStableArray::IndexOfBigInt+...)
- #01 ... libark_jsruntime.so(Interpreter::Execute+...)
- #02 ... GeneratorReEnterInterpreter
- #03 at findXXXList (entry|feat_xxx|1.0.0|src/main/ets/model/LeakXXXModel.ts:440:1)
- ...
- #20 ... libappkit_native.z.so(MainThread::Start)
复制代码 表面看栈顶落在 ArkRuntime(方舟运行时)的 Builtins 接口上,容易怀疑虚拟机有 bug。实际根因是主线程里写了一个非常大的 for/while 循环,循环中调用 IndexOfBigInt 这类 Builtins 接口。单次循环并不慢,但循环次数巨大,主线程被占满 6 秒以上,WatchDog 的判活任务迟迟排不进主线程队列,最终上报冻屏。
一、先判断是不是方舟运行时 bug
方舟运行时(libark_jsruntime.so、libace_napi.z.so)是 ArkTS/JS 代码的执行引擎。freeze 栈顶出现在方舟运行时,绝大多数情况下不是引擎 bug,而是开发者写的 ArkTS/JS 代码让主线程繁忙,引擎只是在执行这些代码。
一个便于记忆的判断方法是:去掉 jsruntime 栈帧后,看 3S 栈和 6S 栈指向的应用代码行是否相同。相同,说明主线程被这段代码卡住,大概率是循环或耗时调用,而不是死锁;不同,说明主线程还在推进业务,只是任务太重,需要结合 trace 判断是否频繁调用导致繁忙。
WatchDog 的检测机制是每隔 3 秒向主线程队列抛一个判活任务。3 秒没执行,上报 THREAD_BLOCK_3S;6 秒还没执行,上报 THREAD_BLOCK_6S。3S 栈和 6S 栈本质上是两个时刻的快照,因此两份栈是否指向同一行,是区分“单段代码耗时过长”和“业务繁忙但仍在推进”的关键信号。
二、案发代码与复杂度问题
该案例中的 findXXXList 用于返回 [start, end] 区间中缺失消息组成的列表。原实现用 while 遍历,并在循环里反复调用 indexOf:- // 问题代码(LeakXXXModel.ts:440)
- public async findXXXList(indexList, start, end) {
- while (start <= end) {
- // 在 indexList 里查找当前下标是否缺失,内部调用 Array.indexOf
- // (底层对应 Builtins::JSStableArray::IndexOfBigInt)
- if (indexList.indexOf(start) === -1) {
- this.missingList.push(start);
- }
- start++;
- }
- return this.missingList;
- }
复制代码 当 indexList 很大、[start, end] 区间也很宽时,while 循环要跑几十万次,每次循环里又对大数组做 indexOf,单次查找是 O(n),整体复杂度接近 O(n²)。主线程一直在这个函数里出不来,WatchDog 的高优先级判活 Timer 排在主线程任务队列里得不到执行,于是冻屏。
堆栈与代码的对应关系可以这样看:#03 是开发者代码中的大循环加 indexOf;#02 是 ArkTS 异步函数回到解释器;#01 是方舟解释器执行 JS 字节码;#00 是 indexOf 的 Builtins 实现被反复调用。
三、排查路径
第一,看 freeze 栈。重点不是栈顶是否出现 libark_jsruntime.so,而是去掉运行时栈帧后,应用代码行是否同时出现在 3S 栈和 6S 栈中。本例中两份栈都指向 LeakXXXModel.ts:440,说明这 3 秒内主线程一直卡在同一行。
第二,反向定位开发者代码。从堆栈里找带 .ts 的行,例如 #03 at findXXXList (entry|feat_xxx|1.0.0|src/main/ets/model/LeakXXXModel.ts:440:1),直接定位到 LeakXXXModel.ts:440 的 findXXXList 函数,检查它在做什么,最终发现 while 循环里嵌套 indexOf。
第三,用 trace 交叉验证。若只看栈还不够确定,可以抓 freeze 前的 trace。该案例的 trace 显示主线程在这 6 秒里没有大段空白,不是在持锁等待,也没有频繁 GC,而是一直处于 running 状态并连续执行同一个函数,这与大循环的特征吻合。
三条判别线索可以合并使用:栈顶在方舟运行时;3S 栈与 6S 栈指向同一行应用代码;栈中无持锁或等待帧,可排除死锁。三条齐备,基本可判定为主线程被某段代码卡住的主线程繁忙型冻屏。
四、修复方案与取舍
核心原则是不要让主线程长时间停留在一个同步耗时函数里。该案例给出三种手段:加超时保护,改动最小,适合短期无法重构逻辑的场景;优化算法,降低循环复杂度,从根上减少单次执行耗时;把整个耗时函数挪到 TaskPool/Worker 执行,不阻塞主线程。
方案一,加超时保护。设置一个定时器做超时检测,当循环执行超时后直接跳出,避免主线程被卡 6 秒:- // 修复方案一:加超时定时器,超时即跳出
- public async findXXXList(indexList, start, end) {
- let needBreak = false;
- setTimeout(() => {
- needBreak = true;
- }, 100); // 定时器:100ms 超时
- while (start <= end) {
- // do some thing
- start++;
- if (needBreak) {
- break; // 超时退出,避免阻塞主线程
- }
- }
- }
复制代码 while 还是那个 while,但有了 100ms 的逃生出口,主线程不会再被卡 6 秒,WatchDog 的判活任务能及时执行。这个方案本质上是规避,不是根治。
方案二,优化算法。把在大数组里反复 indexOf 换成用 Set 做一次 O(1) 查找,整体复杂度从 O(n²) 降到 O(n):- // 修复方案二:用 Set 替代 indexOf,复杂度 O(n²) → O(n)
- public async findXXXList(indexList, start, end) {
- const indexSet = new Set(indexList); // 一次建索引,O(n)
- for (let i = start; i <= end; i++) {
- if (!indexSet.has(i)) { // O(1) 查找
- this.missingList.push(i);
- }
- }
- return this.missingList;
- }
复制代码 数据量大时,执行时间可从秒级降到毫秒级,从根上消除冻屏。
方案三,挪到 TaskPool。如果逻辑本身确实重、无法优化到毫秒级,就把整个函数放到子线程执行:- // 修复方案三:整个函数挪到 TaskPool
- import taskpool from '@ohos.taskpool';
- @Concurrent
- function findXXXListOnWorker(indexList, start, end): number[] {
- const indexSet = new Set(indexList);
- const missing: number[] = [];
- for (let i = start; i <= end; i++) {
- if (!indexSet.has(i)) missing.push(i);
- }
- return missing;
- }
- public async findXXXList(indexList, start, end) {
- const task = new taskpool.Task(findXXXListOnWorker, indexList, start, end);
- this.missingList = await taskpool.execute(task) as number[];
- return this.missingList;
- }
复制代码 阻塞的是 worker 线程而不是 UI 线程,主线程可以继续响应输入。实际取舍可以按这个顺序理解:能优化算法就优先优化算法,这是根治;短期不能重构逻辑,就加超时保护做规避;逻辑本身确实重,再用 TaskPool/Worker 做线程隔离。TaskPool 隔离的是执行线程,并不会消除算法复杂度,数据量持续增长时仍应优先降低复杂度。
五、修复后验证
现象上,触发原来卡顿的场景后,UI 不再卡死,输入框、滚动列表照常响应;WatchDog 不再上报 THREAD_BLOCK_6S 或 THREAD_BLOCK_3S;用 hilog | grep -i FREEZE 检查,无新上报。
堆栈上,如果再次抓 freeze 堆栈,理论上不应再出现原问题:3S 栈和 6S 栈不再指向同一行应用代码,加超时保护后循环会主动让出;栈顶不再长时间停在 JSStableArray::IndexOfBigInt,优化算法后不再反复 indexOf;若采用 TaskPool 方案,主线程栈上不会出现 findXXXList,它只出现在 worker 线程栈上。
六、同类问题的通用判断
该来源文档还列举了多个主线程繁忙模式,判别思路一致:栈顶在方舟运行时,3S/6S 栈或 trace 暴露主线程繁忙,只是繁忙来源不同。通用规律是,只要栈顶在方舟运行时且确认不是死锁,就沿着“主线程在忙什么”这条线排查。循环、正则、序列化、GC、任务队列拥挤,都是可能方向,最终仍要结合应用代码行和 trace 证据定位。
一句话总结:freeze 栈顶在方舟运行时,不要直接归因于运行时 bug;去掉 jsruntime 栈帧后看 3S 和 6S 的应用代码行,相同就说明主线程被这段代码卡住,不是死锁。找到这段代码后,按场景选择加超时保护、优化算法或挪到 TaskPool。 |