[鸿蒙从零到一] HarmonyOS 任务调度与并发模型实战:taskpool、Worker 与可取消任务
ArkTS 应用中的耗时工作如果直接挤在 UI 线程上,最直观的结果就是点击反馈延迟、动画掉帧,甚至触发应用无响应。网络请求本身通常是异步的,但 JSON 解析、图片处理、文件扫描、数据聚合等计算仍可能长时间占用执行线程。要解决这类问题,不能只是在函数前加上 async,而要先判断任务属于 I/O 等待还是 CPU 计算,再选择合适的并发工具。
本文从 ArkTS 的执行语义开始,逐步拆解 Promise、taskpool 和 Worker 的适用边界,并以“批量分析本地日志”为例,实现可取消、可超时、可观测且能跟随页面生命周期收尾的任务链路。示例接口请以当前 HarmonyOS SDK 类型定义为准,重点是选型原则和工程组织方式。
一、异步不等于并行
async/await 主要解决异步流程的表达问题。等待网络、数据库或文件接口返回时,当前函数可以让出执行权,让 UI 线程继续处理其他事件;但如果异步函数内部执行了一段密集循环,这段计算仍会占用当前线程。
async function loadAndAnalyze(url: string): Promise<number> {
const response = await fetch(url)
const text = await response.text()
// await 之后的同步计算仍运行在当前执行上下文中。
let score = 0
for (let index = 0; index < text.length; index += 1) {
score += text.charCodeAt(index)
}
return score
}
因此,排查卡顿时要把任务拆成两个阶段:
- I/O 阶段:等待网络、文件、数据库或系统服务,优先使用异步 API。
- 计算阶段:解析、压缩、排序、图像处理或大批量转换,评估是否迁移到并发执行单元。
Promise 不是线程创建器。它可以组织异步依赖、并发发起多个 I/O 请求,但不会自动把同步计算搬离 UI 线程。
二、先按任务特征选择工具
HarmonyOS 应用中常见的选择可以归纳为三类:
- Promise 与异步 API:适合短任务和 I/O 等待,调用关系清晰,数据无需跨线程复制。
taskpool:适合可拆分、相对独立的计算任务,由系统管理工作线程和任务调度。- Worker:适合需要长期驻留、保持内部状态或持续收发消息的独立执行单元。
不要只根据“任务很慢”做选择。还要看任务是否可序列化、是否需要共享状态、是否需要持续通信,以及生命周期由谁负责。
例如,计算一批日志文件中各类错误的数量,可以拆成多个纯函数任务交给 taskpool;持续解析来自设备的字节流,需要保留解析缓冲区和协议状态,更适合 Worker;读取单个配置文件后更新页面,则通常用异步文件 API 就够了。
三、把计算逻辑改造成可调度的纯任务
并发任务越少依赖页面、单例和全局状态,越容易调度与测试。以日志分析为例,先定义清晰的输入和输出:
export interface LogChunk {
source: string
content: string
}
export interface LogSummary {
source: string
total: number
warning: number
error: number
}
export function analyzeChunk(chunk: LogChunk): LogSummary {
const lines = chunk.content.split('\n')
let warning = 0
let error = 0
for (const line of lines) {
if (line.includes('[WARN]')) {
warning += 1
}
if (line.includes('[ERROR]')) {
error += 1
}
}
return {
source: chunk.source,
total: lines.length,
warning,
error
}
}
输入只包含字符串,输出只包含普通数据对象,没有 UI 组件引用、文件句柄或上下文对象。这种边界既方便跨执行单元传递,也能避免并发代码意外修改页面状态。
真实项目中还要限制单个任务的数据量。把几百兆字节内容一次性交给任务执行,不仅调度开销大,还可能造成内存峰值。更稳妥的方式是按文件或固定大小分块,并控制同时运行的任务数量。
四、使用 taskpool 执行离散计算
taskpool 适合把独立计算交给系统管理的工作线程。调用侧负责创建任务、等待结果和汇总错误,执行函数负责计算,不直接触碰 UI。
下面展示一种结构化写法。装饰器、任务构造参数和取消接口可能随 SDK 演进,应以项目使用版本的声明为准。
import {
taskpool } from '@kit.ArkTS'
@Concurrent
function analyzeInTask(chunk: LogChunk): LogSummary {
return analyzeChunk(chunk)
}
export async function analyzeAll(
chunks: LogChunk[]
): Promise<LogSummary[]> {
const jobs = chunks.map((chunk) => {
const task = new taskpool.Task(analyzeInTask, chunk)
return taskpool.execute(task) as Promise<LogSummary>
})
return Promise.all(jobs)
}
这段代码说明了基本链路,但生产代码不应无上限地 map 全部任务。大量小任务会增加排队、序列化和上下文切换成本。可以使用分批执行控制并发宽度:
export async function analyzeInBatches(
chunks: LogChunk[],
batchSize: number
): Promise<LogSummary[]> {
const result: LogSummary[] = []
for (let start = 0; start < chunks.length; start += batchSize) {
const batch = chunks.slice(start, start + batchSize)
const summaries = await analyzeAll(batch)
result.push(...summaries)
}
return result
}
batchSize 不宜写死为越大越好。应根据设备能力、任务耗时和内存占用测量后确定,并为低内存设备保留更保守的配置。
五、用 Worker 承载长生命周期状态
Worker 更像一个独立的消息处理单元。主线程通过消息发送命令,Worker 在自己的执行环境中维护状态并返回结果。它适合流式解析、持续计算或需要复用初始化成本的场景。
主线程可以封装一个明确的客户端,隐藏消息协议:
import {
worker } from '@kit.ArkTS'
interface AnalyzeRequest {
requestId: string
type: 'analyze'
chunk: LogChunk
}
interface AnalyzeResponse {
requestId: string
type: 'result' | 'failure'
summary?: LogSummary
message?: string
}
class LogWorkerClient {
private readonly engine = new worker.ThreadWorker(
'entry/ets/workers/LogWorker.ets'
)
constructor() {
this.engine.onmessage = (event) => {
this.handleResponse(event.data as AnalyzeResponse)
}
}
analyze(request: AnalyzeRequest): void {
this.engine.postMessage(request)
}
close(): void {
this.engine.terminate()
}
private handleResponse(response: AnalyzeResponse): void {
// 根据 requestId 完成对应 Promise,并统一清理等待表。
}
}
Worker 侧只处理协议允许的消息:
import {
worker } from '@kit.ArkTS'
const port = worker.workerPort
port.onmessage = (event) => {
const request = event.data as AnalyzeRequest
try {
const summary = analyzeChunk(request.chunk)
port.postMessage({
requestId: request.requestId,
type: 'result',
summary
} as AnalyzeResponse)
} catch (error) {
port.postMessage({
requestId: request.requestId,
type: 'failure',
message: String(error)
} as AnalyzeResponse)
}
}
消息必须带 requestId,否则多个请求并行时无法把回包交给正确的调用者。协议还应包含版本字段,便于应用升级后识别不兼容消息。
六、跨线程传递数据时要守住边界
跨执行单元通信不是普通函数调用。页面组件实例、Ability 上下文、打开的文件句柄、数据库连接和带复杂原型链的对象,都不适合作为任务参数直接传递。
推荐遵循以下约束:
- 输入输出使用字符串、数字、布尔值、数组和结构明确的普通对象。
- 只传任务需要的数据,不把整个页面状态或仓库对象打包发送。
- 大块二进制数据优先评估可转移对象或分块方案,避免不必要复制。
- Worker 内部创建并管理自己的临时资源,结束时显式释放。
- 所有返回错误转换成稳定错误码和可记录信息,不跨边界抛出任意对象。
如果任务严重依赖共享可变状态,通常说明边界拆得不够清楚。先把状态转换成不可变快照,再交给执行单元,会比在多个线程之间维护写入顺序更可靠。
七、取消不是删除一个 Promise
用户离开页面、切换筛选条件或重新发起分析时,旧任务的结果往往已经没有业务价值。JavaScript Promise 本身没有通用的强制取消能力,因此应用需要同时处理“停止底层工作”和“忽略过期结果”。
可以为每轮分析分配一个令牌:
class AnalysisController {
private generation = 0
async start(chunks: LogChunk[]): Promise<LogSummary[]> {
const current = ++this.generation
const result = await analyzeInBatches(chunks, 4)
if (current !== this.generation) {
throw new Error('ANALYSIS_CANCELLED')
}
return result
}
cancel(): void {
this.generation += 1
}
}
令牌能阻止旧结果覆盖新页面状态,但并不一定停止正在执行的计算。如果当前 SDK 和任务类型支持取消,应保存任务引用并调用对应取消能力;Worker 场景可以发送取消消息,让循环在分块边界检查标志。对于无法中断的短计算,可以允许它结束,但不再消费结果。
耗时循环也应主动设计取消点,而不是期待系统在任意指令位置安全中断:
function analyzeLines(
lines: string[],
isCancelled: () => boolean
): number {
let errors = 0
for (let index = 0; index < lines.length; index += 1) {
if (index % 500 === 0 && isCancelled()) {
throw new Error('TASK_CANCELLED')
}
if (lines[index].includes('[ERROR]')) {
errors += 1
}
}
return errors
}
八、为任务增加超时与错误分层
任务异常至少要区分输入错误、业务取消、资源不足、执行失败和超时。页面不应直接展示底层堆栈,而应根据稳定错误码决定重试、降级或提示。
async function withTimeout<T>(
operation: Promise<T>,
timeoutMs: number
): Promise<T> {
let timer: number = 0
const timeout = new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error('TASK_TIMEOUT')), timeoutMs)
})
try {
return await Promise.race([operation, timeout])
} finally {
clearTimeout(timer)
}
}
超时同样不代表底层工作自动终止。触发超时后还要执行取消动作,并阻止晚到结果更新状态。记录日志时建议带上任务类型、数据规模、排队时间、执行耗时、取消原因和错误码,但不要记录完整业务数据或用户隐私。
九、让页面状态只在 UI 线程更新
并发执行单元负责计算,页面状态更新仍应集中在 UI 层。可以让 ViewModel 维护统一状态机:
type AnalysisState =
| {
status: 'idle' }
| {
status: 'running'; progress: number }
| {
status: 'success'; data: LogSummary[] }
| {
status: 'failure'; message: string }
class LogAnalysisViewModel {
state: AnalysisState = {
status: 'idle' }
private readonly controller = new AnalysisController()
async analyze(chunks: LogChunk[]): Promise<void> {
this.controller.cancel()
this.state = {
status: 'running', progress: 0 }
try {
const data = await withTimeout(
this.controller.start(chunks),
30_000
)
this.state = {
status: 'success', data }
} catch (error) {
if (String(error).includes('CANCELLED')) {
return
}
this.state = {
status: 'failure', message: '日志分析失败' }
}
}
stop(): void {
this.controller.cancel()
this.state = {
status: 'idle' }
}
}
页面销毁时调用 stop(),如果持有 Worker,还要关闭 Worker 并清空等待中的回调。不要让 Worker、定时器或事件监听器无期限持有已经退出的页面对象。
十、性能优化要看端到端成本
把代码放进工作线程不一定更快。任务创建、参数复制、排队、线程切换和结果回传都有成本。一个只执行几百微秒的计算,迁移后可能比直接执行更慢。
评估时至少记录这些指标:
- UI 线程最长阻塞时间和关键交互帧率。
- 任务排队时间、纯执行时间和总完成时间。
- 输入数据大小、任务数量与内存峰值。
- 取消后释放资源所需时间。
- 低端设备和大数据量下的失败率。
优化通常从增大任务粒度、限制并发宽度、减少数据复制和复用长生命周期 Worker 入手。不要用同时启动更多任务来掩盖单个任务算法效率低的问题。
十一、如何测试并发任务
并发代码最容易出现的不是算法错误,而是时序错误。测试应覆盖正常结果之外的取消、超时、乱序回包和生命周期退出。
可以把调度器抽象成接口,在单元测试中同步执行:
interface TaskScheduler {
execute<TInput, TOutput>(
operation: (input: TInput) => TOutput,
input: TInput
): Promise<TOutput>
}
class ImmediateScheduler implements TaskScheduler {
async execute<TInput, TOutput>(
operation: (input: TInput) => TOutput,
input: TInput
): Promise<TOutput> {
return operation(input)
}
}
重点测试场景包括:
- 输入为空、数据损坏和超大数据块时结果是否稳定。
- 新任务开始后,旧任务晚到结果是否被丢弃。
- Worker 回包乱序时,是否按
requestId正确完成请求。 - 超时和页面退出后,定时器、回调和 Worker 是否释放。
- 某个分块失败时,是终止全部任务还是返回部分结果。
- 并发宽度受限时,实际运行任务数是否超过上限。
集成测试还应在真机上制造频繁进出页面、前后台切换和低内存场景。模拟器上的线程调度和性能数据不能完全替代真机结果。
十二、常见误区
给同步计算套一层 async
函数变成 Promise 返回值,并不会自动离开 UI 线程。先定位真正占用 CPU 的代码段,再迁移执行位置。
把页面对象传给并发任务
这会模糊线程边界,还可能导致序列化失败或生命周期泄漏。任务只接收最小数据快照。
为每个小操作创建 Worker
Worker 有创建和销毁成本。短小离散计算优先评估 taskpool,持续任务再考虑复用 Worker。
只做超时,不处理晚到结果
超时 Promise 已经失败,但底层任务可能继续运行。必须配合取消机制或代际令牌,防止旧结果覆盖新状态。
并发数量不设上限
大量任务会同时争抢 CPU 和内存,反而拖慢整个应用。用分批、队列或信号量控制并发宽度。
总结
HarmonyOS 并发设计的关键不是记住某个 API,而是建立稳定的任务边界:I/O 等待交给异步 API,独立计算交给 taskpool,需要持续状态和消息通信的工作交给 Worker。任务输入输出保持简单,UI 状态集中更新,再补上取消、超时、错误分层和生命周期清理,才能真正避免卡顿和状态错乱。
落地时可以从一次明确的性能问题开始:测量 UI 阻塞,提取纯计算函数,小规模迁移并对比端到端耗时。确认收益后再扩展到更多任务,比一次性重写所有异步代码更稳妥。