ArkTS 并发选型:TaskPool 与 Worker 到底该用哪个,先算清线程通信这笔账
上个月给一个 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 压力,压缩省下来的时间全贴回去了。几个具体结论:
- 传引用不传副本:能传文件路径、数据库 id、
PixelMap句柄的就别传原始像素数据。上面压缩函数接收的是src路径而不是字节数组,这是刻意设计的。 - 批量优于逐条:压 50 张图,一次
execute传一个 50 项的数组,比 50 次execute各传一项快得多——每次调用都有固定的调度与序列化开销。 - 结果只回必要字段:子线程算完把整个中间结构体回传是常见浪费,回
{ ok, size }三个字段就够了,明细让它写进文件或数据库,主线程按需读。 - 小任务别并发:单次执行不到 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 两个字段,便于在日志里对齐请求和响应。
八、怎么验证改对了
不要凭"感觉不卡了"收工,三个指标足够说明问题:
- 主线程长任务:用 Profiler 录一段完整操作,看主线程有没有超过 50ms 的连续占用。改对之后,压缩期间主线程应该只有零星的短任务。
- 内存峰值:同样的 50 张图,对比改前改后的
PSS峰值。并发度从 8 降到 3 之后峰值应明显下降。 - 取消延迟:从点击取消到界面恢复可交互的时间,切片后应该稳定在 200ms 以内。
再补一条线上可观测的:在每个任务入口和出口打一条 hilog,记录 taskId 与耗时。线上出现"卡住"反馈时,能立刻分辨是任务没提交、任务在排队,还是任务在跑但结果没回来。
九、收成一页判断表
- 纯计算、可切片、无状态 → TaskPool +
@Concurrent,按批提交,控制在途数。 - 有状态、长生命周期、频繁小交互 → Worker + 固定消息协议,页面级管理生命周期。
- 数据大于 1MB → 先想能不能传句柄/路径,不能就落文件,不要跨线程搬字节。
- 单任务 < 5ms → 留在主线程,别调度。
- 需要中断 → 任务必须切片,取消标志在片间检查。
并发本身不难,难的是承认它的成本:线程不是免费的,数据过边界是要付费的。把这两件事算清楚,选型基本不会再错;反过来,一上来就问"TaskPool 和 Worker 哪个性能好",大概率会在通信上把省下来的时间赔回去。
- 点赞
- 收藏
- 关注作者
评论(0)