把计算丢进 TaskPool,本身不难。真正容易出问题的是:主线程传过去的对象到底是复制一份,还是多个任务看同一份?任务里能不能改?如果任务运行中要告诉页面“已经处理到第几批”,这个进度又该放在哪里?
这类问题如果前期没拆清楚,后面会出现很难排查的现象:数据量一大就慢,明明只是读配置却被多处改掉,同一个任务跑完以后 UI 进度和真实结果对不上。我的处理方式比较固定:输入配置尽量做成只读,任务只产出结果和进度事件,不在子任务里改共享输入。
先把几个概念放到同一张桌子上
| 名称 | 在这个问题里负责什么 |
|---|---|
| TaskPool | 适合短时间、可拆分、可并发的计算任务 |
| Worker | 更适合常驻线程、长时间消息循环、持续通信 |
| 普通对象 | 跨线程时通常按隔离内存传递,容易带来拷贝成本 |
| Sendable | 用来表达可以在线程间共享的数据边界 |
| freeze/只读约束 | 让共享输入不能被任务偷偷改掉 |
这几个点不要分开看。只说 TaskPool,容易把所有东西都丢进去;只说 Sendable,又容易忽略任务生命周期;只说 freeze,又容易忘记进度和结果不能也一起冻住。比较稳的做法是:输入、进度、结果分三层。
问题一:大对象每个任务都复制一份,数据量上来就慢
先看一个常见场景:页面上有一批条目,要按规则做评分、过滤、分组。规则对象本身很大,里面可能有权重、黑名单、版本号、候选 ID。很多人一开始会直接把整包规则传给每个任务。
interfaceRankingRules{version:numberminScore:numberblockedTags:string[]weights:Record<string,number>recipeIds:string[]}@ConcurrentfunctionrankSlice(rules:RankingRules,sliceIndex:number):number{// 每个任务都拿到一份 rules,看起来简单,但大对象会被反复传递returnrules.recipeIds.filter((_,index)=>index%4===sliceIndex).length}小数据量时这没什么感觉;一旦规则对象很大,任务又拆得多,问题就明显了:真正的计算可能不慢,慢的是任务启动前后的数据传递。
我更推荐把规则当作只读输入来处理。任务只读它,不改它;任务自己的输出单独返回。
@SendableclassRankingRulesSnapshot{version:number=0minScore:number=60blockedTags:string[]=[]weights:Record<string,number>={}recipeIds:string[]=[]freezeAfterBuild():RankingRulesSnapshot{Object.freeze(this.blockedTags)Object.freeze(this.weights)Object.freeze(this.recipeIds)Object.freeze(this)returnthis}}@ConcurrentfunctionrankSliceWithSnapshot(rules:RankingRulesSnapshot,sliceIndex:number):number{returnrules.recipeIds.filter((_,index)=>index%4===sliceIndex).length}这里重点不是“用了一个装饰器就一定更快”,而是边界清楚了:规则快照是输入,任务结果是输出。输入不承担进度、不承担临时状态,也不承担 UI 展示状态。
问题二:任务想回传进度,顺手改了共享对象
另一个更隐蔽的问题是进度。比如一个任务处理到一半,想告诉页面“第 2 组完成了”。如果直接在共享对象里写finishedCount,后面很容易乱:多个任务同时写,顺序不稳定;某个任务失败了,输入对象还留着半截状态;下一轮任务复用对象时,旧进度没清掉。
不要这么写:
@SendableclassBadSharedState{recipeIds:string[]=[]finishedCount:number=0}@ConcurrentfunctionbadTask(state:BadSharedState,sliceIndex:number):number{constcount=state.recipeIds.filter((_,index)=>index%4===sliceIndex).length state.finishedCount+=countreturncount}这个写法最麻烦的地方不是语法,而是职责混在了一起:recipeIds是输入,finishedCount是运行过程,最后返回值又是结果。一个对象同时扮演三个角色,后面一定难维护。
我会把进度单独做成事件。
interfaceProgressEvent{taskIndex:numberphase:'start'|'done'|'failed'accepted:number}interfaceSliceResult{taskIndex:numberaccepted:numberprogress:ProgressEvent}@ConcurrentfunctionrankSliceAndReport(rules:RankingRulesSnapshot,sliceIndex:number):SliceResult{constaccepted=rules.recipeIds.filter((_,index)=>index%4===sliceIndex).lengthreturn{taskIndex:sliceIndex,accepted,progress:{taskIndex:sliceIndex,phase:'done',accepted}}}这样页面侧拿到结果后再合并:
asyncfunctionrunRanking(rules:RankingRulesSnapshot){consttasks=[0,1,2,3].map(index=>{returntaskpool.execute(newtaskpool.Task(rankSliceAndReport,rules,index))})constresults=awaitPromise.all(tasks)consttotal=results.reduce((sum,item)=>sum+item.accepted,0)constprogress=results.map(item=>item.progress)return{total,progress}}这里有一个很实用的判断标准:只要某个字段会随着任务执行变化,就不要把它塞进共享输入对象。它应该是结果,或者是事件。
我本地用一个小脚本验证了边界
为了避免只停留在概念上,我用脚本模拟了两种方式:
- 普通传递方式:4 个任务各自复制一份规则;
- 只读共享方式:规则对象只构建一次,任务只读,进度单独返回。
运行结果是这样的:
{"ok":true,"cloneStyle":{"cloneCount":4,"totalAccepted":1200},"sharedReadonlyStyle":{"cloneCount":0,"totalAccepted":1200,"progressEvents":[{"taskIndex":0,"phase":"done","accepted":300},{"taskIndex":1,"phase":"done","accepted":300},{"taskIndex":2,"phase":"done","accepted":300},{"taskIndex":3,"phase":"done","accepted":300}]},"frozenCheck":{"mutationBlocked":true,"minScoreStill":60}}这个验证说明三件事:
- 规则对象不应该被每个任务重复复制;
- 只读输入可以防止任务偷偷改配置;
- 进度事件和最终结果应该独立返回,不要污染输入对象。
几种方案怎么选
| 方案 | 适合场景 | 风险 |
|---|---|---|
| 普通对象直接传 | 小对象、低频任务、逻辑简单 | 数据大了容易有拷贝成本 |
| Sendable 共享输入 | 大对象、多任务复用、只读规则 | 需要遵守 Sendable 约束 |
| freeze 后共享 | 配置、规则、只读快照 | 不适合需要频繁变更的状态 |
| Worker 常驻 | 长时间通信、持续任务 | 生命周期和消息协议更重 |
我的选择顺序一般是:
- 只是一次很小的计算,用普通对象就够;
- 规则对象很大,多个 TaskPool 任务都要读,用 Sendable 或共享快照;
- 这个对象不应该被改,就在构建完成后冻结;
- 如果需要长时间持续通信,再考虑 Worker。
可以封装成一个小工具
项目里可以把这套边界收成一个小的任务入口。
classParallelRankingRunner{asyncrun(rules:RankingRulesSnapshot,sliceCount:number):Promise<number>{consttasks=Array.from({length:sliceCount},(_,index)=>{returntaskpool.execute(newtaskpool.Task(rankSliceAndReport,rules,index))})constresults=awaitPromise.all(tasks)returnresults.reduce((sum,item)=>sum+item.accepted,0)}}这样调用方只关心“我要跑几片、总结果是多少”,不用每个页面都重新想一遍线程间对象怎么传、进度怎么回、配置能不能改。
最后怎么避免踩坑
我会把检查项写成四条:
- 输入对象只放输入,不放进度;
- 共享输入能只读就只读;
- 任务结果单独返回,失败信息也单独返回;
- 大对象进入 TaskPool 前先判断有没有必要共享,别为了用新能力而用新能力。
Sendable 真正解决的不是“语法怎么写”,而是线程间数据边界怎么更清楚。边界清楚以后,TaskPool 代码会简单很多:输入稳定,任务独立,结果可合并,页面也不会被一堆临时状态拖乱。