[鸿蒙从零到一] HarmonyOS 任务调度与并发模型实战:taskpool、Worker 与可取消任务

鸿蒙从零到一 HarmonyOS 任务调度与并发模型实战:taskpool、Worker 与可取消任务

ArkTS 应用中的耗时工作如果直接挤在 UI 线程上,最直观的结果就是点击反馈延迟、动画掉帧,甚至触发应用无响应。网络请求本身通常是异步的,但 JSON 解析、图片处理、文件扫描、数据聚合等计算仍可能长时间占用执行线程。要解决这类问题,不能只是在函数前加上 async,而要先判断任务属于 I/O 等待还是 CPU 计算,再选择合适的并发工具。

本文从 ArkTS 的执行语义开始,逐步拆解 Promise、taskpool 和 Worker 的适用边界,并以"批量分析本地日志"为例,实现可取消、可超时、可观测且能跟随页面生命周期收尾的任务链路。示例接口请以当前 HarmonyOS SDK 类型定义为准,重点是选型原则和工程组织方式。

一、异步不等于并行

async/await 主要解决异步流程的表达问题。等待网络、数据库或文件接口返回时,当前函数可以让出执行权,让 UI 线程继续处理其他事件;但如果异步函数内部执行了一段密集循环,这段计算仍会占用当前线程。

ts 复制代码
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 就够了。

三、把计算逻辑改造成可调度的纯任务

并发任务越少依赖页面、单例和全局状态,越容易调度与测试。以日志分析为例,先定义清晰的输入和输出:

ts 复制代码
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 演进,应以项目使用版本的声明为准。

ts 复制代码
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 全部任务。大量小任务会增加排队、序列化和上下文切换成本。可以使用分批执行控制并发宽度:

ts 复制代码
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 在自己的执行环境中维护状态并返回结果。它适合流式解析、持续计算或需要复用初始化成本的场景。

主线程可以封装一个明确的客户端,隐藏消息协议:

ts 复制代码
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 侧只处理协议允许的消息:

ts 复制代码
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 本身没有通用的强制取消能力,因此应用需要同时处理"停止底层工作"和"忽略过期结果"。

可以为每轮分析分配一个令牌:

ts 复制代码
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 场景可以发送取消消息,让循环在分块边界检查标志。对于无法中断的短计算,可以允许它结束,但不再消费结果。

耗时循环也应主动设计取消点,而不是期待系统在任意指令位置安全中断:

ts 复制代码
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
}

八、为任务增加超时与错误分层

任务异常至少要区分输入错误、业务取消、资源不足、执行失败和超时。页面不应直接展示底层堆栈,而应根据稳定错误码决定重试、降级或提示。

ts 复制代码
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 维护统一状态机:

ts 复制代码
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 入手。不要用同时启动更多任务来掩盖单个任务算法效率低的问题。

十一、如何测试并发任务

并发代码最容易出现的不是算法错误,而是时序错误。测试应覆盖正常结果之外的取消、超时、乱序回包和生命周期退出。

可以把调度器抽象成接口,在单元测试中同步执行:

ts 复制代码
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 阻塞,提取纯计算函数,小规模迁移并对比端到端耗时。确认收益后再扩展到更多任务,比一次性重写所有异步代码更稳妥。

相关推荐
颜进强1 小时前
前端看后端 14:什么是 CORS?
前端·后端
sugar__salt1 小时前
Vue3 自定义指令与插槽(Slot)技术详解
前端·javascript·vue.js·前端框架·vue
Csvn2 小时前
🎯 Flex 布局的 `min-width: auto` 陷阱:为什么内容总是撑破容器?
前端
果然_2 小时前
纯前端图片压缩怎么做?不用上传服务器,浏览器里跑完所有逻辑
前端
Csvn2 小时前
🖼️ OffscreenCanvas:把 Canvas 绘制搬出主线程,动画卡顿的终极解药
前端
用户69371750013842 小时前
了解一下 Agent Harness
android·前端·后端
swipe2 小时前
16|(前端转全栈)前端人排查后端问题:curl、traceId、日志、MySQL、Redis 怎么用?
前端·后端·面试
anyup2 小时前
uni-app 没有根组件?仅需几行代码实现全局 Toast 和 Modal
前端·架构·uni-app