ArkTS 并发选型:TaskPool 与 Worker 到底该用哪个,先算清线程通信这笔账

举报
yd_274993192 发表于 2026/11/09 10:43:03 2026/11/09
【摘要】 把耗时任务挪到子线程只是第一步,真正决定卡不卡的往往是数据怎么过去、结果怎么回来。本文从一次图片批量压缩的实测出发,对比 TaskPool 与 Worker 在任务粒度、并发度控制、序列化开销、取消与超时上的差异,给出 CPU 密集型与 IO 型任务的选型判断表,并整理出结构化克隆的隐性成本、长任务切片、并发度与设备核数匹配、取消后资源回收四个容易踩的坑,最后给出一套可复用的并发封装与打点验证方法

上个月给一个 HarmonyOS 相册应用做"批量压缩"功能:选中几十张图,一次性压到目标体积再上传。最初把所有压缩逻辑塞进主线程,结果进度条刚走到 10% 界面就僵住了,点击无响应,连返回键都要等两秒。挪到子线程之后问题变成了另一种形态:不卡了,但内存峰值翻了一倍,取消操作也停不下来。这篇把 TaskPool 和 Worker 的差异按"通信成本"这条主线捋一遍,顺带记四个踩过的坑。

先分清两类任务

选型之前要先给任务定性,这一步比选 API 重要得多:

  • CPU 密集型:图片解码压缩、大数组排序、加密摘要、复杂布局计算。特点是可切片、无状态、纯计算,输入输出都能用普通对象表达。
  • IO 型 / 长生命周期:数据库批量写入、文件流式读写、长连接心跳、需要持有大量上下文的状态机。特点是有状态、要保活、频繁小交互。

ArkTS 给的两套并发设施刚好对应这两类:TaskPool 是任务池模型,任务提交即走、由框架调度线程数;Worker 是常驻线程模型,线程活着、你自己管消息。把 IO 型任务丢进 TaskPool,或者每压一张图就起一个 Worker,都是在拿工具的短板干活。

一、TaskPool:一次性的、可并发的计算

TaskPool 的心智模型接近"提交任务 → 拿 Promise"。带 @Concurrent 装饰的函数在子线程执行,参数与返回值都走结构化克隆:

@Concurrent
function compressOne(src: string, quality: number): CompressResult {
  const img = image.createImageSource(src);
  const pixelMap = img.createPixelMapSync({ desiredSize: { width: 1080, height: 1920 } });
  const packer = image.createImagePacker();
  const buf = packer.packingSync(pixelMap, { format: 'image/jpeg', quality: quality });
  return { ok: true, size: buf.byteLength, path: src };
}

// 主线程侧:并发跑一批
const results = await Promise.all(
  files.map(f => taskpool.execute(compressOne, f, 80))
);

它有三个直接好处:并发度由框架按设备能力调度,不用自己算核数;任务结束线程自动回收,不会留下常驻内存;taskpool.cancel() 可以按任务取消。缺点也很明确:函数必须打 @Concurrent 且不能捕获外部闭包变量,所有依赖都得显式传参;每次调用的参数和返回值都要序列化一次。

二、Worker:常驻的、有状态的通道

Worker 更像传统多线程:new worker.ThreadWorker('entry/ets/workers/compress.ets') 起一个线程,之后用 postMessage / onmessage 双向通信。适合"线程要一直活着、反复收发小消息"的场景,比如一边解压一边往数据库写,或者维护一个长连接的会话状态。

// worker 侧
import { worker, ThreadWorkerGlobalScope } from '@kit.ArkTS';
const g: ThreadWorkerGlobalScope = worker.workerPort;
g.onmessage = (e: MessageEvents) => {
  const { id, chunk } = e.data;
  const saved = writeChunkToDb(chunk);
  g.postMessage({ id, saved });
};

// 主线程侧
const w = new worker.ThreadWorker('entry/ets/workers/writer.ets');
w.postMessage({ id: 1, chunk: buf });

代价是线程生命周期归你管:忘记 terminate() 就会在页面销毁后一直占着内存;多个页面各起一个 Worker 会让常驻线程数悄悄涨上去;消息是异步的,请求与响应的配对得自己做(通常带一个自增 id)。

三、真正决定体验的是通信成本,不是线程数

实测下来最容易忽视的一点:跨线程传大数据,比在主线程算还慢。结构化克隆是深拷贝,传一个 8MB 的 ArrayBuffer,拷贝本身就要几十毫秒,再加上两侧的 GC 压力,压缩省下来的时间全贴回去了。几个具体结论:

  1. 传引用不传副本:能传文件路径、数据库 id、PixelMap 句柄的就别传原始像素数据。上面压缩函数接收的是 src 路径而不是字节数组,这是刻意设计的。
  2. 批量优于逐条:压 50 张图,一次 execute 传一个 50 项的数组,比 50 次 execute 各传一项快得多——每次调用都有固定的调度与序列化开销。
  3. 结果只回必要字段:子线程算完把整个中间结构体回传是常见浪费,回 { ok, size } 三个字段就够了,明细让它写进文件或数据库,主线程按需读。
  4. 小任务别并发:单次执行不到 2~3ms 的任务,调度开销比计算本身还大,直接同步跑更划算。

判断标准可以简化成一句:单任务耗时 > 5ms 且数据量小,才值得跨线程。反过来,数据量大、任务又碎,先想办法合并,再考虑并发。

四、坑 1:把 UI 对象或类实例塞进参数

结构化克隆只认可序列化的数据:普通对象、数组、ArrayBuffer、Map/Set 可以,但自定义类的实例会丢掉原型和方法,@Observed 的对象传过去变成普通对象,回调函数、this、Context 一律不行。我最初直接传了一个带业务方法的实体类,子线程里调 item.getSize() 直接抛异常,报错信息还很绕。

规范做法是只传 DTO:进子线程前把实体摊平成纯数据对象,回主线程后再组装回实体。这一步多做一层转换,但边界清楚了,后面改字段不会突然炸在子线程里。

五、坑 2:一次提交一个超大任务,取消等于没取消

TaskPool 的 cancel() 只是把还没开始执行的任务标记成取消,已经在跑的任务无法中断。所以"用户点了取消,界面还在转圈"几乎是必然的——那个压了 3 秒的大任务还在后台跑完。

解法是把长任务切成小片,每片之间检查取消标志:

@Concurrent
async function compressBatch(paths: string[], quality: number): Promise<CompressResult[]> {
  const out: CompressResult[] = [];
  for (let i = 0; i < paths.length; i++) {
    out.push(compressOne(paths[i], quality));   // 单张为一个切片
  }
  return out;
}

// 主线程:按批提交,每批之间可取消
for (const group of chunk(files, 5)) {
  if (cancelled) break;
  const r = await taskpool.execute(compressBatch, group, 80);
  results.push(...r);
  onProgress(results.length / files.length);
}

切片粒度按"单次 100ms 以内"来定,既能保证取消响应及时,又不至于让调度开销占大头。

六、坑 3:并发度拍脑袋设成 CPU 核数

很多人会写 Math.min(files.length, 8) 这样的硬编码并发数。中低端设备上 8 个线程同时解码图片,内存峰值能直接翻倍,还容易触发系统回收把后台线程干掉。

更稳的做法是让框架调度,自己只控"同时提交多少个":

// 同时最多 3 个在途任务,其余排队
const limit = 3;
let cursor = 0;
async function worker() {
  while (cursor < files.length) {
    const f = files[cursor++];
    await taskpool.execute(compressOne, f, 80);
  }
}
await Promise.all(Array.from({ length: limit }, worker));

实测把在途数从 8 降到 3,总耗时只多了 7%,但内存峰值降了将近一半,取消响应也更快。对于解压、渲染这类吃内存的任务,内存才是瓶颈,不是核数。

七、坑 4:Worker 忘了 terminate,页面退出后还在跑

Worker 是不随页面销毁的。如果 Worker 里挂着定时器或长轮询,页面退出后它照样在跑,下次进页面又起一个,两次的结果互相覆盖,日志看着像"数据错乱"。

约定三条:Worker 由持有它的页面统一管理,在 aboutToDisappear 里 terminate();每个 Worker 只做一件事,不做"万能线程";消息协议固定带 id 与 type 两个字段,便于在日志里对齐请求和响应。

八、怎么验证改对了

不要凭"感觉不卡了"收工,三个指标足够说明问题:

  1. 主线程长任务:用 Profiler 录一段完整操作,看主线程有没有超过 50ms 的连续占用。改对之后,压缩期间主线程应该只有零星的短任务。
  2. 内存峰值:同样的 50 张图,对比改前改后的 PSS 峰值。并发度从 8 降到 3 之后峰值应明显下降。
  3. 取消延迟:从点击取消到界面恢复可交互的时间,切片后应该稳定在 200ms 以内。

再补一条线上可观测的:在每个任务入口和出口打一条 hilog,记录 taskId 与耗时。线上出现"卡住"反馈时,能立刻分辨是任务没提交、任务在排队,还是任务在跑但结果没回来。

九、收成一页判断表

  • 纯计算、可切片、无状态 → TaskPool + @Concurrent,按批提交,控制在途数。
  • 有状态、长生命周期、频繁小交互 → Worker + 固定消息协议,页面级管理生命周期。
  • 数据大于 1MB → 先想能不能传句柄/路径,不能就落文件,不要跨线程搬字节。
  • 单任务 < 5ms → 留在主线程,别调度。
  • 需要中断 → 任务必须切片,取消标志在片间检查。

并发本身不难,难的是承认它的成本:线程不是免费的,数据过边界是要付费的。把这两件事算清楚,选型基本不会再错;反过来,一上来就问"TaskPool 和 Worker 哪个性能好",大概率会在通信上把省下来的时间赔回去。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。