
最近我给一个本地素材库加了“缩略图内容哈希”。目标很简单导入一批图片后在后台算出轻量 Hash用来辅助重复素材判断。第一版我直接把 48 个文件全部扔进 TaskPool页面看起来很流畅但只要用户快速切换相册、重新筛选一次问题马上出现同一路径会被重复提交已经不需要的任务还在继续跑旧任务稍晚返回以后甚至会覆盖新一轮列表结果。真正难的不是并发本身而是任务身份、取消时机和结果归属。DemoThumbHashGuardLab的批次为thumb_hash_09148 个文件最终执行 36 个、取消 12 个、丢弃 3 个迟到结果generation 为 5结束后Active Tasks0。一、TaskPool 只负责并发业务还得自己定义“同一个任务是谁”最开始我的代码每次进入可视区都 new 一个taskpool.Task。如果同一张图因为列表复用、筛选刷新又进来一次就会再提交一份完全相同的 Hash 计算。系统能执行它们但不知道这两个任务在业务上其实是同一个。所以我先加一层 RegistryKey 直接使用素材路径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 ): Promisevoid { 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 Dropped3。如果结果还会写数据库也要把 generation / batchId 带进持久化层避免旧任务覆盖新数据。三、页面已经不需要的任务要主动 cancel而不是等它自然结束第二个问题来自相册切换。用户从 48 个文件的目录切到另一个筛选条件后其中 12 个 Hash 任务已经没有任何展示价值。如果让它们继续占 TaskPool虽然最终结果会被 generation 丢弃但 CPU 还是白跑了一遍。我会在新的可见集合稳定后取消不再需要的任务private cancelStale( visiblePaths: Setstring ): 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()也没关系。最终必须同时满足Cancelled12、Executed36、Active Tasks0、Latest Generation5其中Active Tasks0是任务生命周期真正结束的信号。六、批量任务不是越多越好还要看当前业务是否真的需要TaskPool 的价值是把 CPU 工作移出主线程不意味着所有任务都要长期有效。页面临时任务应跟随可视范围提交和取消全库预计算则应使用另一套后台作业策略。七、调试页只保留能判断并发是否收口的数据HiLog 固定输出batchthumb_hash_091 files48queued48cancelled12executed36lateResultsDropped3 generation5activeTasks0State: RUNNING - STABLE运行结果里最关键的是Queued48Executed36Cancelled12Late Results Dropped3Active Tasks0Latest Generation5Last Task ID62017Hash Cost184 msActive Tasks0说明 Registry 没留下幽灵任务。八、正式项目里还要补几个边界正式项目还要注意cancel 不是事务回滚多页面共享同一素材时要做引用计数高频任务通信不能直接变成 UI 刷新大批量任务应控制提交节奏并带明确 batchId。九、这次我真正补上的是 Task 的“归属感”最后状态链路变成QUEUED → RUNNING → CANCELLING → STABLETaskPool 负责执行Registry 负责去重cancel 负责回收无效计算generation 负责丢弃迟到结果。并发任务真正稳定不是因为“线程跑起来了”而是每一个 Task 都知道自己为什么存在、什么时候失效以及结果回来以后还能不能被当前页面接收。