基于 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 渲染都会暂停。用户看到的就是:点了按钮之后界面冻住了,等计算完才恢复。

这个连锁反应是这样的:

  1. 主线程被阻塞 → 事件循环停滞
  2. 事件循环停滞 → vsync 信号无法响应,渲染管线中断
  3. 渲染管线中断 → 帧率从 60fps 掉到个位数
  4. 持续阻塞 > 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 的模块,可以被安全地结构化克隆。

规则二:参数和返回值必须是可序列化类型

可序列化的类型包括:

  • 基本类型:numberstringbooleannullundefined
  • 日期:Date
  • 正则:RegExp
  • 集合:ArrayMapSet
  • 二进制: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 的行为是一样的。

几个注意点:

  1. TaskGroup 内的任务也是独立执行的,任务之间不能有依赖关系
  2. 结果类型是 Object[],需要逐个类型断言
  3. TaskGroup 也支持取消taskpool.cancel(group) 可以取消整组任务
  4. 如果其中一个任务失败,整组的 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,按以下步骤排查:

  1. 检查传入 taskpool.execute 的函数是否有 @Concurrent 装饰器
  2. 检查函数是否是顶层函数(不是类方法、不是箭头函数赋值、不是闭包)
  3. 检查函数的入参类型是否全部可序列化
  4. 检查函数的返回值类型是否可序列化
  5. 检查函数内部是否访问了 UI 上下文或外部组件状态
  6. 检查函数内部是否引用了不可序列化的 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。这比事后补救要轻松得多。


参考:HarmonyOS 官方文档 - TaskPoolTaskPool 和 Worker 对比

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐