ArkTS 并发实战:TaskPool 与 Worker 的边界、选型与四个典型坑
一、先说结论:ArkTS 的并发不是"多线程随便开"
HarmonyOS NEXT 的应用默认跑在一条主线程上:UI 渲染、事件分发、业务逻辑共享同一个事件循环。这条线程的帧预算大约是 16.7ms(60Hz)到 8.3ms(120Hz)。一旦你在这条线程上做了一次几十毫秒的同步计算、一次同步文件读取,或者一次大 JSON 的解析,掉帧就是必然的——用户看到的是"划不动"。
ArkTS 给出的两种官方并发手段是 TaskPool 和 Worker。很多人第一反应是"哪个更强用哪个",但这两者的定位完全不同:
| 维度 | TaskPool | Worker |
|---|---|---|
| 线程生命周期 | 托管,任务结束线程复用 | 手动创建/销毁,长期驻留 |
| 启动开销 | 小(复用线程池) | 较大(每次实例化一条线程) |
| 状态保持 | 无状态,每次都是新任务 | 可保持线程内状态 |
| 数量控制 | 由框架按负载调度 | 由开发者自己控制,容易开多 |
| 适用场景 | 一次性、可并行的计算任务 | 长期运行、需要累积状态的任务 |
一句话选型:能被切成"输入—计算—输出"的,用 TaskPool;需要一条常驻线程持续收消息、维护状态的,用 Worker。
二、TaskPool:把计算切成可并行的块
TaskPool 的核心约束是 @Concurrent 装饰器。只有被它标记的函数才能进入任务池执行,而这个装饰器有一串必须遵守的规则,踩中任何一条都会在编译期或运行期挂掉。
import { taskpool } from '@kit.ArkTS';
// 必须是顶层函数,不能是类方法
@Concurrent
function sumRange(start: number, end: number): number {
let total = 0;
for (let i = start; i < end; i++) {
total += Math.sqrt(i);
}
return total;
}
async function runParallel(): Promise<number> {
const tasks: taskpool.Task[] = [];
const chunk = 1_000_000;
for (let i = 0; i < 4; i++) {
tasks.push(new taskpool.Task(sumRange, i * chunk, (i + 1) * chunk));
}
// 并发提交,全部完成后再汇总
const results = await taskpool.execute(tasks) as number[];
let total = 0;
for (const r of results) {
total += r;
}
return total;
}
这段代码里有三个值得注意的点:
- 参数是值传递。
sumRange拿到的start、end是拷贝过去的数字,不是引用。传对象时要按结构化克隆规则走,类和原型不会跟过去。 taskpool.execute支持传任务数组,框架会自动调度到不同线程并行执行,返回顺序与提交顺序一致——这比自己手写线程数控制要稳。- 不要在
@Concurrent函数里写this。它不在原来的对象上下文里执行,this是 undefined,闭包变量也一律拿不到。
再补两个实用能力:taskpool.executeDelayed 用于"延迟多久后跑一次",taskpool.executePeriodic 用于"每隔多久跑一次",后者返回的 Task 对象可以用 taskpool.cancel(task) 取消。周期性任务一定要持有 Task 引用,否则页面退出时你没法把它停掉,它会一直跑。
三、Worker:需要一条"活着"的线程
Worker 适合另一种形态:比如一个持续接收传感器数据、在内部维护滑动窗口的处理器,或者一个需要复用已加载模型/已解析词典的服务。这类任务的特点是"启动成本高、单次消息很小、状态要累积"。
// entry/ets/workers/DataWorker.ets
import { worker, ThreadWorkerGlobalScope, MessageEvents } from '@kit.ArkTS';
const workerPort: ThreadWorkerGlobalScope = worker.workerPort;
let window: number[] = []; // 线程内状态,跨消息保留
workerPort.onmessage = (e: MessageEvents): void => {
const value = e.data as number;
window.push(value);
if (window.length > 100) {
window.shift();
}
let sum = 0;
for (const v of window) {
sum += v;
}
workerPort.postMessage(sum / window.length);
};
主线程侧:
import { worker } from '@kit.ArkTS';
const w = new worker.ThreadWorker('entry/ets/workers/DataWorker.ets');
w.onmessage = (e: MessageEvent) => {
console.info(`moving average = ${e.data}`);
};
w.postMessage(42);
// 页面销毁时必须显式终止,否则线程会一直活着
aboutToDisappear(): void {
this.w?.terminate();
}
window 这个数组是 Worker 真正有价值的地方:它活在线程内部,跨消息保留,主线程完全不用管。用 TaskPool 做同样的事,你得每次把整个窗口传进去再传回来,序列化成本比计算本身还高。
四、四个典型坑
坑 1:在 @Concurrent 函数里访问外部变量。
const factor = 2;
@Concurrent
function scale(n: number): number {
return n * factor; // 编译不过 / 运行时拿不到
}
并发函数是独立编译单元,闭包不跨线程。正确做法是把 factor 作为参数传进去。
坑 2:往并发任务里传类实例,回来发现方法没了。
结构化克隆只复制可枚举的自有属性,原型链不在复制范围内。new User('a') 传过去,回来是个长得一样的普通对象,user.getName() 直接报 not a function。要么传纯数据对象,要么在接收侧重新构造实例,要么用 Sendable 对象(API 11 起支持,@Sendable 类可跨线程共享引用)。
坑 3:用 TaskPool 跑阻塞 IO。
任务池的线程数是有限的,被几个同步文件读写或者同步网络请求占住之后,后续计算任务只能排队。TaskPool 适合 CPU 密集型计算;IO 请走异步 API,或者干脆交给 Worker。
坑 4:高频小消息把 Worker 用成了"更慢的同步调用"。
postMessage 每次都要序列化。如果一帧内发几十条小消息,序列化和调度开销会超过计算本身,主线程反而更卡。正确做法是在主线程侧做批量合并(比如攒够 16ms 的数据一次发),或者改用 SharedArrayBuffer + Sendable,把"传数据"变成"共享内存"。
五、怎么验证自己真的优化了
不要凭感觉。至少做两件事:
- 在任务前后用
Date.now()打点,配合hilog输出耗时,确认计算确实离开了主线程; - 用 DevEco Studio 的 Profiler 看主线程的帧耗时分布,重点看有没有超过 16ms 的长帧。
如果优化后长帧还在,多半不是计算没搬走,而是搬走之后又在主线程上做了等结果之外的活——比如在 await 之后同步处理了几万条数据。把这段处理也一起塞进并发函数,才是完整的优化。
六、小结
TaskPool 和 Worker 不是替代关系:TaskPool 解决"把一次计算并行拆开",Worker 解决"给一类任务一条常驻线程"。真正决定性能的往往不是选哪个 API,而是有没有把序列化成本算进去、有没有把状态放对地方、有没有在页面退出时把线程收干净。
写并发代码时先问自己三个问题:这份数据要跨线程几次?这条线程要活多久?退出时谁来关掉它?想清楚这三个,剩下的 API 调用其实很少。
- 点赞
- 收藏
- 关注作者
评论(0)