最近我给一个本地素材库加了“缩略图内容哈希”。目标很简单:导入一批图片后,在后台算出轻量 Hash,用来辅助重复素材判断。第一版我直接把 48 个文件全部扔进 TaskPool,页面看起来很流畅,但只要用户快速切换相册、重新筛选一次,问题马上出现:同一路径会被重复提交,已经不需要的任务还在继续跑,旧任务稍晚返回以后甚至会覆盖新一轮列表结果。

真正难的不是并发本身,而是任务身份、取消时机和结果归属。Demo ThumbHashGuardLab 的批次为 thumb_hash_091:48 个文件最终执行 36 个、取消 12 个、丢弃 3 个迟到结果,generation 为 5,结束后 Active Tasks=0。

一、TaskPool 只负责并发,业务还得自己定义“同一个任务是谁”

最开始我的代码每次进入可视区都 new 一个 taskpool.Task。如果同一张图因为列表复用、筛选刷新又进来一次,就会再提交一份完全相同的 Hash 计算。

系统能执行它们,但不知道这两个任务在业务上其实是同一个。

所以我先加一层 Registry,Key 直接使用素材路径:

import { taskpool } from '@kit.ArkTS'

@Concurrent
function hashThumbnail(path: string): string {
  let sum = 0

  for (let i = 0; i < 500000; i++) {
    sum = (sum + i * 97) & 0x7fffffff
  }

  return `${path}:${sum.toString(16)}`
}

private submitHash(path: string): void {
  if (this.taskRegistry.has(path)) {
    this.duplicateSubmitBlocked++
    return
  }

  const generation = this.latestGeneration
  const task = new taskpool.Task(
    hashThumbnail,
    path
  )

  this.taskRegistry.set(path, {
    task,
    generation
  })

  void this.executeHash(path, task, generation)
}

这里解决的是重复提交的业务定义:同一个 path 在当前 generation 里只允许一个活动 Task。若算法版本变化,Key 应升级为 path + algorithmVersion。

Hash 核心留在独立文件中,不反向 import 页面状态。

二、执行结果回来以前,要先问一句:它还属于当前页面吗

TaskPool 的 Promise 返回,只说明任务算完了,不代表这个结果现在还应该进入 UI。

用户可能已经切换目录,或者重新发起了一批任务。旧任务如果比新任务晚返回,就会形成典型的“迟到结果覆盖新状态”。

我用 generation 做结果门禁:

private async executeHash(
  path: string,
  task: taskpool.Task,
  generation: number
): Promise<void> {
  try {
    const result =
      await taskpool.execute(task) as string

    if (generation !== this.latestGeneration) {
      this.lateResultsDropped++
      return
    }

    this.hashResultMap.set(path, result)
    this.executed++
  } catch (_) {
    // cancel 后进入这里,不再写 UI
  } finally {
    this.taskRegistry.delete(path)
    this.activeTasks =
      this.taskRegistry.size
  }
}

这次测试里一共出现 3 个迟到结果,因此最终 Late Results Dropped=3。

如果结果还会写数据库,也要把 generation / batchId 带进持久化层,避免旧任务覆盖新数据。

三、页面已经不需要的任务,要主动 cancel,而不是等它自然结束

第二个问题来自相册切换。

用户从 48 个文件的目录切到另一个筛选条件后,其中 12 个 Hash 任务已经没有任何展示价值。如果让它们继续占 TaskPool,虽然最终结果会被 generation 丢弃,但 CPU 还是白跑了一遍。

我会在新的可见集合稳定后取消不再需要的任务:

private cancelStale(
  visiblePaths: Set<string>
): void {
  this.latestGeneration++

  for (const [path, record]
    of this.taskRegistry.entries()) {
    if (visiblePaths.has(path)) {
      continue
    }

    try {
      taskpool.cancel(record.task)
      this.cancelled++
    } catch (_) {
      // 任务可能已经完成
    }

    this.taskRegistry.delete(path)
  }

  this.activeTasks =
    this.taskRegistry.size
}

本轮最终取消 12 个、执行 36 个。generation 解决结果正确性,cancel 解决资源浪费,两层缺一不可。

四、并发函数尽量保持“纯”,不要把 UI 状态带进去

TaskPool 并发函数保持最小依赖,不引入 @Observed、AppStorage 等 UI 状态。Hash 核心只接收可序列化参数并返回纯结果。

项目里 HashTaskRunner.ets 只做计算,TaskRegistry.ets 管任务登记,页面只接收最终结果。如果 Hash 还要打开文件,open/close 也必须在并发函数内部成对处理。

五、任务取消后,Registry 必须比 UI 更早收口

取消以后 Registry 立即删除;Promise finally 再执行一次 delete() 也没关系。最终必须同时满足 Cancelled=12、Executed=36、Active Tasks=0、Latest Generation=5,其中 Active Tasks=0 是任务生命周期真正结束的信号。

六、批量任务不是越多越好,还要看当前业务是否真的需要

TaskPool 的价值是把 CPU 工作移出主线程,不意味着所有任务都要长期有效。页面临时任务应跟随可视范围提交和取消;全库预计算则应使用另一套后台作业策略。

七、调试页只保留能判断并发是否收口的数据

HiLog 固定输出:

batch=thumb_hash_091 files=48

queued=48

cancelled=12

executed=36

lateResultsDropped=3 generation=5

activeTasks=0

State: RUNNING -> STABLE

运行结果里最关键的是:

  • Queued:48
  • Executed:36
  • Cancelled:12
  • Late Results Dropped:3
  • Active Tasks:0
  • Latest Generation:5
  • Last Task ID:62017
  • Hash Cost:184 ms

Active Tasks=0 说明 Registry 没留下幽灵任务。

八、正式项目里还要补几个边界

正式项目还要注意:cancel 不是事务回滚;多页面共享同一素材时要做引用计数;高频任务通信不能直接变成 UI 刷新;大批量任务应控制提交节奏并带明确 batchId。

九、这次我真正补上的,是 Task 的“归属感”

最后状态链路变成:

QUEUED → RUNNING → CANCELLING → STABLE

TaskPool 负责执行,Registry 负责去重,cancel 负责回收无效计算,generation 负责丢弃迟到结果。

并发任务真正稳定,不是因为“线程跑起来了”,而是每一个 Task 都知道自己为什么存在、什么时候失效,以及结果回来以后还能不能被当前页面接收。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐