HarmonyOS 6.1 TaskPool 并发线程池实战:把主线程从“堵成狗“到“不卡UI“
基于 API 12+ 实战 Demo,从主线程阻塞讲到 TaskGroup 批量并发,附带完整可运行代码和踩坑记录
写在前面
做鸿蒙开发的人,大概都经历过这个场景:页面上一个按钮点击之后,整个界面就冻住了。滑动没反应,动画卡死,甚至系统弹个 ANR 对话框出来。最后查了半天,发现是在 onClick 里写了个 JSON 解析,或者搞了个大数据排序,直接把主线程给堵了。
ArkTS 的运行时模型是单线程事件循环——UI 渲染、手势事件、业务逻辑全挤在主线程上。你但凡在主线程搞个几毫秒的阻塞,帧率就会掉。超过几百毫秒,用户就能感知到卡顿。超过 5 秒,系统直接给你弹 ANR。这不是危言耸听,这是每个鸿蒙开发者迟早要面对的现实。
解决方案就是把耗时任务扔到后台线程去跑。HarmonyOS 提供了两套并发方案:TaskPool 和 Worker。其中 TaskPool 是官方推荐的主力方案,用好了真的能让主线程飞起来。这篇文章就把我用 TaskPool 的实战经验全部整理出来,从原理到踩坑,一次讲清楚。

一、为什么是 TaskPool?先搞清楚问题的根源
1.1 主线程阻塞的连锁反应
先看一个最典型的场景。假设你需要在页面上计算 1000 万次数学运算:
// 在主线程直接跑——这就是灾难的开始
Button('计算')
.onClick(() => {
let result: number = 0;
for (let i = 0; i < 10000000; i++) {
result += Math.sqrt(i) * Math.sin(i) * Math.cos(i);
}
this.result = result.toFixed(2);
})
这段代码看起来没什么问题,点一下就出结果了嘛。但实际运行的时候,for 循环是同步执行的,主线程被完全占住,期间所有的事件处理和 UI 渲染都会暂停。用户看到的就是:点了按钮之后界面冻住了,等计算完才恢复。
这个连锁反应是这样的:
- 主线程被阻塞 → 事件循环停滞
- 事件循环停滞 → vsync 信号无法响应,渲染管线中断
- 渲染管线中断 → 帧率从 60fps 掉到个位数
- 持续阻塞 > 5s → 系统判定应用无响应,弹出 ANR 对话框
这就是所谓的 UI Jank。用户感知到的就是"这 App 怎么这么卡",或者更直接的——“这 App 垃圾”。
1.2 TaskPool 怎么解决这个问题
TaskPool 的思路很简单:主线程只负责 UI 和事件处理,耗时计算扔到工作线程去跑,算完了把结果拿回来更新 UI。
主线程: [UI渲染] → [事件处理] → [提交任务] → [继续响应UI] → [拿到结果] → [更新UI]
↓ ↑
工作线程: [执行耗时计算] ──────────────── 结果返回
关键点在于:任务提交之后,主线程立刻就回来了,可以继续响应用户操作、处理渲染帧。等工作线程算完了,Promise resolve,主线程在下一个微任务里拿到结果更新 UI。整个过程用户感知不到任何卡顿。

二、@Concurrent 装饰器:TaskPool 的入场券
要把一个函数扔到 TaskPool 里执行,第一步就是用 @Concurrent 装饰器标记它。这不是可选的,是强制的——不标记就报错,没有任何商量余地。
2.1 基本用法
import { taskpool } from '@kit.ArkTS';
@Concurrent
function heavyCompute(iterations: number): number {
let result: number = 0;
for (let i = 0; i < iterations; i++) {
result += Math.sqrt(i) * Math.sin(i) * Math.cos(i);
}
return result;
}
@Concurrent 告诉编译器两件事:第一,这个函数会被工作线程执行;第二,这个函数需要满足线程安全约束。编译器会根据这个标记做静态检查,不合规的直接编译报错。
2.2 五条铁律
使用 @Concurrent 有五条必须遵守的规则,违反任何一条都会导致运行时错误(错误码 10200014):
规则一:只能装饰模块顶层函数
// 正确:顶层函数
@Concurrent
function processData(data: number): number {
return data * 2;
}
// 错误:类方法
class Calculator {
@Concurrent // 编译错误!
compute(n: number): number {
return n * n;
}
}
// 错误:闭包/箭头函数
@Concurrent // 编译错误!
const compute = (n: number): number => n * n;
这个限制的根本原因是:TaskPool 执行函数时,需要把函数序列化后传到工作线程。类方法隐含了 this 指向,闭包捕获了外部变量,这些都无法安全地跨线程传递。顶层函数是"干净"的——没有 this、没有闭包、只有入参和 import 的模块,可以被安全地结构化克隆。
规则二:参数和返回值必须是可序列化类型
可序列化的类型包括:
- 基本类型:
number、string、boolean、null、undefined - 日期:
Date - 正则:
RegExp - 集合:
Array、Map、Set - 二进制:
ArrayBuffer及各种 TypedArray - 普通对象:属性值也必须是可序列化类型
不可序列化的类型:
- 函数(包括回调)
Error对象- DOM 元素
- 带有循环引用的对象
- 包含上述不可序列化类型的嵌套对象
规则三:函数内部不可访问 UI 上下文
@Concurrent
function badFunction(): void {
// 错误!工作线程没有 UI 上下文
let context = getContext(this);
let resourceManager = context.resourceManager;
}
工作线程压根就没有 UI 上下文,你调 getContext()、访问 UIAbilityContext 这些东西,运行时直接报错。UI 相关的操作必须在主线程完成,这一点没有变通余地。
规则四:不可访问外部非 @Concurrent 模块的状态
@Concurrent 函数只能访问自己的入参和 import 的模块。组件里的 @State、@Local 这些状态变量,在工作线程里是看不到的——线程间内存隔离,各跑各的。
规则五:违反规则报 10200014
这条不是规则,是违反规则的后果。错误码 10200014 的官方描述是:"The function is not marked as concurrent."但实际上,这个错误码不仅仅出现在"忘了加 @Concurrent"的场景,以下情况都可能触发 10200014:
- 函数没加 @Concurrent 装饰器
- @Concurrent 函数内部访问了不可序列化的数据
- 传入了类方法或闭包作为任务函数
- @Concurrent 函数内部捕获了外部变量
三、taskpool.execute():最直接的执行方式
3.1 直接传入函数
最简单的用法,直接把 @Concurrent 函数和参数传给 taskpool.execute():
@Concurrent
function fibonacci(n: number): number {
if (n <= 1) return n;
let a: number = 0;
let b: number = 1;
for (let i: number = 2; i <= n; i++) {
const temp: number = a + b;
a = b;
b = temp;
}
return b;
}
// 直接执行
async function runFibonacci(): Promise<void> {
const result: Object = await taskpool.execute(fibonacci, 40);
console.info('fib(40) = ' + result.toString());
}
taskpool.execute() 返回的是一个 Promise<Object>,用 await 拿结果就行。这里有个细节——返回值的类型是 Object,不是泛型。所以拿到结果后需要用 Number() 将 Object 转为 number:
const numResult: number = Number(poolResult);
注意 ArkTS 严格模式下禁止使用 as 类型断言(如 poolResult as number),应使用 Number() 进行安全的类型转换。这不是设计缺陷,是因为工作线程返回的数据经过了结构化克隆,类型信息在传输过程中会丢失一部分,统一用 Object 接收更安全。
注意:直接传入函数的方式不支持取消任务。 你一旦 execute 了,就只能等它跑完。如果需要取消能力,就得用 Task 对象。
3.2 创建 Task 对象
Task 对象是任务的高级形态,支持取消、设置优先级、添加依赖等操作:
@Concurrent
function heavyCompute(iterations: number): number {
let result: number = 0;
for (let i = 0; i < iterations; i++) {
result += Math.sqrt(i) * Math.sin(i) * Math.cos(i);
}
return result;
}
// 创建 Task 对象
const task: taskpool.Task = new taskpool.Task(heavyCompute, 10000000);
// 执行并指定优先级
const result: Object = await taskpool.execute(task, taskpool.Priority.HIGH);
// 如果需要取消(在任务开始执行前)
taskpool.cancel(task);
Task 对象 vs 直接执行的区别:
| 特性 | 直接传入函数 | Task 对象 |
|---|---|---|
| 取消任务 | 不支持 | 支持 |
| 设置优先级 | 不支持 | 支持 |
| 添加依赖 | 不支持 | 支持 |
| 延时执行 | 不支持 | 支持 |
| 代码简洁度 | 更简洁 | 稍冗余 |
如果你的任务是一次性的、不需要取消,直接传函数更简洁。如果任务可能需要取消(比如用户切了页面、取消了操作),就得用 Task 对象。
四、TaskGroup:批量任务的并发利器
4.1 为什么需要 TaskGroup
实际项目中经常遇到这种场景:一批同类型的任务需要并行执行,全部完成后汇总结果。比如同时解码 5 张图片、同时排序 5 组数据、同时计算 5 个区间的统计值。
你当然可以手动创建 5 个 Task 然后 Promise.all(),但 TaskGroup 提供了更优雅的方式:
@Concurrent
function sortArray(size: number): number {
const arr: number[] = [];
for (let i = 0; i < size; i++) {
arr.push(Math.random() * 10000);
}
for (let i = 0; i < arr.length - 1; i++) {
for (let j = 0; j < arr.length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
const temp: number = arr[j];
arr[j + 1] = temp;
arr[j] = arr[j + 1];
}
}
}
return arr.length;
}
async function runTaskGroup(): Promise<void> {
const group: taskpool.TaskGroup = new taskpool.TaskGroup();
group.addTask(sortArray, 5000);
group.addTask(sortArray, 8000);
group.addTask(sortArray, 10000);
group.addTask(sortArray, 12000);
group.addTask(sortArray, 15000);
const results: Object[] = await taskpool.execute(group) as Object[];
// results 是一个数组,顺序与 addTask 的顺序一致
results.forEach((val: Object, idx: number) => {
console.info('任务' + (idx + 1).toString() + ': 排序' + Number(val).toString() + '个元素');
});
}
4.2 TaskGroup 的执行机制
TaskGroup 提交后,系统会自动把组内的任务分配到不同的工作线程并行执行。执行顺序由系统调度决定,但返回结果的顺序保证与 addTask 的顺序一致——这一点和 Promise.all 的行为是一样的。
几个注意点:
- TaskGroup 内的任务也是独立执行的,任务之间不能有依赖关系
- 结果类型是
Object[],需要逐个类型断言 - TaskGroup 也支持取消:
taskpool.cancel(group)可以取消整组任务 - 如果其中一个任务失败,整组的 Promise 会 reject
4.3 实测:TaskGroup 的性能收益
在我们的 Demo 中,5 个不同规模的冒泡排序任务,用 TaskGroup 并行执行的总耗时,远低于在主线程顺序执行的总耗时。因为 5 个任务被分配到了不同的工作线程同时跑,实际等待时间约等于最慢的那个任务的时间,而不是所有任务时间之和。
这在数据密集型场景下收益特别明显。比如你有一批图片要解码、一批 JSON 要解析、一批文件要哈希——只要任务之间没有依赖关系,全部扔进 TaskGroup 就对了。

五、Priority:任务也有三六九等
5.1 三个优先级
TaskPool 提供三个优先级等级:
| 枚举值 | 数值 | 说明 |
|---|---|---|
taskpool.Priority.HIGH |
0 | 高优先级,优先调度 |
taskpool.Priority.MEDIUM |
1 | 中优先级(默认值) |
taskpool.Priority.LOW |
2 | 低优先级,最后调度 |
优先级影响的是任务在队列中的调度顺序,而不是执行的权重。高优先级任务会先被分配到空闲工作线程,低优先级任务在队列里等着。如果所有工作线程都忙,即使是 HIGH 优先级的任务也得排队。
5.2 怎么设优先级
// 方式一:execute 时指定
taskpool.execute(task, taskpool.Priority.HIGH);
// 方式二:不指定,默认 MEDIUM
taskpool.execute(task);
5.3 实际项目中的优先级策略
优先级的设置应该根据任务对用户体验的影响程度来决定:
- HIGH:直接影响当前可见 UI 的任务。比如当前屏幕正在显示的图片解码、当前页面的数据加载。用户在等这个结果,等不了就不爽。
- MEDIUM:一般的业务逻辑计算。大多数任务用默认值就行,不用特意指定。
- LOW:预加载、预计算类的任务。比如图库预加载左右两侧的缩略图、首页预加载二级页面的数据。这类任务做完了更好,没做完也不影响当前体验。
一个典型的场景是图库大图浏览:当前显示的图片用 HIGH 优先级解码,左右预加载的图片用 LOW 优先级。当用户往右滑动切换下一张时,取消左侧预加载任务,新当前图用 HIGH,右侧新预加载用 LOW。这种策略能保证用户感知到的加载速度始终是最优的。
六、TaskPool vs Worker:到底选谁?
这是被问得最多的问题。官方说法是"大多数场景推荐 TaskPool",但"大多数"到底是多大?先看一张完整对比表:
| 对比维度 | TaskPool | Worker |
|---|---|---|
| 线程管理 | 系统自动管理,开发者无需关心 | 手动创建和销毁 |
| 生命周期 | 任务维度,跑完即走 | 线程维度,长期驻留 |
| 任务时长 | 上限 3 分钟(不含异步等待),超时被系统回收 | 无限制 |
| 线程数量 | 自动管理,上限为 CPU 核数 - 1 | 开发者自行管理,同进程最多 64 个 |
| 通信方式 | Promise 返回结果 | postMessage 双向通信 |
| 任务取消 | 支持 | 不支持 |
| 优先级 | 支持 HIGH/MEDIUM/LOW | 不支持(API 18 前) |
| 线程复用 | 支持,空闲线程可被复用 | 不支持,每个 Worker 独立 Runtime |
| 内存开销 | 低(共享线程池) | 高(每个 Worker 独立堆空间) |
| 任务组 | 支持 TaskGroup | 不支持 |
| 串行队列 | 支持 SequenceRunner | 不支持 |
| 函数要求 | 顶层 @Concurrent 函数 | 完整的 .ets 入口文件 |
| 创建耗时 | 低(线程池预热) | 高(需要初始化独立 Runtime) |
| 参数传递 | 直接传递,自动序列化 | 需封装为消息对象 |
| LongTask | 支持,无时长限制但需手动 terminateTask | 天然支持 |
6.1 从底层模型理解差异
TaskPool 是任务维度。你提交一个任务,系统从线程池里挑一个空闲线程去跑,跑完了线程回池子等着接下一个活。线程的创建、扩容、缩容全由系统负责。开发者只管提交任务和拿结果,不用关心线程从哪来、到哪去。
TaskPool 的扩缩容机制是这样的:
- 扩容:提交任务时触发扩容检测,如果空闲线程数不够,就根据负载计算创建新线程。上限是 CPU online 核数 - 1,8 核设备最多 7 个工作线程。
- 缩容:每 30 秒检测一次,如果线程空闲超过 30s 且没有长时任务、没有未释放的句柄、没有子 Worker,就回收。
Worker 是线程维度。你 new worker.ThreadWorker() 创建一个线程,这个线程就会一直存在,直到你主动调 terminate() 销毁。每个 Worker 有独立的运行环境——独立的事件循环、独立的全局对象、独立的堆空间。Worker 和宿主线程之间通过 postMessage() 传递消息,数据经过结构化克隆(深拷贝)后传输。
6.2 通信模型差异
TaskPool 的通信是"一锤子买卖":提交任务 → 等结果 → 拿到结果就完事。整个过程是 Promise 驱动的,单向的,简洁明了。
Worker 的通信是"持续对话":主线程 postMessage() 发消息,Worker onmessage 收消息,处理完再 postMessage() 发回来。这是双向的、持续的,适合需要多轮交互的场景。
这种差异决定了两者的适用场景截然不同。
6.3 内存开销差异
这个差异很多人没意识到。每个 Worker 线程启动时都要分配独立的运行环境,包括独立的堆空间、全局对象、事件循环等。这个开销是不小的——一个 Worker 实例的内存占用大约在 1-2MB 左右(具体数值取决于设备和使用情况)。
TaskPool 的工作线程是共享的,线程池里的线程可以被多个任务复用。如果你有 100 个独立任务要跑,用 TaskPool 可能只需要 7 个工作线程就能搞定(串行复用),用 Worker 就得创建 100 个实例(虽然不太现实,但理论上如此)。
七、纯计算对比(1000万次)
7.1 对比方案
Demo 中的纯计算对比:同样的 1000 万次数学运算,一次在主线程跑,一次在 TaskPool 跑。主线程执行时 UI 卡死,TaskPool 执行时 UI 不卡——这就是核心差异。
private async runComputeComparison(): Promise<void> {
this.isRunning = true;
const iterations: number = 10000000;
// 主线程执行(会卡UI)
const mainStart: number = Date.now();
let mainResult: number = 0;
for (let i = 0; i < iterations; i++) {
mainResult += Math.sqrt(i) * Math.sin(i) * Math.cos(i);
}
const mainEnd: number = Date.now();
this.mainThreadTime = (mainEnd - mainStart).toString() + 'ms (卡UI)\n结果: ' + mainResult.toFixed(2);
// TaskPool 执行(不卡UI)
const poolStart: number = Date.now();
try {
const poolResult: Object = await taskpool.execute(heavyCompute, iterations);
const poolEnd: number = Date.now();
const numResult: number = Number(poolResult);
this.taskPoolTime = (poolEnd - poolStart).toString() + 'ms (不卡UI)\n结果: ' + numResult.toFixed(2);
this.addLog('纯计算对比完成: 主线程' + (mainEnd - mainStart).toString() + 'ms vs TaskPool' + (poolEnd - poolStart).toString() + 'ms');
} catch (e) {
this.taskPoolTime = '执行失败: ' + String(e);
}
this.isRunning = false;
}
7.2 结果分析
实际测试中,你会发现一个有意思的现象:
纯计算耗时:主线程和 TaskPool 差不多,甚至 TaskPool 可能还略慢一点。这是因为 TaskPool 有任务调度和序列化的额外开销——提交任务、序列化参数、跨线程传输、反序列化结果,这些都需要时间。纯数学计算主线程通常更快,因为 TaskPool 有线程调度开销。
但是—— 关键不在于计算本身快不快,而在于主线程有没有被堵住。
主线程跑的时候,整个 UI 是冻住的,按钮点了没反馈,列表滑不动,动画停了。TaskPool 跑的时候,任务提交之后主线程立刻就回来了,UI 可以正常响应用户操作。用户感知到的流畅度是天壤之别。
所以 TaskPool 的核心价值不是"让计算更快",而是"让主线程不被堵住"。计算该花多长时间还得花多长时间,但不会影响 UI 的响应。
7.3 JSON解析对比(5000条数据)
纯数学计算主线程可能更快,但 JSON 解析场景不同——stringify/parse 的内存分配和 GC 开销更重,而且 TaskPool 的真正价值不是"更快",而是"不卡UI"。
JsonItem 接口定义
interface JsonItem {
id: number;
name: string;
score: number;
active: boolean;
}
@Concurrent 函数:parseLargeJson
@Concurrent
function parseLargeJson(size: number): number {
const arr: JsonItem[] = [];
for (let i = 0; i < size; i++) {
const item: JsonItem = {
id: i,
name: 'item_' + i.toString(),
score: Math.random() * 100,
active: i % 2 === 0
};
arr.push(item);
}
const jsonStr: string = JSON.stringify(arr);
const parsed: JsonItem[] = JSON.parse(jsonStr) as JsonItem[];
return parsed.length;
}
主线程 vs TaskPool JSON对比方法
private async runJsonComparison(): Promise<void> {
this.isRunning = true;
const size: number = 5000;
// 主线程执行(会卡UI)
const mainStart: number = Date.now();
const arr: JsonItem[] = [];
for (let i = 0; i < size; i++) {
const item: JsonItem = {
id: i,
name: 'item_' + i.toString(),
score: Math.random() * 100,
active: i % 2 === 0
};
arr.push(item);
}
const jsonStr: string = JSON.stringify(arr);
const parsed: JsonItem[] = JSON.parse(jsonStr) as JsonItem[];
const mainEnd: number = Date.now();
this.mainThreadJsonTime = (mainEnd - mainStart).toString() + 'ms (卡UI)\n解析' + parsed.length.toString() + '条';
// TaskPool 执行(不卡UI)
const poolStart: number = Date.now();
try {
const poolResult: Object = await taskpool.execute(parseLargeJson, size);
const poolEnd: number = Date.now();
const numResult: number = Number(poolResult);
this.taskPoolJsonTime = (poolEnd - poolStart).toString() + 'ms (不卡UI)\n解析' + numResult.toString() + '条';
this.addLog('JSON对比完成: 主线程' + (mainEnd - mainStart).toString() + 'ms vs TaskPool' + (poolEnd - poolStart).toString() + 'ms');
} catch (e) {
this.taskPoolJsonTime = '执行失败: ' + String(e);
}
this.isRunning = false;
}
JSON解析有stringify/parse开销,TaskPool异步不卡UI才是真正优势。主线程跑JSON解析时UI会冻住,TaskPool跑时UI可正常交互。
7.4 Fibonacci 阶梯测试
Demo 中还设计了 Fibonacci 的阶梯测试,分别计算 fib(30)、fib(40)、fib(45):
private async runFibonacci(n: number): Promise<void> {
const start: number = Date.now();
try {
const result: Object = await taskpool.execute(fibonacci, n);
const end: number = Date.now();
this.fibonacciResult = 'fib(' + n.toString() + ') = ' + Number(result).toString() + ', 耗时' + (end - start).toString() + 'ms';
this.addLog('fibonacci(' + n.toString() + ') = ' + Number(result).toString());
} catch (e) {
this.fibonacciResult = '失败: ' + String(e);
}
}
随着 n 的增大,计算耗时呈指数增长。fib(30) 可能几毫秒就完事,fib(40) 就需要几十毫秒,fib(45) 可能要到上百毫秒甚至更多。如果在主线程跑 fib(45),用户一定能感知到卡顿。但扔到 TaskPool 里,主线程完全不受影响。
这个测试也说明了一个问题:不是只有"很慢"的任务才需要用 TaskPool,任何可能超过 16ms(一帧的时间)的同步计算都应该考虑放到后台线程。 16ms 是 60fps 的帧预算,超过这个时间就会掉帧。你对"掉帧"可能没有感知,但用户对"不流畅"的感知是极其敏锐的。
八、错误码 10200014 排查手册
10200014 是 TaskPool 最常见的错误码,官方描述是"The function is not marked as concurrent.“但实际上触发这个错误的原因远不止"忘了加 @Concurrent”。
8.1 常见触发场景及解决方案
场景一:真的忘了加 @Concurrent
最简单也最常见的原因。解决方案就是加上装饰器。
// 错误
function compute(n: number): number { return n * 2; }
taskpool.execute(compute, 10); // 10200014
// 正确
@Concurrent
function compute(n: number): number { return n * 2; }
taskpool.execute(compute, 10);
场景二:传入了类方法
类方法隐含了 this 绑定,无法跨线程传递。
// 错误
class Calculator {
@Concurrent // 编译就不会让你过
compute(n: number): number { return n * n; }
}
// 正确:把逻辑提取为顶层函数
@Concurrent
function computeN(n: number): number { return n * n; }
场景三:@Concurrent 函数内部访问了不可序列化的数据
函数的参数或返回值包含了函数、Error、循环引用等不可序列化类型。
// 错误:返回值包含函数
@Concurrent
function badReturn(): Object {
return { callback: () => {} }; // 函数不可序列化
}
// 正确:只返回可序列化数据
@Concurrent
function goodReturn(): number {
return 42;
}
场景四:@Concurrent 函数内部访问了 UI 上下文
// 错误
@Concurrent
function readResource(): string {
let context = getContext(); // 工作线程没有 UI 上下文
return context.resourceManager.getStringSync(0x12345678);
}
// 正确:在主线程获取资源,把结果传入
@Concurrent
function processData(data: string): string {
return data.toUpperCase();
}
// 主线程
let str = getContext().resourceManager.getStringSync(0x12345678);
taskpool.execute(processData, str);
8.2 排查思路
遇到 10200014,按以下步骤排查:
- 检查传入 taskpool.execute 的函数是否有 @Concurrent 装饰器
- 检查函数是否是顶层函数(不是类方法、不是箭头函数赋值、不是闭包)
- 检查函数的入参类型是否全部可序列化
- 检查函数的返回值类型是否可序列化
- 检查函数内部是否访问了 UI 上下文或外部组件状态
- 检查函数内部是否引用了不可序列化的 import
如果以上都检查过了还是报错,可以尝试把函数内容逐行注释,逐步定位到底是哪一行触发的。这种排除法虽然笨,但对 10200014 这种"描述简单、原因复杂"的错误特别有效。
九、什么时候选 Worker 而不是 TaskPool?
虽然官方说"大多数场景用 TaskPool",但有几个场景你必须选 Worker:
9.1 任务执行时间超过 3 分钟
TaskPool 对普通任务有 3 分钟的执行时长限制(不包含 Promise/async-await 的异步等待时间)。超过 3 分钟,系统会强制回收任务。
注意这个"3 分钟"指的是 CPU 实际执行时间,不是挂钟时间。如果你的任务里有网络请求、文件读写等异步操作,这些异步等待的时间是不算在内的。只有 CPU 在密集计算的同步时间才算。
但如果你的任务是纯 CPU 密集型的——比如后台跑一个机器学习推理模型,预计要跑 1 小时——那就不能用 TaskPool,必须用 Worker。Worker 没有执行时长限制,线程会一直存在直到你主动销毁。
当然,TaskPool 从 API 12 开始也支持 LongTask(长时任务),没有执行时长限制。但 LongTask 的工作线程会一直存在,直到调用 terminateTask() 后才会在空闲时被回收。本质上 LongTask 的行为已经接近 Worker 了,这时候要权衡的是 API 风格的偏好。
9.2 需要维护线程状态
有些任务不是"跑完就扔"的,而是需要一个长期存活的后台线程来持续处理事件。比如:
- 音频解码线程:需要持续读取音频帧、解码、送播放
- WebSocket 长连接:需要持续监听消息收发
- 后台数据同步:需要定期拉取服务端数据,维护本地缓存
- 硬件句柄管理:每次创建的句柄都不同,需要永久保存
这些场景的核心特征是:线程需要维护内部状态,多轮操作之间有上下文关联。TaskPool 的设计是"无状态"的——每次任务执行都是独立的,任务之间无法共享线程内的状态。Worker 天生就是"有状态"的——线程一直存在,内部变量可以跨任务共享。
9.3 需要双向持续通信
TaskPool 的通信模型是 Promise 驱动的单向通信:提交任务 → 等结果。如果你需要在任务执行过程中持续和主线程交互(比如进度回调、增量结果推送),TaskPool 就不够用了。
Worker 的 postMessage 双向通信机制天然支持这种场景:
// 主线程
myWorker.postMessage({ type: 'start' });
myWorker.onmessage = (event) => {
if (event.data.type === 'progress') {
this.progress = event.data.value;
}
};
// Worker 线程
workerPort.onmessage = (event) => {
for (let i = 0; i < 100; i++) {
// 每处理一步就推送进度
workerPort.postMessage({ type: 'progress', value: i });
}
workerPort.postMessage({ type: 'done', result: finalResult });
};
9.4 选型决策树
整理一个简单的决策树:
任务是否需要执行超过 3 分钟的 CPU 密集计算?
├─ 是 → Worker(或 TaskPool LongTask)
└─ 否 ↓
任务是否需要维护线程内部状态?
├─ 是 → Worker
└─ 否 ↓
任务是否需要双向持续通信?
├─ 是 → Worker
└─ 否 ↓
任务是否需要取消/优先级?
├─ 是 → TaskPool
└─ 否 ↓
→ TaskPool(大多数场景的默认选择)
一句话总结:TaskPool 面向任务,Worker 面向线程。 如果你的需求是"把一段计算扔到后台跑完、拿回结果",用 TaskPool。如果你的需求是"需要一个长期存活的后台线程,持续处理事件",用 Worker。
十、Demo 完整代码走读
下面把整个 Demo 的代码完整走一遍,逐段讲解。
10.1 导入和 @Concurrent 函数定义
import { router } from '@kit.ArkUI';
import { taskpool } from '@kit.ArkTS';
import { hilog } from '@kit.PerformanceAnalysisKit';
@Concurrent
function heavyCompute(iterations: number): number {
let result: number = 0;
for (let i = 0; i < iterations; i++) {
result += Math.sqrt(i) * Math.sin(i) * Math.cos(i);
}
return result;
}
@Concurrent
function fibonacci(n: number): number {
if (n <= 1) return n;
let a: number = 0;
let b: number = 1;
for (let i: number = 2; i <= n; i++) {
const temp: number = a + b;
a = b;
b = temp;
}
return b;
}
@Concurrent
function sortArray(size: number): number {
const arr: number[] = [];
for (let i = 0; i < size; i++) {
arr.push(Math.random() * 10000);
}
for (let i = 0; i < arr.length - 1; i++) {
for (let j = 0; j < arr.length - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
const temp: number = arr[j];
arr[j + 1] = temp;
arr[j] = arr[j + 1];
}
}
}
return arr.length;
}
三个 @Concurrent 函数,分别对应 Demo 中的三个测试场景:
heavyCompute:纯数学密集计算,用于主线程 vs TaskPool 的对比fibonacci:Fibonacci 数列计算,用于阶梯测试sortArray:冒泡排序,用于 TaskGroup 批量测试
注意这三个函数全部是模块顶层函数,参数和返回值都是 number 类型(可序列化),内部没有访问任何 UI 上下文或外部状态。完全符合 @Concurrent 的约束。
10.2 组件状态定义
@Entry
@ComponentV2
struct TaskPoolDemo {
@Local mainThreadTime: string = '未执行';
@Local taskPoolTime: string = '未执行';
@Local fibonacciResult: string = '未执行';
@Local groupResults: string = '未执行';
@Local isRunning: boolean = false;
@Local logText: string = '';
@Local activeTab: number = 0;
用了 @ComponentV2 + @Local,这是 V2 状态管理的写法。isRunning 用于防止重复点击,logText 用于操作日志,activeTab 控制 Tabs 的当前页。
10.3 UI 布局:Tabs 双页结构
Demo 用了两个 Tab 页:
- 实战演示:包含主线程对比、Fibonacci 计算、TaskGroup 批量任务三个功能模块
- 选型对比:TaskPool vs Worker 的对比表格、@Concurrent 规则说明、选型建议
每个功能模块之间用 Divider 分隔,操作日志用 Scroll 包裹,整体布局清晰直观。
10.4 核心逻辑:主线程 vs TaskPool 对比
private async runComparison(): Promise<void> {
this.isRunning = true;
const iterations: number = 10000000;
// 先跑主线程
const mainStart: number = Date.now();
let mainResult: number = 0;
for (let i = 0; i < iterations; i++) {
mainResult += Math.sqrt(i) * Math.sin(i) * Math.cos(i);
}
const mainEnd: number = Date.now();
this.mainThreadTime = (mainEnd - mainStart).toString() + 'ms (卡UI)\n结果: ' + mainResult.toFixed(2);
// 再跑 TaskPool
const poolStart: number = Date.now();
try {
const poolResult: Object = await taskpool.execute(heavyCompute, iterations);
const poolEnd: number = Date.now();
const numResult: number = Number(poolResult);
this.taskPoolTime = (poolEnd - poolStart).toString() + 'ms (不卡UI)\n结果: ' + numResult.toFixed(2);
this.addLog('纯计算对比完成: 主线程' + (mainEnd - mainStart).toString() + 'ms vs TaskPool' + (poolEnd - poolStart).toString() + 'ms');
} catch (e) {
this.taskPoolTime = '执行失败: ' + String(e);
}
this.isRunning = false;
}
这里先跑主线程再跑 TaskPool,是为了让用户能直观感受到主线程执行时 UI 冻住的现象。实际项目中你肯定不会在主线程跑这种计算,这里纯粹是为了对比演示。
注意 taskpool.execute(heavyCompute, iterations) 的写法——直接传函数引用和参数,不需要 new Task。这是最简洁的用法,适合不需要取消的场景。
10.5 Fibonacci 阶梯测试
private async runFibonacci(n: number): Promise<void> {
const start: number = Date.now();
try {
const result: Object = await taskpool.execute(fibonacci, n);
const end: number = Date.now();
this.fibonacciResult = 'fib(' + n.toString() + ') = ' + Number(result).toString() + ', 耗时' + (end - start).toString() + 'ms';
this.addLog('fibonacci(' + n.toString() + ') = ' + Number(result).toString());
} catch (e) {
this.fibonacciResult = '失败: ' + String(e);
}
}
通过三个按钮分别传入 n=30、40、45,观察不同规模的计算耗时。这里 fib 函数用的是迭代实现而不是递归,避免了栈溢出的风险。但即使如此,fib(45) 的计算量也不小,在主线程跑会有明显卡顿。
10.6 TaskGroup 批量任务
private async runTaskGroup(): Promise<void> {
const start: number = Date.now();
try {
const group: taskpool.TaskGroup = new taskpool.TaskGroup();
group.addTask(sortArray, 5000);
group.addTask(sortArray, 8000);
group.addTask(sortArray, 10000);
group.addTask(sortArray, 12000);
group.addTask(sortArray, 15000);
const results: Object[] = await taskpool.execute(group) as Object[];
const end: number = Date.now();
let summary: string = '';
results.forEach((val: Object, idx: number) => {
summary += '任务' + (idx + 1).toString() + ': 排序' + Number(val).toString() + '个元素 ';
});
this.groupResults = summary + '\n总耗时: ' + (end - start).toString() + 'ms';
this.addLog('TaskGroup完成: 5个任务, ' + (end - start).toString() + 'ms');
} catch (e) {
this.groupResults = '失败: ' + String(e);
}
}
5 个不同规模的冒泡排序任务,通过 group.addTask() 加入 TaskGroup,然后统一 execute。返回值是 Object[],结果顺序与 addTask 的顺序一致。
冒泡排序的时间复杂度是 O(n²),所以不同规模的排序耗时差异很大。5000 个元素的排序和 15000 个元素的排序,耗时差了将近 9 倍。但因为是并行执行,总耗时只取决于最慢的那个任务,而不是所有任务耗时之和。这就是并行的威力。
10.7 操作日志
private addLog(msg: string): void {
const now: Date = new Date();
const time: string = now.getMinutes().toString().padStart(2, '0') + ':' + now.getSeconds().toString().padStart(2, '0');
this.logText = '[' + time + '] ' + msg + '\n' + this.logText;
}
简单的日志方法,新日志在顶部,带时间戳。方便观察每个操作的执行时机和耗时。
10.8 选型对比 Tab
第二个 Tab 页用表格对比了 TaskPool 和 Worker 的差异,同时列出了 @Concurrent 的规则、TaskPool 核心价值和选型建议。这些信息在实际开发中经常需要查阅,放在 Demo 里方便随时参考。
特别强调 TaskPool核心价值:TaskPool 的价值不是比主线程快,而是不卡UI。
- 纯CPU计算: 主线程和 TaskPool 耗时接近,但主线程执行时 UI 会卡死
- JSON解析/IO操作: TaskPool 执行期间 UI 可正常交互
- 批量任务: TaskGroup 多个任务并行执行,总耗时远小于串行
十一、几个实战 Tips
11.1 不要在 TaskPool 任务里打印 hilog
虽然从技术上讲,@Concurrent 函数内部可以调用 hilog,但这会带来额外的序列化开销和日志系统的线程安全竞争。如果一定要打印,建议在主线程拿到结果后再打印,或者只在调试阶段使用,上线前去掉。
11.2 大数据传递用 ArrayBuffer transfer
如果你需要在主线程和工作线程之间传递大量二进制数据(比如图片 buffer、音频数据),直接传会被结构化克隆深拷贝一份,内存和时间开销都很大。可以用 ArrayBuffer 的 transfer 机制避免拷贝:
const task = new taskpool.Task(processBuffer, buffer);
task.setTransferList([buffer]);
const result = await taskpool.execute(task);
// 此后主线程不再持有 buffer 的所有权
transfer 是零拷贝的——ArrayBuffer 的所有权从主线程转移到工作线程,主线程不再能访问这个 buffer。适合"传过去就不需要了"的场景。
11.3 错误处理别忘了
taskpool.execute() 可能抛出异常,一定要 try-catch。除了 10200014,还可能遇到:
- 10200015:取消不存在的任务
- 10200016:取消正在执行的任务
- 序列化失败:参数或返回值包含不可序列化类型
Demo 里每个 execute 都包了 try-catch,这不是过度防御,是必要的工程实践。
11.4 避免任务粒度太细
TaskPool 的任务调度和序列化是有开销的。如果你的任务只需要几十微秒就能完成,那调度开销可能比计算本身还大。这种情况下,不如把多个小任务合并成一个稍大的任务再提交。
经验值:单个 TaskPool 任务的执行时间建议在 1ms 以上。低于这个阈值,调度开销占比太高,得不偿失。
11.5 TaskPool 的线程不是无限多的
前面说过,TaskPool 的工作线程上限是 CPU 核数 - 1。8 核设备最多 7 个工作线程。如果你一次性提交了 50 个任务,前 7 个会立即执行,剩下的 43 个在队列里排队。TaskPool 的优势在于复用——跑完一个任务的线程不会销毁,会接着跑下一个。但如果任务量特别大,排队延迟是不可避免的。合理使用优先级来确保关键任务优先执行。
写在最后
TaskPool 不是银弹,它解决的是"主线程被阻塞"这个特定问题。对于短时、独立、无状态的并发任务,TaskPool 是目前 HarmonyOS 上最高效的方案——系统管理线程、自动负载均衡、支持优先级和取消,开发者只需要关注业务逻辑本身。
对于长时、有状态、需要持续通信的场景,Worker 依然是更好的选择。两者不是竞争关系,而是互补关系。
理解了线程模型和通信机制的差异,选型就不是什么难事。记住那个决策树,基本不会选错。
最后一点建议:不要等 UI 卡了才想起来用并发。在设计阶段就识别出潜在的耗时操作,提前规划好哪些要在主线程、哪些要放到 TaskPool、哪些需要 Worker。这比事后补救要轻松得多。
更多推荐


所有评论(0)