初学者的鸿蒙多线程并发之 TaskPool 踩坑之旅
1. 背景
-
目标群体:鸿蒙初学者
-
版本:HarmonyOS 3.1/4.0
-
背景:鸿蒙 App 的全局路由管理功能,需要在 App 启动时初始化对 raw 下的相关配置文件进行读取、解析并缓存。App 启动时涉及到了大量模块的初始化,好多模块都涉及到 IO 以及计算操作。鸿蒙的 ArkTS 是在继承 TypeScript 语法的基础上进行了优化,但是其脱离不了 js,js 又是单线程的,故担忧其性能。果断查阅官方文档描述,不出所料官方是这么回复的:
ArkTS 层接口的异步如果不涉及 I/O 操作,则异步任务会在主线程的微任务执行时机触发,仍然占用主线程。推荐使用 TaskPool,分发到后台任务池进行。
就是这个回复,让我这个初学者开启了多线程异步任务的踩坑之旅。
2. 并发
2.1 概述
并发是指在同一时间段内,能够处理多个任务的能力。HarmonyOS 系统提供了异步并发和多线程并发两种处理策略。
-
异步并发是指异步代码在执行到一定程度后会被暂停,以便在未来某个时间点继续执行,这种情况下,同一时间只有一段代码在执行。
-
多线程并发允许在同一时间段内同时执行多段代码。在主线程继续响应用户操作和更新 UI 的同时,后台也能执行耗时操作,从而避免应用出现卡顿。
ArkTS 支持异步并发和多线程并发。
-
Promise 和 async/await 提供异步并发能力,适用于单次 I/O 任务的开发场景。详细请参见异步并发概述。(这个就是 js 的异步任务,官方文档资料中也指出其适用于单次 I/O 的场景开发,例如一次网络请求、一次文件读写等操作。)
-
TaskPool 和 Worker 提供多线程并发能力,适用于 CPU 密集型任务、I/O 密集型任务和同步任务等并发场景。详细请参见多线程并发概述。(我们需要的就是这个,App 启动过程中涉及大量 IO、计算等操作)。当任务不需要长时间(3 分钟)占据后台线程,而是一个个独立的任务时,推荐使用 TaskPool。
2.2 多线程并发之 TaskPool
并发模型是用来实现不同应用场景中并发任务的编程模型,常见的并发模型分为基于内存共享的并发模型和基于消息通信的并发模型。
Actor 并发模型作为基于消息通信并发模型的典型代表,不需要开发者去面对锁带来的一系列复杂偶发的问题,同时并发度也相对较高,因此得到了广泛的支持和使用。
当前 ArkTS 提供了 TaskPool 和 Worker 两种并发能力,TaskPool 和 Worker 都基于 Actor 并发模型实现。
PS:TaskPool 会随着应用进程起一个线程,省去了首次任务执行创建线程的开销,线程创建开销较小。
鸿蒙的多线程并发都是基于 Actor 并发模型实现,不是基于内存共享的。你不需要考虑对内存上锁导致的一系列功能、性能问题。但是 Actor 并发模型每一个线程都是一个独立 Actor,每个 Actor 有自己独立的内存,Actor 之间通过消息传递机制触发对方 Actor 的行为,不同 Actor 之间不能直接访问对方的内存空间。Actor 并发模型线程之间是内存隔离的。
3.TaskPool 开发流程(踩坑之旅)
好的,看完文档我就开始按照官方流程进行了如下代码编写:
1. 使用@Concurrent 注解定义并发函数,在函数中执行 IO、计算等耗时操作。
//并发函数
@Concurrent
async function loadDnsTable(): Promise<Map<string, string>> {
//TODO:读取raw下的资源文件,对齐进行解析并且缓存
}
2. Harmony 要求并发函数必须是全局 function 不能是类方法,???那我如何调用我自己创建的 RawTableReader 类的方法去读取、解析并且缓存路由表。故再次翻阅官方文档,终于在 FAQ 中找到了答案:如何将类 Java 语言的线程模型(内存共享)的实现方式转换成在 ArkTS 的线程模型下(内存隔离)的实现方式话说你们就不能将他写在 taskpool 文档里么?!
export interface RawTableReader extends lang.ISendable {
readRawTable(context: common.Context): Map<string, string>;
}
并发函数修改后如下:
@Concurrent
function loadDnsTable(args: Object[]): Map<string, string> {
let rawTableReader: RawTableReader = args[0] as RawTableReader;
//此处的context类是EntryAbility启动时后注入到VirtualDomain单例类中的
let context: common.Context = VirtualDomain.getInstance().getAppContext();
return rawTableReader.readRawTable(context);
}
3. 使用 TaskPool 执行包含密集 I/O 的并发函数:通过调用 execute()方法执行任务,并在回调中进行调度结果处理。
let task: taskpool.Task = new taskpool.Task('vdn', loadDnsTable, rawTableReader);
taskpool.execute(task).then((result: Object) => {
let r: Map<string, string> = result as Map<string, string>;
}).catch((error: BusinessError) => {
VdnLog.warn(`loadDnsTable error code = ${error.code} message = ${error.message}`);
});
4. 好了开发完了,我开始了我的一次运行。不出意外报错了,断点调试半天大致意思是:context is undefined
-
难道单例类没初始化注入 context?检查代码以及断点再次尝试,EntryAbility 启动时已经注入了全局 context。
-
好吧,那我直接在 context 获取地方进行断点。又是小半天过去,我发现了问题两次调用 VirtualDomain.getInstance()返回的实例竟然不是一个?! ! .
-
我又思考并且到处翻阅文档好久,总算想起来了 Harmony 的多线程是基于 Actor 的内存隔离的不是内存共享的,我在主线程注入的 context 的 VirtualDomain 单例对象跟我子线程获取到的根本就不是一个,那肯定就 undefined 了。
5. 我想起来之前华为的官方人员在 FAQ 中回复可以使用应用全局状态存储 AppStorage 缓存 context 对象,于是我继续修改代码 context 改为使用官方全局单例 AppStorage 进行存储获取。结果是:再次失败,好了我用实践证明了官方的 AppStorage 在多线程情况下也是有问题的。大家使用时一定注意!
6. 我就不信一个 context 我就解决不了了?再次查阅官方文档皇天不负苦心人,我再次找到了 TaskPool 和 Worker 支持的序列化类型这篇文档里描述了 context 是 Native 绑定对象可以在 TaskPool 中进行序列化传输。因此再次修改代码
export calss xxx {
...
let context: common.Context = VirtualDomain.getInstance().getAppContext();
let task: taskpool.Task = new taskpool.Task('vdn', loadDnsTable, rawTableReader, context);
...
}
@Concurrent
function loadDnsTable(args: Object[]): Map<string, string> {
let rawTableReader: RawTableReader = args[0] as RawTableReader;
let context: common.Context = args[1] as common.Context;
return rawTableReader.readRawTable(context);
}
7.这次代码直接报错了 Casting "Non-sendable" data to "Sendable" type is not allowed (arkts-sendable-as-expr) <ArkTSCheck>
我按照你的官方 task api 构建的 task,你也说了 context 是 Native 绑定对象是支持的序列化类型。结果 let context: common.Context = args[1] as common.Context; 你直接给我编译报错?
8. 继续思考,进行了如下修改,编译 OK,运行测试也👌🏻,我的踩坑之旅总算结束了😭。
//虽然入参是Object[]对象,这里的args要注意必须使用lang.ISendable[],否则就会编译报错
@Concurrent
function loadDnsTable(args: lang.ISendable[]): Map<string, string> {
let rawTableReader: RawTableReader = args[0] as RawTableReader;
let context: common.Context = args[1] as common.Context;
return rawTableReader.readRawTable(context);
}
4. 总结
4.1 技术经验
-
Harmony 单次 IO 可以使用异步任务,如果涉及到多次 IO 或者大量计算建议使用多线程异步并发,异步任务的微任务也会有一定程度卡顿。
-
Harmony 的多线程是基于 Actor 内存隔离的,单例是失效的,如果需要使用相关成员变量或者方法请进行序列化传输
-
Harmony 官方的全局状态存储 AppStorage 在多线程情况下也是失效的
-
Harmony 的 TaskPool 会随着应用进程起一个线程,省去了首次任务执行创建线程的开销,线程创建开销较小
-
如果是时长大于 3 分钟的耗时任务,需要使用 Worker
4.2 后续
-
taskpool 还支持组任务、取消任务、依赖任务。
-
worker 的用法
-
未来 Harmony 正式版推出之后是否会推出 App 进程下线程可以共享数据的模块
5. 团队介绍
「三翼鸟数字化技术平台-智家APP平台」通过持续迭代演进移动端一站式接入平台为三翼鸟APP、智家APP等多个APP提供基础运行框架、系统通用能力API、日志、网络访问、页面路由、动态化框架、UI组件库等移动端开发通用基础设施;通过Z·ONE平台为三翼鸟子领域提供项目管理和技术实践支撑能力,完成从代码托管、CI/CD系统、业务发布、线上实时监控等Devops与工程效能基础设施搭建。