☰
Parallel.js 并行计算实战:spawn、map、reduce 性能优化
2026/11/4 21:56:11 网站建设 项目流程

你如果做过浏览器端的批量数据处理,大概都遇到过这种场面:页面上点一个“导出报表”,主线程瞬间被一个几十万行的循环占满,进度条卡在 0%,用户以为页面死了,连刷新都点不动。我第一次碰这类问题时的直觉是“加个 setTimeout 分片跑”,结果只是把卡顿从 8 秒变成 8 秒的碎卡顿,体验没本质改善。真正让主线程喘上气的做法,是把重计算搬进 Worker。而 Parallel.js 就是那个把 Worker 池、任务切分、结果汇合这三件事封装成一个轻量对象的库——它最常用的三个入口就是spawn、map、reduce。

这篇内容围绕这三个核心 API 展开,讲清楚它们各自的调用契约、内部执行链路、参数背后的成本账,以及在真实项目里怎么组合着用。适合已经知道 Worker 是什么、但还没把并行计算真正跑出加速比的前端同学;也适合接手了一个“用了 Parallel.js 但还是卡”的遗留项目、想搞清楚问题出在哪里的读者。我会把闭包丢失、分片粒度、序列化开销这些实际会咬人的细节拆开讲,代码都给到可复制的程度。

1. 从主线程卡死到 Worker 池:Parallel.js 的定位与能力边界

1.1 一个典型的单线程阻塞场景

先还原一下问题现场,因为很多人在选型时把 Worker 想得太万能。有个内部数据看板,需要把用户上传的 CSV 解析成对象数组,再做分组、求和、排序,最后生成一份可下载的汇总表。数据量在 30MB 到 80MB 之间浮动,解析加聚合在主线程跑,实测 6 到 12 秒,期间页面完全无响应,滚动、点击、输入全部挂起。

我最初的处理方式是给循环里插await new Promise(r => setTimeout(r, 0)),每处理 5000 行让出一次。这个方案能让页面“不那么死”,但整体耗时反而涨了 20% 左右,因为每次让出都意味着一次宏任务调度,而且数据处理本身仍然是串行的。更关键的是,让出时机之后主线程还是要处理渲染,用户感受到的是持续的掉帧,不是流畅。

真正的解法是把计算拆到 Worker 里。Worker 的好处是独立线程、独立事件循环,主线程只管收消息和渲染。但裸写 Worker 有一堆琐碎活:创建几个、怎么切分数据、怎么把结果合并、错误怎么回传、Worker 生命周期怎么管。Parallel.js 把这些打包成new Parallel(data).map(fn).then(cb)这种链式写法,代码量能压到原来的三分之一。

1.2 它的三段式流水线:切分、派发、汇合

Parallel.js 的所有 API 本质上都在跑同一条流水线:把输入数据按 worker 数量切成分片 → 每个 worker 拿一片独立计算 → 主线程把各片结果按原顺序拼回来。spawn 是这条流水线的退化版(只有一片、只有一个 worker),map 是标准版,reduce 则是在标准版后面再挂一段归并。

理解这条流水线,就能明白它的能力边界在哪。它擅长的是** embarrassingly parallel** 型任务——分片之间没有依赖,每片算完就完事。比如对十万条记录逐条做格式转换、对一张大图按区块做像素运算、对一批文本做分词统计。它不擅长的是有强顺序依赖的任务,比如滚动哈希、有状态的流式聚合、需要跨分片通信的迭代算法。这类任务硬塞进去,要么结果错,要么并行度用不满。

还有个容易被忽略的边界:并行不等于免费。每个任务从主线程发到 Worker,数据要经过一次结构化克隆(structural clone),这是深拷贝,不是引用传递。80MB 的数组发四次,内存和 CPU 的代价相当可观。所以 Parallel.js 的加速比公式大致是总耗时 ≈ 序列化成本 + 计算成本/worker数 + 结果回传成本,当计算本身很轻时,前两项会直接吃掉收益甚至倒挂。

1.3 版本差异与安装方式

这里得提醒一句:npm 上的paralleljs和十几年前流传的那份parallel.js单文件,行为是有区别的。老版本依赖一个外部 worker 脚本,通过 AJAX 加载后再 eval;新版本倾向于用 Blob URL 动态生成 Worker。这个差异会直接影响你在打包工具下的表现——老版本在某些构建配置里会 404,新版本则可能撞上 CSP 的blob:限制。

安装就是常规的npm install paralleljs,然后在模块里import Parallel from 'paralleljs'。如果你要在 Node 环境跑,注意它的 Worker 实现会退化到child_process或直接同步执行(取决于配置),跨进程的数据传递成本比浏览器里高不少,这时候它更多是个 API 统一层,别指望拿到浏览器里那样的加速比。

提示:动手之前先确认你的运行环境支持Worker和Blob。如果不支持(比如某些内嵌 WebView),Parallel.js 会静默退化成单线程执行,代码不报错但完全没有并行,很容易误判成“这库没用”。

2. spawn:最自由也最容易踩坑的入口

2.1 spawn 的调用契约与返回值

spawn是三个 API 里最底层的一个。它只用一个 Worker,把你的函数扔进去跑一次,然后把函数返回值原样送回主线程。签名是p.spawn(fn, env),其中fn接收一个对象参数,返回值就是最终结果。

import Parallel from 'paralleljs'; const p = new Parallel([42], { maxWorkers: 1 }); p.spawn(function (payload) { // payload.data 是构造 Parallel 时传入的数组 const n = payload.data[0]; let sum = 0; for (let i = 0; i < n; i++) sum += Math.sqrt(i); return sum; // 这个值会原样传给 then }).then(function (result) { console.log('结果:', result); });

有两点必须记牢。第一,fn里拿到的不是你的原始数组,而是一个包装对象,数据在payload.data里,另外还有payload.offset和payload.rSeed两个字段,后者用于随机数种子分发,做蒙特卡洛模拟之类需要可复现随机性时能派上用场。第二,fn的返回值必须是可结构化克隆的东西——数字、字符串、普通对象、数组、TypedArray 都行,函数、DOM 节点、类实例(带原型链方法的)不行,回传时会直接抛错或者变成空对象。

很多人第一次用 spawn 会写错,是因为把它当成了Promise的.then,以为可以链式串多个。实际上p.spawn()返回的是 Parallel 实例本身,.then()才是结果回调,而且这个then不是标准 Promise 的 then,不能在它后面接.catch()。想要错误处理,得自己包一层 try/catch 在 worker 函数里,把错误信息当结果返回。

2.2 函数序列化:闭包为什么在 Worker 里消失了

这是 spawn 最大的坑,也是新手最容易困惑的地方。Parallel.js 把函数送进 Worker 的方式是fn.toString(),也就是只把函数的源码文本发过去,然后在 Worker 里重新 eval 出来。这意味着函数体里引用的所有外部变量都会丢失,因为它们只存在于主线程的作用域里。

const factor = 1.5; // 主线程变量 p.spawn(function (payload) { // 这里访问 factor 会抛 ReferenceError return payload.data[0] * factor; });

跑起来会看到 worker 里报factor is not defined。理解这个机制之后就很好办,三条路可以走:

  1. 把依赖通过参数传进去。改造方法是在主线程先把数据算好,塞进new Parallel([...])的数组里,worker 函数只从payload.data取。
  2. 用 env 参数注入。下面会细讲。
  3. 把常量写死在函数体里。这是最土但最可靠的办法,适合那些固定不变的配置。

还有个相关的坑:函数体里不能用某些语法。因为要走toString()+ eval 这条路,如果你的构建工具开了压缩混淆,函数源码可能被压成一行甚至改了变量名——变量名改了没关系(都是局部的),但如果用了箭头函数、可选链、类的私有字段这类语法,在旧版 Worker 环境的 eval 里可能直接语法错误。稳妥做法是 worker 函数里避开太新的语法糖,宁可写得啰嗦一点。

注意:如果你在函数里console.log,输出会出现在 Worker 的控制台上下文里,Chrome DevTools 需要在 Console 面板右上角切换执行上下文才能看到。很多人以为日志没打出来是代码没执行,其实是找错地方了。

2.3 env 参数的成本账与用法

spawn(fn, env)的第二个参数就是专门用来解决闭包问题的。你传一个普通对象进去,Parallel.js 会把它克隆到 Worker 的全局作用域里,函数体里通过this.env或者直接env就能读到(具体位置跟版本有关,拿不准的话在函数开头console.log(this)看一眼就行)。

p.spawn(function (payload) { const cfg = this.env; // 常见注入位置 const base = payload.data[0]; return base * cfg.factor + cfg.bias; }, { factor: 1.5, bias: 10 });

关键成本账在这里:env 同样是深拷贝。传一个几百字节的配置对象无所谓,但如果图省事把几 MB 的查找表塞进 env,每创建一个 Worker 就复制一次,开销会直接吃掉并行收益。我的经验是 env 只放小的、只读的、结构简单的配置,超过几十 KB 的数据一律走new Parallel(data)的数组通道,因为那条通道至少还能通过分片摊薄成本。

另外 env 里别放函数。虽然某些实现里能传过去,但函数在克隆过程中会变成undefined或者直接失败,行为不可靠。真需要 worker 内部用一个辅助函数,就把它定义在 worker 函数体内部,随源码一起被toString()带走。

3. map:分片粒度直接决定加速比

3.1 map 的完整执行链路

map是日常用得最多的一个,它做的事就是“对数组每个元素跑一遍同一个函数,结果按原顺序收集”。写起来比原生Array.prototype.map只多一个.then:

const p = new Parallel(records, { maxWorkers: 4 }); p.map(function (item) { return { id: item.id, score: item.a * 0.6 + item.b * 0.4 }; }).then(function (results) { // results 是一个数组,顺序跟 records 完全一致 render(results); });

它内部的链路是:主线程把records按maxWorkers切成若干片,每片生成一个 Worker(最多同时存在maxWorkers个),Worker 里对分片内每个元素调用你的函数,算完把分片结果数组回传,主线程按分片顺序拼接成最终数组。整个过程对顺序是有保证的——你不用担心结果乱序,这一点比自己手写 Worker 池省心很多。

有一点值得说清楚:map 里的函数对每个元素各调一次,所以这种写法最自然,也最符合直觉。但它同时意味着函数里不要放累积型的逻辑(比如往外层数组 push),因为每个分片在各自线程里跑,push 到的是各自的局部变量,主线程看不到。

3.2 分片数与 maxWorkers 的配合

maxWorkers默认取navigator.hardwareConcurrency,也就是 CPU 逻辑核数。这个默认值在大多数机器上合理,但有两个例外必须手动调。

第一个例外是用户设备本身就是多任务重载状态。你开 8 个 Worker 占满 8 核,浏览器 UI 线程、其他标签页、系统进程全在抢 CPU,实际观感是整机变卡。我一般把maxWorkers设成Math.max(2, hardwareConcurrency - 1),留一个核给主线程做渲染和事件响应。

第二个例外是分片数远小于 Worker 数。假设你只有 6 个元素,却设了 8 个 Worker,那会有 2 个 Worker 白启动,创建和销毁的开销纯亏。反过来,元素数是 10000、Worker 数只有 4,每个 Worker 分到 2500 个元素,分片够大,调度开销摊得薄,这是理想情况。

真正要调的是每片的元素数量,而不是分片数量。我用过的一个经验区间是:每个分片的任务量控制在 10ms 到 100ms 之间。低于 10ms,调度和序列化的成本占比过高;高于 100ms,容易出现尾部效应——最后一片算完了,其他片早就空了,Worker 在闲置。下面这张表是我在几组不同规模数据上实测的倾向性结论(数值是相对单线程的加速比,测试机为 8 逻辑核):

数据规模单元素耗时建议分片数实测加速比说明
1000 条0.01ms不分片0.4x序列化成本远大于计算,纯亏
10000 条0.1ms41.8x开始有收益,但不多
100000 条0.1ms64.2x甜点区
10000 条5ms65.1x计算重,分片少也无所谓
100 条200ms63.0x尾部效应明显,6 片里最短的那片拖尾

从表里能看出来,决定成败的不是数据量,而是总计算量。总计算量在几十毫秒以下,别用并行;到几百毫秒以上,并行才开始划算;到秒级,并行的收益就非常明显了。

3.3 结果顺序、异常与部分失败

map的结果顺序是有保证的,这一点上面说过。但异常处理没有兜底。如果某个分片里的函数抛了错,默认行为是那个 Worker 直接挂掉,主线程的.then永远不会被调用,程序就卡在那里,没有任何提示。这个坑我踩过一次,排查了半天才定位到是某个脏数据导致JSON.parse失败。

稳妥写法是在 worker 函数内部自己兜住:

p.map(function (item) { try { return { ok: true, value: transform(item) }; } catch (e) { return { ok: false, id: item.id, reason: e.message }; } }).then(function (results) { const failed = results.filter(r => !r.ok); if (failed.length) console.warn('失败条目:', failed.length, failed.slice(0, 5)); const values = results.filter(r => r.ok).map(r => r.value); render(values); });

把每个元素包成{ok, value}或者{ok:false, reason},这样单条数据出问题不会拖垮整批,还能在主线程统一汇总失败原因。这个模式在真实业务里几乎是必须的——你永远不知道用户上传的数据里藏着什么。

关于超时,Parallel.js 本身没提供超时机制。如果某个分片可能陷入死循环(比如用户输入的参数导致 while 不退出),唯一的保护是在主线程用setTimeout加一个兜底计时器,超时后手动把 UI 切到失败态,并放弃这次计算的结果。Worker 本身不会被强杀,但至少用户不会一直看到转圈。

4. reduce:被误解最深的聚合 API

4.1 reduce 的回调签名为什么长那样

第一次看 Parallel.js 的 reduce 示例,多数人都会愣一下——它的回调和原生Array.prototype.reduce完全不一样:

const p = new Parallel([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); p.reduce(function (data) { return data.data[0] + data.data[1]; }).then(function (total) { console.log(total); // 45 });

回调参数是一个对象,里面有data数组和offset,而不是原生 reduce 的(acc, cur, idx, arr)四件套。为什么这么设计?因为并行归并的结构和串行折叠不一样。串行 reduce 是一条链,每次拿“累加器 + 当前元素”;而并行归并是一棵树,worker 内部先把分片折成一个中间值,主线程再把各分片的中间值两两折。

那个data数组里装的,就是当前待合并的两个东西——可能是两个原始元素,可能是两个分片的中间结果,也可能是中间结果加原始元素。你的函数只需要回答一个问题:把这两个东西合成一个,结果是什么。这种设计的好处是函数天然是结合律友好的,只要能写成两两合并,就能并行。

必须强调一点:data的长度在绝大多数调用中是 2,但边界情况下可能只有 1(比如分片元素数为奇数时,最后会剩一个单独的元素)。稳妥写法是判一下长度:

p.reduce(function (payload) { const arr = payload.data; if (arr.length === 1) return arr[0]; return merge(arr[0], arr[1]); });

4.2 两阶段归并与 offset 的作用

reduce 的实际执行分两段。第一段是分片内归并:数据按 worker 数切片,每个 Worker 在自己那片里反复调用你的回调,把整片折成一个中间值。第二段是跨片归并:主线程拿到 N 个中间值后,再调用若干次回调,最终折成一个总结果。

offset字段就是分片在原数组里的起始下标。它的价值在于,如果你的聚合逻辑需要知道“这两个元素在原始序列里的相对位置”,可以靠它做判断。比如做加权平均时,权重要跟原始下标对应,offset就能用上。但大多数求和、求最值、求并集的场景根本用不到它,忽略即可。

这里有个常见的错误模式:有人把 reduce 当成 map 用,在回调里写复杂逻辑,导致归并树不平衡。因为 reduce 的回调会被调用n-1次(n 是参与归并的元素总数),如果每次调用都很重,总开销会比串行一遍还大。正确的思路是让每次合并尽可能轻,把重活放在 map 阶段做完。所以实践中我更喜欢“先 map 成局部结果,再用 reduce 合并”的两段式,而不是指望 reduce 一步到位。

4.3 数据倾斜与空分区

reduce 的性能杀手是数据倾斜。假设你要把一批日志按类型分组计数,某些类型的日志量占了 80%,那么某个分片里会挤满这类数据,它算得特别慢,其他 Worker 早早就空转等它。并行度名义上是 8,实际有效并行度可能只有 2 到 3。

应对办法有两个。一是在切分前打乱数据顺序,让热点分散到各个分片。这个操作本身在主线程是 O(n),但相比后面省下的时间完全值得。二是换切分策略,不用均匀切片,而是按预估工作量做加权切分。Parallel.js 没暴露切分策略的接口,所以实际做法是:先自己把数据切好成若干个“工作量相近的批次”,再用new Parallel(batches)让每个元素就是一个批次,然后在 worker 函数里对该批次做内部循环。这样等于用一层间接,把切分权拿回自己手里。

空分区的问题更隐蔽。如果你传入的数组里有大量null、undefined、或者 worker 函数对某些元素直接返回undefined,归并阶段可能拿到一堆“空”的中间值。求和场景下这不出错(加 0 不影响),但求最大值、求平均就会出问题。我的习惯是在 worker 函数入口统一过滤:

p.map(function (item) { if (!item || typeof item.value !== 'number') return null; return compute(item); }).then(function (results) { const clean = results.filter(r => r !== null); // 后续只用 clean });

先把脏数据挡在 map 阶段,reduce 阶段只处理干净的中间值,逻辑会简单很多,也更容易调试。

5. 三者的选型对照与实测数据

5.1 一张决策表

这三个 API 不是递进关系,而是三种不同的任务形状。选错了不会报错,但要么白费力气,要么根本跑不出正确结果。

场景特征该用哪个理由
只跑一个重计算任务,输入是一份配置或一个大对象spawn不需要分片,一个 Worker 就够
对数组每个元素做同构变换,元素间无依赖map天然可分片,顺序有保证
需要把数组折成单个结果(求和、求最值、求并集)reduce自动做两阶段归并
需要先变换再聚合map 后用原生数组方法,或 map + reduce 组合避免 reduce 回调过重
分片之间需要交换中间数据都不合适得自己搭 Worker + 消息协议
计算量在几十毫秒以下都不用直接主线程串行更快

表里最后两行最重要。很多人一开始就想“能不能都用 Parallel.js 解决”,答案是不能,而且越早接受这个结论越省时间。它是个轻量库,不是分布式计算框架。

5.2 序列化成本压过计算成本的临界点

这是我最想让读者记住的一件事:Parallel.js 的瓶颈往往不在计算,而在数据搬运。每次new Parallel(data)、每次.then回传结果、每次 env 注入,都是一次深拷贝。拷贝成本跟数据规模成正比,而且这个系数不低。

我做过一组粗糙的测量,环境是桌面浏览器的普通配置,数据是一维数字数组:

数据规模单次 postMessage 往返耗时(约)
1 万个数字3 到 6 ms
10 万个数字30 到 60 ms
100 万个数字300 到 700 ms
10 万个对象(每个 5 个字段)150 到 300 ms

这些数字不是精确基准,但量级可以参考。它说明:如果你有 100 万个元素、每个元素的计算只要 0.001ms,那计算总耗时 1 秒,两次搬运就要 0.6 到 1.4 秒,加速比会非常难看。而如果你的每个元素要算 0.05ms,计算总耗时 50 秒,那点搬运成本就是零头。

所以判断一个任务值不值得并行,我习惯先做单线程小样本测试:取 1% 的数据跑一遍,测出单元素耗时,乘以总量估算总计算时长。如果总时长低于 200ms,直接放弃并行;如果在 200ms 到 1 秒之间,可以试试但别抱太高期待;超过 1 秒,并行才开始有明确价值。

顺带说一个减成本的小技巧:尽量传 TypedArray 而不是普通数组。Float64Array、Uint32Array这类结构在结构化克隆时的效率比对象数组高得多,体积也小。如果你的数据是纯数值,在传进 Parallel 之前转成 TypedArray,能省下不少时间。但要注意,worker 函数里就得按 TypedArray 的方式遍历,别用Array.isArray做判断。

5.3 混合编排:spawn 做预热,map 做批处理

实际项目里,三个 API 混着用会比单用一个效果好。我常用的一个编排模式是:spawn 做一次性的初始化,map 做主力批处理。

比如做图像处理,需要先构建一个降采样用的查找表(几百 KB),然后对几十万个像素块逐个套用。查找表构建一次就够了,用 spawn 在单个 Worker 里跑完拿回来;像素块处理用 map 并行。但问题是,map 的每个 Worker 也需要那份查找表——这时候要么通过 env 传(每个 Worker 复制一份,成本高),要么在 map 的 worker 函数内部重新构建一份(每个 Worker 各算一次,用 CPU 换内存)。

我一般选后者。因为查找表构建本身是纯计算,几十毫秒的事,让每个 Worker 各算一份,总时间也就多几十毫秒,比复制几百 KB 数据划算。这个取舍的逻辑是:当“重算”比“传输”便宜时,就重算。这条原则在并行计算里非常通用,值得记下来。

另一种编排是分阶段:先用 map 把原始数据规整成紧凑的中间结构,主线程做一次轻量的过滤和排序,再用 map 做第二轮重计算。分两段的好处是中间可以插入进度反馈,用户不至于盯着一个不动的进度条。Parallel.js 本身不提供进度事件,但你可以把数据切成几大批,每批单独new Parallel(...)跑一次,批与批之间更新进度——用多次小任务换可观测性,这在长任务里是值得的。

6. 工程化落地:打包、调试与降级

6.1 evalPath 与 Worker 加载失败

Parallel.js 需要一份 Worker 脚本。新版倾向于用Blob动态生成,但如果你的页面有严格的 CSP(内容安全策略),blob:协议可能被拦,这时候就需要用options.evalPath指定一个真实存在的脚本路径:

const p = new Parallel(data, { maxWorkers: 4, evalPath: '/static/js/parallel-worker.js' });

这个路径必须能被 Worker 构造器直接加载,也就是同源、可访问、Content-Type 正确。如果路径写错,最常见的现象是没有任何报错,.then永远不触发——因为 Worker 加载失败是异步的,错误被静默吞掉了。排查办法是打开 Network 面板,过滤一下有没有那个 js 文件的请求,状态码是不是 200。我遇到过路径被构建工具加了 hash 的情况,配置里写死文件名就必然 404。

还有个细节:Worker 脚本里如果要importScripts加载别的依赖,那些依赖也得是能直接跑的浏览器脚本,不能是 CommonJS 模块。这一点在混合使用打包工具时特别容易翻车——主应用是 ESM,Worker 里却是老式的全局脚本,两边依赖共享不了,只能各打一份。

6.2 无法克隆的对象与报错定位

结构化克隆不支持的东西比想象中多:函数、Symbol、DOM 节点、Error对象的部分字段、带 getter 的类实例、Proxy、WeakMap。一旦你不小心把这类东西塞进new Parallel(data),报错信息通常很含糊,只告诉你“could not be cloned”,不说是哪个字段。

定位办法是逐层收敛。先确认顶层是数组还是对象,再把每个字段单独传一次试试:

// 逐个字段试探,找出无法克隆的那个 const sample = data[0]; for (const key of Object.keys(sample)) { try { structuredClone(sample[key]); } catch (e) { console.error('无法克隆的字段:', key, e.message); } }

structuredClone是浏览器原生 API,能不能克隆它说了算,跟 Parallel.js 用的机制一致,所以拿它做预检是最准的。我现在的习惯是在数据入口处加一段开发环境的预检,只在process.env.NODE_ENV !== 'production'时跑,提前把问题暴露出来,别等到线上才炸。

还有一种隐性失败:数据里混了Date对象。结构化克隆是支持Date的,克隆过去还是Date,但如果你的 worker 函数里用JSON.stringify做比较,Date会变成字符串,逻辑就错了。这类问题不会报错,但结果不对,排查起来很费劲。我的做法是在进 Parallel 之前统一把数据序列化成扁平结构,把Date转成时间戳数字,把类实例转成普通对象,让 worker 里只处理纯数据。

6.3 降级与特性检测

上线前必须考虑不支持 Worker 的环境。虽然现在主流浏览器都支持,但内嵌 WebView、部分受限的浏览器扩展环境、以及某些企业内网环境里,Worker 可能被禁用。Parallel.js 在检测不到 Worker 时会不会静默降级,取决于版本,不能赌。

我的做法是显式检测,然后准备两条路径:

function processData(data) { const canUseWorker = typeof Worker !== 'undefined' && typeof Blob !== 'undefined' && typeof structuredClone === 'function'; if (!canUseWorker) { // 降级:主线程分片执行,配合 requestIdleCallback 让出 return runInMainThreadChunked(data); } return new Parallel(data, { maxWorkers: pickWorkerCount() }) .map(workerFn) .then(r => r); } function pickWorkerCount() { const cores = navigator.hardwareConcurrency || 4; return Math.max(2, Math.min(cores - 1, 8)); }

注意pickWorkerCount里那个Math.min(cores - 1, 8)的上限。核数特别多的机器(比如 32 核工作站)上开 31 个 Worker,创建开销和内存占用会很夸张,而且收益早就到顶了。8 个是个经验上限,再往上加,加速比增长基本停滞,调度开销反而上升。

降级路径里那个runInMainThreadChunked需要自己实现,核心就是把数据切成小块,每块处理完用setTimeout(0)或者requestIdleCallback让出一次,保证页面至少能响应输入。它的性能不如 Worker,但至少不会白屏。


我在多个项目里用下来,最深的体会是:Parallel.js 的价值不在“帮你写 Worker”,而在于它把这个决策逼到你面前。你不用它的时候,很容易想当然地觉得“这事并行一下就好了”;用了它之后,你会被迫算清楚数据有多大、每片要算多久、传一次要多久,然后发现有一多半的场景其实不该并行。我现在的习惯是先写串行版本,测出耗时,只有超过 1 秒且分片之间确实无依赖时,才考虑换 Parallel.js。这个顺序反过来,大概率是在给自己挖坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询