查看: 3703|回复: 3

HarmonyOS libuv主线程阻塞与uv_async死循环

[复制链接]
发表于 5 天前 | 显示全部楼层 |阅读模式
在 HarmonyOS 应用开发中,libuv 是底层异步 IO 的核心引擎。原文通过两个 Freeze 案例说明:异步框架并不等于业务代码天然安全,主线程调用同步接口、以及 uv_async_t 句柄跨 uv_loop_t 重复初始化,都会引发界面冻结或事件循环死锁。下面按问题现象、堆栈路径、根因和重构方案梳理可落地的排查方法。

一、主线程调用 uv 同步接口导致 UI 卡死

问题现象:应用发送视频文件时界面完全卡死,日志显示主线程阻塞在 uv_fs_sendfile。原文堆栈关键路径为:业务层 ChatViewModel.ts 的 handleSendVideo 触发,经 NAPI 调用 libfs.z.so 的 CopyFile::Sync,最终进入 libuv.so 的 uv_fs_sendfile。该接口是同步文件系统操作,执行期间占用 CPU 并等待 I/O 完成;数据量大或磁盘慢时会长时间切断主线程事件循环,UI 无法刷新。

根因:开发者在主线程(UI 线程)直接调用文件模块同步接口(Sync API)。原文错误写法如下:
  1. // ChatViewModel.ts
  2. async handleSendVideo() {
  3.   // 直接在主线程调用同步拷贝接口,导致主线程阻塞
  4.   const result = await ohos.fileio.copyFileSync(srcPath, destPath);
  5.   console.log("Copy done");
  6. }
复制代码

修复方向:把耗时同步操作移出主线程,使用 taskpool 或 worker。原文给出的 taskpool 重构示例如下:
  1. // 1. 定义任务池任务类
  2. import { taskpool } from '@kit.TaskPoolKit';
  3. class CopyFileTask implements taskpool.Task {
  4.   srcPath: string;
  5.   destPath: string;
  6.   constructor(src: string, dest: string) {
  7.     this.srcPath = src;
  8.     this.destPath = dest;
  9.   }
  10.   execute(): void {
  11.     // 在后台线程执行同步 IO,不影响主线程
  12.     try {
  13.       ohos.fileio.copyFileSync(this.srcPath, this.destPath);
  14.     } catch (e) {
  15.       console.error(`Copy failed: ${JSON.stringify(e)}`);
  16.     }
  17.   }
  18. }
  19. // 2. 业务层调用
  20. async handleSendVideo() {
  21.   try {
  22.     // 提交任务到 taskpool,立即返回,不阻塞主线程
  23.     const task = new CopyFileTask(srcPath, destPath);
  24.     await taskpool.execute(task);
  25.     console.log("Copy task submitted and completed in background");
  26.   } catch (e) {
  27.     console.error(`Task pool error: ${JSON.stringify(e)}`);
  28.   }
  29. }
复制代码

二、PDF 场景下 uv_async 事件循环死锁

问题现象:应用在 taskpool 场景调用第三方 SDK 的 so 接口时必现卡死。堆栈显示线程卡在 libuv 事件循环 uv_run,关键帧包括 uv_async_io、uv_io_poll、uv_run,并出现在 OHOSLoopHandler::OnTriggered 调用链上。

死锁机制:原文通过反编译 libuv.so 源码说明,uv__async_io 会先把 loop->async_handles 移动到临时 queue,再循环取出节点并执行 h->async_cb(h)。如果回调内部再次调用 uv_async_send 或类似操作,使同一个 h 节点重新进入当前正在遍历的 queue,while (!uv__queue_empty(&queue)) 就会持续为真,形成死循环。原文通过 GDB 调试和日志发现,同一个 uv_async_t 句柄在主线程 Loop 和 TaskWorker 线程 Loop 中都被初始化。

根因:SDK 的 LibuvSingleton 初始化存在缺陷。uv_async_init 在每次 getInstance() 后的 initialize 中被调用,没有 call_once 保护;uv_async_t 又与 uv_loop_t 强绑定,跨线程、跨 Loop 重复初始化同一个句柄会破坏内部链表结构,使不同 Loop 的队列相互交织。原文错误代码示意如下:
  1. class LibuvSingleton {
  2. public:
  3.   static LibuvSingleton &getInstance() {
  4.     static LibuvSingleton instance;
  5.     return instance;
  6.   }
  7.   void initialize(uv_loop_t *loop) {
  8.     loop_ = loop;
  9.     // 危险:每次调用都会重新初始化同一个句柄
  10.     // 如果主线程和子线程都调用了 initialize,同一个 async_handle 会被注册到两个不同的 Loop
  11.     uv_async_init(loop, &async_handle, LibuvSingleton::processTask);
  12.     async_handle.data = this;
  13.   }
  14. private:
  15.   uv_async_t async_handle;
  16.   uv_loop_t *loop_;
  17.   // ... 其他成员
  18. };
复制代码

修复方向:确保单例初始化只执行一次,并保证 Loop 与句柄绑定关系正确。原文修复版使用 std::call_once:
  1. #include <mutex>
  2. #include <atomic>
  3. class LibuvSingleton {
  4. public:
  5.   static LibuvSingleton &getInstance() {
  6.     static LibuvSingleton instance;
  7.     return instance;
  8.   }
  9.   // 使用 call_once 确保 initialize 只被调用一次
  10.   void initialize(uv_loop_t *loop) {
  11.     std::call_once(init_flag_, [this, loop]() {
  12.       this->loop_ = loop;
  13.       // 仅在第一次初始化时创建句柄
  14.       // 注意:如果需要在不同 Loop 使用,应创建不同的句柄实例,
  15.       // 而不是复用同一个 static 成员。
  16.       // 此处假设该单例仅服务于单一 Loop 上下文,或需改为线程局部存储
  17.       uv_async_init(loop, &async_handle, LibuvSingleton::processTask);
  18.       async_handle.data = this;
  19.     });
  20.   }
  21.   // ... 其他方法保持不变
  22. private:
  23.   LibuvSingleton() {}
  24.   static std::once_flag init_flag_; // 静态成员,用于控制初始化
  25.   uv_async_t async_handle;
  26.   uv_loop_t *loop_;
  27.   // ...
  28. };
  29. // 静态成员初始化
  30. std::once_flag LibuvSingleton::init_flag_;
复制代码

如果 SDK 确实需要在多个线程或 Loop 中使用异步通知,原文建议不要复用同一个 uv_async_t 实例,可改为 thread_local 存储每个线程自己的 uv_async_t 句柄;或在 initialize 中检查句柄是否已初始化,已初始化则复用,但必须确保 loop 相同,否则报错。

三、排查清单与工程约束

原文给出的排查线索包括:应用卡死在 uv_run 或 uv_io_poll;创建了多个 napi_threadsafe_function 或 uv_async_t,但回调偶尔不执行或执行异常;堆栈中出现链表遍历相关的死循环。遇到这些现象,应检查是否存在多 Loop 重复初始化问题。

落到工程实践,可归纳为四点:主线程零阻塞,任何文件 IO、网络请求、复杂计算的同步操作必须使用 taskpool、worker 或异步 API;单例初始化保护,涉及系统底层句柄(如 uv_async_t、pthread_mutex 等)的单例,务必使用 std::call_once 或互斥锁保护,防止多线程竞态;句柄生命周期管理,uv_async_t 等句柄与 uv_loop_t 强关联,切勿在多个不同的 uv_loop_t 中初始化同一个句柄实例;代码审查重点,在 Code Review 时重点关注 Native C++ 层中全局变量、静态变量在多线程环境下的初始化逻辑。

从这两个案例可以推导:HarmonyOS 应用即便使用异步框架,也要区分“框架异步”和“当前调用线程是否阻塞”;Native 层单例与系统句柄的初始化,必须按 Loop/线程边界进行设计。否则问题往往不在 ArkTS 业务代码表面,而会在 libuv 事件循环和 NAPI 调用链中表现为 Freeze。
回复

使用道具 举报

发表于 5 天前 | 显示全部楼层

Re: HarmonyOS libuv主线程阻塞与uv_async死循环

这篇整理得挺扎实的,两个案例一个偏业务代码习惯,一个偏底层句柄生命周期,正好覆盖了"异步框架不等于自动安全"这条主线,尤其是把 await 了 sync 接口这种看起来很异步、实际是同步阻塞的坑点名出来,很多人确实会误判。 顺着补充几点实践中的小坑,供参考: 一、主线程同步 IO 那块,用 taskpool 的思路没问题,但落地时要注意 Task 类需要 @Concurrent 装饰,构造参数和 execute 的返回值要能序列化,别把主线程的对象、句柄直接传进去。另外 copyFileSync 失败时 catch 里只打日志,上层拿不到失败原因,建议 Task 里显式返回结果结构体,让 handleSendVideo 能区分成功、失败和取消。还有一点,大文件拷贝即使挪到后台,也别一次性全量塞进去,可以考虑分片或者进度回调,不然用户还是会觉得"没反应"。 二、uv_async 那个死循环,call_once 是必要但不充分的。同一个 uv_async_t 天然和 uv_loop_t 强绑定,多线程、多 Loop 场景下更稳的做法是每个 Loop 各自持有一个句柄,而不是全局共享一个。另外句柄的生命周期要跟 Loop 对齐,uv_close 之后必须等 close_cb 回来才能释放对象,否则容易变成另一个方向的悬空访问。SDK 内部如果自己起线程建 Loop,最好把 Loop 和线
回复 支持 反对

使用道具 举报

发表于 5 天前 | 显示全部楼层

Re: HarmonyOS libuv主线程阻塞与uv_async死循环

看完很有收获,两个案例把“异步框架不等于业务代码安全”说得很清楚。主线程同步 IO 这个点很实用,排查时可以重点搜业务代码里直接调用的 Sync 接口,尤其是文件拷贝、发送视频这类链路,把同步操作封装到 taskpool 或 worker 里,避免事件循环被长时间切断。 uv_async 那个死循环的分析也很关键,核心是同一个 uv_async_t 句柄跨 uv_loop_t、跨线程重复初始化,破坏了内部队列结构。修复版用 call_once 保证 initialize 只执行一次是正确方向,不过结合首帖看,可能还要注意两点:一是 call_once 后不同线程传入的 loop 如果不是同一个,应该校验并拒绝二次绑定,确保句柄只属于唯一的 loop;二是回调里要避免对同一句柄再次 uv_async_send 造成重入,否则即使初始化只做一次,队列遍历仍可能被重新塞入节点。整体排查思路很落地,感谢分享。
回复 支持 反对

使用道具 举报

发表于 5 天前 | 显示全部楼层

Re: HarmonyOS libuv主线程阻塞与uv_async死循环

楼主这两个案例总结得很实在,主线程同步 IO 和 uv_async 跨 loop 重复初始化确实是两个很容易被“异步框架”表象掩盖的问题。主线程那个场景里,Sync 接口即使放在 async 函数里调用,实际还是会阻塞 UI 线程,搬到 taskpool 的方向是对的;实际落地时建议再补上任务异常回传、进度或取消处理,以及大文件、慢磁盘场景下的压测,避免任务池排队带来新的卡顿。 uv_async 死循环的根因分析也很关键,句柄和 loop 强绑定,单例初始化没有 once 保护,在多线程场景下确实容易把内部队列搅乱。修复时除了用 call_once 保证只初始化一次,最好也把句柄生命周期和线程归属一起梳理清楚,排查时重点看 uv_async_init 的调用点以及主线程和 TaskWorker 线程是否都碰过同一个句柄。首帖末尾的修复代码好像没贴完整,如果能补全 call_once 示例和验证方式就更好了。感谢分享。
回复 支持 反对

使用道具 举报

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

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

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

官方核心成员

关注微信公众号

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

GMT+8, 2026-10-27 17:23 , Processed in 0.026709 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部