查看: 1165|回复: 3

HarmonyOS ArkTS主线程冻屏:3S/6S栈同源排查

[复制链接]
发表于 昨天 10:00 | 显示全部楼层 |阅读模式
在 HarmonyOS 应用开发中,偶发 UI 卡死并由 WatchDog 上报 THREAD_BLOCK_6S 的 AppFreeze 事件,是典型的冻屏排障场景。某应用案例中,开发者拿到的 freeze 堆栈同时包含 3S 警告栈和 6S 错误栈,两份栈顶都停在同一个函数:
  1. #00 ... libark_jsruntime.so(Builtins::JSStableArray::IndexOfBigInt+...)
  2. #01 ... libark_jsruntime.so(Interpreter::Execute+...)
  3. #02 ... GeneratorReEnterInterpreter
  4. #03 at findXXXList (entry|feat_xxx|1.0.0|src/main/ets/model/LeakXXXModel.ts:440:1)
  5. ...
  6. #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:
  1. // 问题代码(LeakXXXModel.ts:440)
  2. public async findXXXList(indexList, start, end) {
  3.   while (start <= end) {
  4.     // 在 indexList 里查找当前下标是否缺失,内部调用 Array.indexOf
  5.     // (底层对应 Builtins::JSStableArray::IndexOfBigInt)
  6.     if (indexList.indexOf(start) === -1) {
  7.       this.missingList.push(start);
  8.     }
  9.     start++;
  10.   }
  11.   return this.missingList;
  12. }
复制代码
当 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 秒:
  1. // 修复方案一:加超时定时器,超时即跳出
  2. public async findXXXList(indexList, start, end) {
  3.   let needBreak = false;
  4.   setTimeout(() => {
  5.     needBreak = true;
  6.   }, 100); // 定时器:100ms 超时
  7.   while (start <= end) {
  8.     // do some thing
  9.     start++;
  10.     if (needBreak) {
  11.       break; // 超时退出,避免阻塞主线程
  12.     }
  13.   }
  14. }
复制代码
while 还是那个 while,但有了 100ms 的逃生出口,主线程不会再被卡 6 秒,WatchDog 的判活任务能及时执行。这个方案本质上是规避,不是根治。

方案二,优化算法。把在大数组里反复 indexOf 换成用 Set 做一次 O(1) 查找,整体复杂度从 O(n²) 降到 O(n):
  1. // 修复方案二:用 Set 替代 indexOf,复杂度 O(n²) → O(n)
  2. public async findXXXList(indexList, start, end) {
  3.   const indexSet = new Set(indexList); // 一次建索引,O(n)
  4.   for (let i = start; i <= end; i++) {
  5.     if (!indexSet.has(i)) { // O(1) 查找
  6.       this.missingList.push(i);
  7.     }
  8.   }
  9.   return this.missingList;
  10. }
复制代码
数据量大时,执行时间可从秒级降到毫秒级,从根上消除冻屏。

方案三,挪到 TaskPool。如果逻辑本身确实重、无法优化到毫秒级,就把整个函数放到子线程执行:
  1. // 修复方案三:整个函数挪到 TaskPool
  2. import taskpool from '@ohos.taskpool';
  3. @Concurrent
  4. function findXXXListOnWorker(indexList, start, end): number[] {
  5.   const indexSet = new Set(indexList);
  6.   const missing: number[] = [];
  7.   for (let i = start; i <= end; i++) {
  8.     if (!indexSet.has(i)) missing.push(i);
  9.   }
  10.   return missing;
  11. }
  12. public async findXXXList(indexList, start, end) {
  13.   const task = new taskpool.Task(findXXXListOnWorker, indexList, start, end);
  14.   this.missingList = await taskpool.execute(task) as number[];
  15.   return this.missingList;
  16. }
复制代码
阻塞的是 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。
回复

使用道具 举报

发表于 昨天 19:00 | 显示全部楼层

Re: HarmonyOS ArkTS主线程冻屏:3S/6S栈同源排查

这个案例的排查思路写得很清楚,尤其是“去掉 jsruntime 栈帧后看 3S 栈和 6S 栈是否指向同一行应用代码”这个判断方法,很实用,比一看到栈顶是 Builtins 就怀疑方舟运行时靠谱得多。用 trace 看主线程是否持续 running、有没有持锁等待或频繁 GC 来交叉验证,也能把“主线程繁忙型冻屏”和死锁、等待型问题区分开。 不过方案一里的超时保护示例需要特别注意:在 ArkTS/JS 主线程上,如果 while 循环是完全同步的,setTimeout 的回调其实很难在 100ms 时插进来执行,因为主线程一直被这个循环占着,定时器回调只能排队,等循环结束后才有机会跑。也就是说,靠 setTimeout 置 needBreak 来打断同步 while,实际效果可能达不到预期。更稳妥的做法是在循环内部主动检查时间戳,比如记录开始时间,每次迭代判断是否超过阈值再 break;或者把大循环改成分片处理,每处理一批就 await 让出一次事件循环。这样超时保护才真正有逃生出口。 修复取舍上,优先用 Set 把反复 indexOf 的 O(n²) 降下来是根治方向;超时保护适合兜底,但不能替代算法优化;TaskPool/Worker 适合计算量大且可迁移的场景,不过要注意参数和结果序列化、不能依赖主线程闭包状态,以及结果回主线程更新 UI 的成本。整体上,这个案例的定位路径和
回复 支持 反对

使用道具 举报

发表于 昨天 19:10 | 显示全部楼层

Re: HarmonyOS ArkTS主线程冻屏:3S/6S栈同源排查

这篇总结很实用,尤其是用 3S 栈和 6S 栈是否指向同一行应用代码来区分“单段代码耗时过长”和“业务繁忙但仍在推进”,比只盯着栈顶有没有 libark_jsruntime 靠谱多了。trace 里主线程持续 running、没有持锁等待和频繁 GC,再结合三条线索合并判断主线程繁忙型冻屏,这个排查路径很清晰。 修复上我倾向优先做方案二,indexOf 在大数组里反复查确实是 O(n²),先构建 Set 再遍历 [start, end],查找降到 O(1),能从根上减少单次执行耗时;TaskPool/Worker 适合逻辑重、可异步化的场景。方案一超时保护作为兜底可以,但纯同步 while 里 setTimeout 回调本身也要等主线程空闲才执行,可能等不到 100ms 就卡满 6 秒,最好配合分片让出或每轮检查时间戳,不要只依赖定时器置标志。整体排查思路值得收藏。
回复 支持 反对

使用道具 举报

发表于 昨天 19:20 | 显示全部楼层

Re: HarmonyOS ArkTS主线程冻屏:3S/6S栈同源排查

这个排查思路挺实用的,尤其是“去掉 jsruntime 栈帧后看 3S 栈和 6S 栈是否指向同一行应用代码”这个判别方法,比单纯盯着栈顶是不是 libark_jsruntime.so 要靠谱得多。很多同学一看到方舟运行时出现在栈顶就往引擎 bug 上想,其实大多数情况还是业务代码把主线程占住了,引擎只是在老老实实执行。 有个细节想补充一下:方案一里用 setTimeout 去置 needBreak 这个写法,在纯同步的 while 循环里其实是有风险的。因为主线程被这个循环完全占满时,事件循环根本没机会跑定时器回调,needBreak 大概率一直是 false,等于逃生出口没生效。更稳的做法是在循环内部直接检查时间戳,比如进来先记个 startTime,循环里每隔一段判断 Date.now() - startTime 是否超过阈值再 break,这样不依赖定时器排队。当然这只是临时兜底,跟帖主说的一样,根治还是得落到算法和线程模型上。 方案二换 Set 的思路没问题,O(n²) 降到 O(n) 是立竿见影的。如果 indexList 本身有序,其实还可以考虑双指针或者直接遍历区间做差集,连 Set 的内存开销都能省掉。方案三挪 TaskPool/Worker 也要注意数据量,如果 indexList 很大,序列化和拷贝成本本身也可能成为新的瓶颈,最好先评估一下传输开销。 整体上“
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-23 13:46 , Processed in 0.034178 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部