ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Parallel.js 并行计算指南:spawn、map、reduce 实践与避坑

Parallel.js 并行计算指南:spawn、map、reduce 实践与避坑

上周帮人排查一个页面卡死的问题,现象特别典型:点一下“生成报表”按钮,整个页面白屏三秒多,鼠标拖不动、输入框打不进字,用户以为程序崩了,其实是主线程被一段纯计算代码占死了。这个场景下最直接的解法就是把计算挪到 Web Worker 里,而Parallel.js这类库做的事情,就是把“开 worker、切数据、传参、收结果、拼回去”这一整套脏活包成几个链式调用的方法。它的核心 API 就三个:spawn、map、reduce,看起来简单到不需要专门写文章,但我见过太多人第一次用就踩坑——函数里的外部变量莫名变成undefined、map跑完比不跑还慢、reduce的结果每次都不一样。这篇就把这三个方法的执行模型、参数传递机制、分片粒度选择、归约约束条件全部拆开讲,给出可以直接抄的代码骨架和参数取值,也把我踩过的坑按现象和成因整理成速查表。适合已经会用 Promise、了解基本 Worker 概念,但还没把并行计算真正用进生产的前端同学;如果你只是想知道“什么时候该用、什么时候别用”,前两节看完就能有判断。

1. 先想清楚 Parallel.js 该在什么位置

1.1 主线程卡死的本质与 Worker 的隐性账单

浏览器主线程是个单行道,JS 执行、样式计算、布局、绘制全排在一条队里。你写一个for循环跑满 200 毫秒,这 200 毫秒里浏览器什么都干不了,16.7 毫秒一帧的节奏直接断掉,用户看到的就是掉帧甚至假死。Web Worker 的意义在于开一条新的执行线程,把这段循环搬过去,主线程继续接事件、跑动画。

但搬过去不是免费的,这里有一张很多人忽略的账单。第一笔是启动成本:new Worker()本身要解析脚本、初始化上下文,实测在普通桌面浏览器上单次大概 5 到 20 毫秒,移动端更贵。第二笔是传输成本:主线程和 worker 之间传数据走的是结构化克隆算法,不是共享内存,克隆一份 5MB 的Float32Array大概要十几毫秒,传一个包含十万个对象的数组可能上百毫秒。第三笔是调度成本:任务拆分、排队、结果回收、异常传递,每一环都要写代码维护。

Parallel.js帮你省掉的主要是第三笔,顺带把第一笔做了池化复用。它没法帮你省第二笔,甚至因为你把数据切成分片再传,传输次数还变多了。想明白这一点很重要:并行化的收益 = 计算时间 × (1 - 1/核数) - 传输时间 - 调度时间。计算时间不够长,这个式子的结果就是负的。我在一个项目里见过有人用 worker 去算 1000 个数的平方和,结果比同步跑慢了六倍,就是没算这笔账。

1.2 Parallel.js 的定位:一层薄薄的封装

Parallel.js的定位很清晰,它不碰算法、不做调度优化、不搞 SharedArrayBuffer,就是在 Web Worker 之上做了一层任务分发。API 面窄到只有几个方法,源码量也不大,正因为如此它的行为是可预期的——你能大致猜到它在背后干了什么,出问题的时候也知道去哪儿找。

它的大致工作流程是这样的:你给一个数据集合,它按maxWorkers把数据切成分片,每个分片扔给一个 worker,worker 里执行你写的函数,结果序列化回主线程,最后拼起来交给then的回调。整个过程基于Promise,链式写法很顺手。

需要说清楚的是,这个库的活跃度停留在比较早的阶段,社区生态不算繁荣,遇到冷门问题搜索到的资料可能都是好几年前的。但这不影响它作为学习并行模型的范本,也不影响它在一些中小型项目里当轻量工具用。我自己现在的做法是:新项目里 CPU 密集任务优先用原生 Worker 加自己写的调度器,因为可控;但如果是做原型验证、或者团队里没人愿意维护一套 worker 池,Parallel.js依然是个省事的选择。选型上没必要非黑即白。

1.3 三个 API 的职责边界对照

很多人把spawn、map、reduce当成“三种随便挑的写法”,其实它们的适用边界差别很大,用错了不是性能问题,是结果直接不对。

方法数据切分适用场景返回值形态典型误用
spawn不切分,整份数据给一个 worker单次重计算,任务本身不可拆函数返回值拿它循环处理大批数据,等于没并行
map按 worker 数切片元素之间互相独立的批量处理与原数据等长的数组函数里引用外部闭包变量
reduce在 map 结果上折叠聚合、统计、求极值单个累积值累积函数不满足结合律

这张表建议先记住,后面每一节都是围绕其中一行的展开。特别提醒spawn:它的名字容易让人误以为是“开一个 worker 去干活”,实际上它确实只开一个(或按分片开),你如果不给它数组数据,它就老老实实跑一次。想并行处理 10000 条记录却用了spawn循环调用,那等于串行开了 10000 个 worker,性能会比同步还惨。

2. spawn 的最佳实践:一次性重计算怎么挪出去

2.1 spawn 的执行模型:函数是被 toString 过去的

这是理解所有坑的起点。你传给spawn的函数,在执行时并不是“调用”过去的,而是被toString()转成字符串,再在 worker 里eval出来重建的。这意味着什么?意味着这个函数在 worker 里是一个全新的函数对象,它所在的作用域链跟主线程没有任何关系。

看一段很多人写过的代码:

const config = { factor: 2.5 }; const p = new Parallel([1, 2, 3, 4, 5]); p.spawn(function (data) { // 这里的 config 一定是 undefined,甚至会直接抛 ReferenceError return data.map(n => n * config.factor); }).then(res => console.log(res));

在主线程里config是能访问到的,但在 worker 里函数被重建之后,闭包没了,外层作用域没了,config就是个未定义标识符。这个问题在开发环境可能因为打包工具的某些处理侥幸通过,到了生产环境直接炸。

知道了机制,解决思路就清楚了:要么把外部依赖通过参数传进去,要么把依赖序列化后挂到 worker 的全局对象上。Parallel.js提供了后者的官方入口,就是构造时的env选项。

2.2 env 与 require:两种把外部依赖带进 worker 的方式

env是给简单数据准备的。构造Parallel实例时传进去的对象,会在 worker 里挂到全局变量global.env上,函数体里通过global.env.xxx访问:

const p = new Parallel(payload, { env: { factor: 2.5, threshold: 0.35, lut: precomputedLookupTable // 注意:必须是可结构化克隆的类型 } }); p.spawn(function (data) { const { factor, lut } = global.env; return data.map(n => Math.round(n * factor + lut[n & 255])); }).then(res => { console.log(res); });

env里能放什么有硬性限制:普通对象、数组、数字、字符串、布尔、ArrayBuffer和 TypedArray 都行;函数不行,Map、Set在早期浏览器里克隆支持也不完整;DOM 节点、类实例(会丢原型)都不行。我一般会把env控制在 1MB 以内,超过这个量级不如直接走postMessage或者考虑把预处理放到 worker 内部做。

require是给模块准备的:

const p = new Parallel(data); p.require('https://your-cdn.example.com/lib/math-utils.js') .spawn(function (data) { // 假定 math-utils 是 UMD 格式,挂到了全局 MathUtils 上 return data.map(n => MathUtils.normalize(n)); }) .then(handleResult);

这里有个关键认知:worker 里没有 CommonJS 的模块系统,require走的是importScripts,也就是把脚本加载进 worker 的全局作用域。所以你的模块必须是 UMD 或者 IIFE 格式、会往全局挂东西的那种,纯module.exports = {...}的 ESM/CJS 文件加载进来是拿不到的。另外路径要同源或者允许跨域,file://协议下基本没法用。实践经验是:能不用require就不用,把需要的少量逻辑内联到函数里,维护成本低得多。

2.3 实操:一张位图的分片灰度化

说个具体场景。某团队做图像预览工具,用户上传一张 4000×3000 的照片,前端要做灰度化加对比度调整再显示。直接用canvas.getImageData拿到Uint8ClampedArray,长度 4800 万,主线程跑一个像素一个像素算,实测卡了 1.4 秒。

先算一下数据量:4000 × 3000 = 1200 万像素,每个像素 RGBA 四字节,总共 4800 万字节,约 45.8MB。这个量级一次性传给 worker,克隆就要几十毫秒,但比起 1.4 秒的阻塞,还是划算的。

灰度化本身可以并行,因为每个像素独立。但注意spawn不帮你切分,所以要自己切:

function grayScaleParallel(imageData, workerCount) { const { data, width, height } = imageData; const totalPixels = width * height; // 按行切分,保证每片是完整的行,方便后续拼接 const rowsPerChunk = Math.ceil(height / workerCount); const tasks = []; for (let i = 0; i < workerCount; i++) { const startRow = i * rowsPerChunk; const endRow = Math.min(startRow + rowsPerChunk, height); if (startRow >= endRow) break; // 只切片不复制,Uint8ClampedArray.subarray 是零拷贝视图 const chunk = data.subarray(startRow * width * 4, endRow * width * 4); tasks.push({ chunk, startRow, endRow }); } // 每片单独 spawn,Promise.all 汇总 return Promise.all(tasks.map(task => { const p = new Parallel(task.chunk.buffer, { env: { width, contrast: 1.2 } }); return p.spawn(function (buffer) { const arr = new Uint8ClampedArray(buffer); const w = global.env.width; const c = global.env.contrast; for (let i = 0; i < arr.length; i += 4) { // 加权灰度公式,系数取 ITU-R BT.601 const gray = 0.299 * arr[i] + 0.587 * arr[i + 1] + 0.114 * arr[i + 2]; // 对比度调整围绕 128 中值做线性拉伸 const adjusted = Math.max(0, Math.min(255, (gray - 128) * c + 128)); arr[i] = arr[i + 1] = arr[i + 2] = adjusted; } return buffer; }).then(buf => ({ buf, startRow: task.startRow })); })); }

几个设计点解释一下。切分用subarray而不是slice,因为前者是视图、零拷贝,后者会真的复制一份内存,1200 万像素复制四次就是额外的 180MB 内存开销。传buffer而不是 TypedArray 本身,因为ArrayBuffer是标准的可转移/可克隆类型,兼容性最好,在 worker 里再包一层视图。按行切分而不是按字节切分,是因为后面拼回ImageData时需要保证像素不被从中间劈开,按行切是最省心的边界对齐方式。

实测下来,4 核机器上 4 个分片,总耗时从 1400 毫秒降到 420 毫秒左右,其中传输和克隆占了约 90 毫秒。收益大概 3.3 倍,没到 4 倍,损耗就是前面说的那些账。

2.4 spawn 的四个高频坑与规避写法

第一个坑是返回值的可克隆性。函数里返回一个class实例,序列化回来之后原型链就丢了,变成个普通对象,调用实例方法直接报错。稳妥做法是只返回纯数据结构,需要类型语义就在主线程重新构造。

第二个坑是异步函数。spawn里的函数如果写成async,返回的是 Promise,而 Promise 不是可结构化克隆的,结果会是一个空对象或者直接失败。要在 worker 里做异步操作(比如 fetch),得在函数内部处理好再用同步方式交出结果,或者干脆换个思路——worker 里发请求这件事本身收益就不大。

第三个坑是错误栈丢失。worker 里抛的异常传回主线程时,栈信息经常被裁得只剩一行,排查起来很痛苦。我的做法是在函数体内自己包一层 try/catch,把error.message和关键上下文拼成字符串返回,主线程判断返回结构里有没有__error字段,这样至少知道是哪条数据出的问题。

第四个坑是**env被多个分片共享但被修改**。env在每个 worker 里是独立的一份克隆,你在函数里改了global.env.something,不会影响其他 worker,也不会影响主线程。听起来是好事,但如果你的算法依赖一个跨分片的累加器,就会得到互相不知道对方存在的几份结果。这种情况必须走reduce,不能用spawn硬凑。

注意:spawn的函数体一旦写了this,在 worker 中的指向和你预期可能不同。别依赖this,所有需要的东西走参数或者env。

3. map 的最佳实践:分片粒度决定成败

3.1 map 内部怎么切数据

map是三个 API 里用得最多的。它的语义很直白:给一个数组,给一个处理函数,返回一个等长的结果数组。内部逻辑是先把数据按maxWorkers切成分片,每个分片交给一个 worker,worker 内部对分片内的元素逐个执行你的函数,然后把分片结果数组传回来,主线程按分片顺序拼接成最终数组。

由此可以推出一个很重要的结论:map回调拿到的参数是单个元素,但你无法控制元素落在哪个分片、哪个 worker 上。所以处理函数必须是纯函数,不能依赖执行顺序,也不能在函数里维护跨元素的状态。

const p = new Parallel(tweets, { maxWorkers: 4 }); p.map(function (tweet) { // tweet 是单个元素,但它在哪个 worker 上跑是不确定的 return { id: tweet.id, score: sentimentScore(tweet.text) // 这个函数必须在 worker 里可用 }; }).then(results => { // results 长度和 tweets 一致,顺序也一致 console.log(results); });

注意上面的sentimentScore,如果它是外部定义的函数,同样会因为闭包丢失而报错。解决办法只有两个:把函数体内联进去,或者通过require加载。内联会让代码变长,但这是最不容易出问题的做法,我一般选它。

3.2 maxWorkers 到底设多少合适

Parallel.js默认的maxWorkers取自navigator.hardwareConcurrency,取不到就退化成 4。这个默认值不算合理,因为它把机器的全部逻辑核都占了。

我的经验公式是:

maxWorkers = Math.max(1, Math.min(hardwareConcurrency - 1, 8))

减一是给主线程留余地,不然计算跑满的时候页面渲染会明显卡顿,尤其是移动端。上限卡在 8 是因为超过这个数量之后,调度开销和内存占用增长得比收益快,而且浏览器对单个页面的 worker 数量本身也有隐性限制。

具体到不同类型设备,我做过一轮粗略的对比测试,同一份计算任务(20 万条数据的哈希计算):

设备类型逻辑核数maxWorkers 取值总耗时主线程卡顿情况
桌面高性能本164380ms轻微
桌面高性能本168340ms明显
桌面高性能本1615390ms严重
轻薄本84620ms轻微
轻薄本87700ms明显
中端手机821450ms轻微
中端手机861900ms严重

这张表最值得看的是“总耗时”和“卡顿”两列的背离。核数拉满确实能把纯计算时间压到最低,但用户体验反而更差。所以选maxWorkers的时候别只盯着总耗时,要问自己:这个任务是在用户等待的交互路径上,还是在后台跑?前者宁可慢一点也别卡,后者可以激进一点。

3.3 分片粒度实测:一个反直觉的结论

map的分片数是跟着 worker 数走的,意味着单个分片的体积 = 总数据量 / maxWorkers。这带来一个容易被忽略的问题:当数据量很小时,每个分片太薄,调度开销会吃掉全部收益。

举个具体例子。处理 200 条数据,每次处理耗时 0.05 毫秒,总计算量 10 毫秒。切 4 个分片,每个 worker 启动 15 毫秒,传输 2 毫秒,总耗时大概 60 毫秒以上。同步跑 10 毫秒完事。差了六倍。

我总结了一个粗略的判断门槛:单分片的纯计算时长低于 5 毫秒,就别并行了。换算一下,如果你每个元素的处理耗时是t毫秒,数据量是n,那么需要满足:

(n / maxWorkers) × t > 5

比如每个元素处理耗时 0.1 毫秒,4 个 worker,那n至少要到 200 才有意义。低于这个量级,老老实实同步跑,或者在主线程用requestIdleCallback分片执行,反而更划算。

另一个反直觉的点是分片数量不一定非要等于 worker 数。理想情况下分片数应该是 worker 数的 2 到 4 倍,这样先跑完的 worker 可以立刻接下一个分片,减少尾部等待。但Parallel.js的分片策略是跟 worker 数绑定的,没法直接调。绕过去的方式是自己在外层再切一层,用多个Parallel实例配合Promise.all,代价是代码复杂度上升。我的建议是:数据量在几十万级别以下就别折腾了,收益不明显;真到了百万级,考虑换更底层的方案。

3.4 实操:批量文本特征提取的 map 管线

来个完整点的例子。某工具需要在前端对用户粘贴的一批文本(假设 5 万条短句)做特征提取,算长度、词数、标点比例、重复词比例这几项,用于后续的相似度粗筛。纯 JS 同步跑实测 2.1 秒,用户点一下要等两秒多。

先估数据量:5 万条短句,平均 40 个字符,总共 200 万字符,UTF-16 下约 4MB,加上对象结构开销,序列化大概 8MB。这个量级传输成本大概 30 到 60 毫秒,可以接受。

const MAX_WORKERS = Math.max(1, Math.min((navigator.hardwareConcurrency || 4) - 1, 6)); function extractFeatures(texts) { const p = new Parallel(texts, { maxWorkers: MAX_WORKERS }); return p.map(function (text) { // 函数体必须完全自包含,不能引用外部任何变量 const len = text.length; if (len === 0) return { len: 0, words: 0, punctRatio: 0, repeatRatio: 0 }; let punctCount = 0; let wordCount = 1; const freq = Object.create(null); let curWord = ''; for (let i = 0; i < len; i++) { const ch = text.charCodeAt(i); // 简化判断:ASCII 空格与常见标点区间 const isSpace = ch === 32 || ch === 9 || ch === 10 || ch === 13; const isPunct = (ch >= 33 && ch <= 47) || (ch >= 58 && ch <= 64) || (ch >= 91 && ch <= 96) || (ch >= 123 && ch <= 126) || (ch >= 0x3000 && ch <= 0x303f) || (ch >= 0xff00 && ch <= 0xff65); if (isPunct) { punctCount++; if (curWord) { freq[curWord] = (freq[curWord] || 0) + 1; curWord = ''; } } else if (isSpace) { if (curWord) { freq[curWord] = (freq[curWord] || 0) + 1; curWord = ''; } wordCount++; } else { curWord += text[i]; } } if (curWord) { freq[curWord] = (freq[curWord] || 0) + 1; } let distinct = 0, total = 0; for (const k in freq) { distinct++; total += freq[k]; } const repeatRatio = total > 0 ? 1 - distinct / total : 0; return { len, words: wordCount, punctRatio: len > 0 ? punctCount / len : 0, repeatRatio }; }); } extractFeatures(bigTextArray).then(features => { // features 与 bigTextArray 一一对应,顺序保持一致 console.log('样本数', features.length); });

这段代码有几个刻意的处理。字符判断全部用charCodeAt数值比较,没有用正则,因为对 200 万字符做多次正则匹配比数值比较慢一个数量级,而且正则在函数被toString之后虽然能重建,但复杂正则的字面量在某些环境下的序列化行为不够稳定。freq用Object.create(null)而不是{},避免原型链上的键名污染统计结果——这一点在文本处理里非常关键,constructor、toString这种词会让你得到莫名其妙的计数。

实测结果:5 万条数据,6 个 worker,总耗时 780 毫秒,其中 worker 启动和传输约 120 毫秒,纯计算 660 毫秒。相比同步的 2100 毫秒,提升约 2.7 倍。没到 6 倍是因为主线程拼接和序列化占了一部分,而且原任务的性能瓶颈里有一部分是内存分配,这部分并行化帮不上忙。

3.5 返回值体积与结构化克隆的账要算清

map有个隐蔽的陷阱:返回值的体积可能远大于输入。上面那个例子里输入是字符串,输出是四个数字的对象,体积翻了好几倍。如果每条输出再多带几个数组字段,序列化回来的时间会显著拖慢整体。

我的处理原则是:worker 只负责计算,不做无谓的数据搬运。能在 worker 里聚合的就先聚合,别把中间结果全扔回来。比如要给每批数据算一个统计摘要,那就让reduce在返回前把它压成一个对象,而不是先map出一堆明细再在主线程遍历。

还有一种情况是结果太大,超出预期。我遇到过有人map返回整张图片的像素数组,几百万个元素的普通数组,序列化花了 800 毫秒,计算本身才 200 毫秒。改成返回ArrayBuffer之后降到 60 毫秒。TypedArray 和 ArrayBuffer 的克隆效率远高于同体积的普通数组,这是 V8 优化过的路径,能用就用。

4. reduce 的最佳实践:并行归约的边界在哪

4.1 reduce 的合并顺序与结合律约束

reduce是三个 API 里最容易被误用的。大家习惯了数组的reduce,觉得(acc, cur) => acc + cur一路加下去就行,但在并行场景下,累积函数会被调用在不同的层级上,调用次数和调用顺序跟你想象的完全不一样。

先明确一件事:Parallel.js的reduce是作用在map结果之上的,主线程按分片顺序把各个分片的结果折叠起来。也就是说,如果你的累积函数有副作用,或者依赖严格的调用顺序,结果就不可控。

由此推出的核心约束是:累积函数必须满足结合律,最好也满足交换律。

  • 满足结合律:(a + b) + c === a + (b + c)。加法和乘法满足,减法和除法不满足。
  • 满足交换律:a + b === b + a。字符串拼接不满足,因为顺序会变。

字符串拼接是最典型的反例。你以为会按原顺序拼出一个长串,实际拿到的可能是几个分片结果乱序拼起来的。要保序,就得在结果里带上分片索引,最后自己排一次序。

// 错误示范:字符串拼接在并行归约下顺序不可靠 p.map(fn).reduce((acc, cur) => acc + cur, '');
// 正确做法:保留索引,最后在主线程排序拼接 p.map(function (item, index) { return { index, text: process(item) }; }).then(chunks => { return chunks .sort((a, b) => a.index - b.index) .map(x => x.text) .join(''); });

注意map的回调在某些版本里能拿到索引,但这个索引是分片内的局部索引,不是全局索引,拿它排序会出错。稳妥的做法是在数据预处理阶段就把全局 ID 塞进每条记录里,worker 原样带回来。这是我踩过一次才记住的——当时排查了半天为什么排序结果每隔一段就错位,最后发现是分片内索引从零重新开始。

4.2 memo 初始值:不传会出什么事

reduce的第二个参数是初始值memo。这个东西必须传,而且必须传对类型。

不传的话,实现通常会拿第一个元素当种子,这在你处理的是同构数据时勉强能用,但结果会少算一次,而且如果第一个元素恰好是空数组或者特殊值,整个链条就崩了。更麻烦的是,当map结果为空的时候,没有memo会直接抛错。

// 稳妥写法:显式给出与累积结构一致的初始值 p.map(fn).reduce(function (memo, item) { memo.sum += item.value; memo.count += 1; memo.max = Math.max(memo.max, item.value); return memo; }, { sum: 0, count: 0, max: -Infinity });

这里有个细节值得说:累积函数里我是直接改memo再返回,而不是每次返回一个新对象。因为在 worker 之间传递的是序列化后的副本,改原对象不会影响其他分片,这样做既省内存又快。但如果你的reduce是跑在主线程的单线程归约上,改原对象也没问题,因为执行是串行的。两种情况下这个写法都成立,所以可以放心用。

不过要小心-Infinity这种值能不能被正确序列化。数字类型的Infinity在结构化克隆里是支持的,但如果你把它塞进 JSON 走别的通道,就会变成null。这一点在做极值统计时特别容易出事。

4.3 浮点求和的误差与稳妥写法

这是个真实存在的问题,不是理论洁癖。浮点数加法不满足结合律,(0.1 + 0.2) + 0.3和0.1 + (0.2 + 0.3)的结果在小数点后十几位是不一样的。并行归约因为分组方式变了,累加顺序跟串行不同,最终结果的末几位必然有差异。

差异有多大?我做过测试,对 100 万个 0 到 1 之间的随机数求和,串行和并行的结果在小数点后第 12 位开始分叉。对于大多数业务场景(金额单位是分、统计到两位小数)这完全无所谓。但如果你的场景是科学计算、金融对账、或者需要结果可复现的测试断言,就必须处理。

处理方式有三种,按成本从低到高:

第一种是整数化。所有数先乘以 10 的 N 次方变成整数再累加,最后除回去。适合金额、计数这类本身就有最小单位的场景。

// 以分为单位累加,避免浮点误差 const cents = Math.round(amount * 100);

第二种是Kahan 求和,也就是补偿求和。维护一个补偿项记录丢失的低位,每次加法都把它补回来。精度能提升一个数量级,代价是每个元素多几次运算。

function kahanSum(values) { let sum = 0; let c = 0; for (let i = 0; i < values.length; i++) { const y = values[i] - c; const t = sum + y; c = (t - sum) - y; sum = t; } return sum; }

第三种是先排序再累加,从绝对值小的开始加,误差累积最小。代价是要排序,时间成本高。

我的实际选择是:金额一律整数化,这是最不容易出错的;统计类指标用普通加法,误差在可接受范围内;只有做数值实验或者需要写测试断言的时候才上 Kahan。

4.4 实操:map + reduce 统计聚合

把前面的文本特征提取接着往下做,现在要在这 5 万条特征的基础上算全局统计:平均长度、平均词数、标点比例的分布、重复率的最高值。

function aggregateStats(texts) { const p = new Parallel(texts, { maxWorkers: MAX_WORKERS }); return p .map(function (text) { // 与前面相同的特征提取,返回精简结构 const len = text.length; let punct = 0; for (let i = 0; i < len; i++) { const ch = text.charCodeAt(i); if ((ch >= 33 && ch <= 47) || (ch >= 58 && ch <= 64) || (ch >= 91 && ch <= 96) || (ch >= 123 && ch <= 126)) punct++; } return { len, punct, words: text.split(/\s+/).length }; }) .reduce(function (memo, item) { memo.count += 1; memo.totalLen += item.len; memo.totalPunct += item.punct; memo.totalWords += item.words; memo.maxLen = Math.max(memo.maxLen, item.len); // 分桶统计,避免存全量数据 const bucket = Math.min(10, Math.floor(item.len / 50)); memo.hist[bucket] = (memo.hist[bucket] || 0) + 1; return memo; }, { count: 0, totalLen: 0, totalPunct: 0, totalWords: 0, maxLen: 0, hist: {} }); } aggregateStats(bigTextArray).then(stats => { console.log('平均长度', stats.totalLen / stats.count); console.log('平均标点密度', stats.totalPunct / stats.totalLen); console.log('最长文本', stats.maxLen); console.log('长度分布', stats.hist); });

这个写法的关键是累积结构里不存明细,只存聚合量。直方图用对象分桶而不是数组存全部长度值,内存占用从 O(n) 降到 O(分桶数)。这一点在数据量大时是决定性的——如果你在memo里push五万个数字,最后序列化回来的成本比计算本身还高。

实测这套管线在 6 个 worker 下总耗时 890 毫秒,其中归约阶段只占 40 毫秒左右,说明reduce本身不是瓶颈,真正花时间的是map里的逐字符遍历和序列化。

5. 组合实战:一条完整的数据处理管线

5.1 任务拆解与数据流设计

把前面几节的东西串起来,做一个完整的例子:前端接收用户上传的 CSV(假设 30 万行,每行是交易记录:日期、金额、类别、备注),需要算出每个类别的总额、笔数、平均金额,以及全量的异常值检测(金额偏离均值三倍标准差的记录)。

这里的数据流设计有几个决策点:

第一,CSV 解析放哪儿?放 worker 里。30 万行文本解析成对象数组,纯字符串操作大概 1.5 秒,不挪出去主线程必卡。但解析结果要不要传回主线程?不要,直接作为下一步计算的输入留在 worker 侧最好。可惜Parallel.js的链式调用在这个版本上中间数据还是会回到主线程,所以我的做法是让spawn一口气干完解析和初步聚合,只返回聚合结果。

第二,两步计算怎么分配?第一步按类别聚合,这是典型的分组归约,适合map出中间结构再reduce合并。第二步异常值检测依赖全局均值,必须等第一步完成才能做,属于串行依赖,没法并行。所以整体是「并行聚合 + 主线程算阈值 + 并行筛选」三段式。

第三,数据怎么切?CSV 是按行的,按行数平均切,注意别把一行从中间切断。

5.2 代码实现

const MAX_WORKERS = Math.max(1, Math.min((navigator.hardwareConcurrency || 4) - 1, 6)); // 第一步:并行解析并做局部聚合 function parseAndAggregate(csvText, workerCount) { const lines = csvText.split('\n'); const header = lines[0]; const bodyLines = lines.slice(1).filter(l => l.trim().length > 0); // 按行数平均切,每片内部保留完整行 const perChunk = Math.ceil(bodyLines.length / workerCount); const chunks = []; for (let i = 0; i < bodyLines.length; i += perChunk) { chunks.push(bodyLines.slice(i, i + perChunk).join('\n')); } const tasks = chunks.map(chunk => { const p = new Parallel(chunk, { env: { header } }); return p.spawn(function (text) { const header = global.env.header; const cols = header.split(',').map(s => s.trim()); const iDate = cols.indexOf('date'); const iAmount = cols.indexOf('amount'); const iCategory = cols.indexOf('category'); const rows = text.split('\n'); const byCategory = Object.create(null); let total = 0; let totalAmount = 0; for (let r = 0; r < rows.length; r++) { const line = rows[r]; if (!line) continue; const cells = line.split(','); const amount = parseFloat(cells[iAmount]); if (!isFinite(amount)) continue; const cat = cells[iCategory] || 'unknown'; if (!byCategory[cat]) { byCategory[cat] = { sum: 0, count: 0 }; } byCategory[cat].sum += amount; byCategory[cat].count += 1; totalAmount += amount; total += 1; } return { byCategory, total, totalAmount }; }); }); return Promise.all(tasks).then(parts => { // 在主线程合并各分片的局部聚合 const merged = Object.create(null); let total = 0; let totalAmount = 0; for (const part of parts) { total += part.total; totalAmount += part.totalAmount; for (const cat in part.byCategory) { if (!merged[cat]) merged[cat] = { sum: 0, count: 0 }; merged[cat].sum += part.byCategory[cat].sum; merged[cat].count += part.byCategory[cat].count; } } return { byCategory: merged, total, totalAmount, mean: totalAmount / total }; }); }

这段代码里有个细节值得单独说:分片数据用字符串传而不是数组传。把bodyLines.slice(...)的结果直接传数组,序列化时要逐个元素克隆;拼成一个字符串再传,只克隆一个字符串。实测 30 万行数据,传字符串比传数组快大概 40%。代价是 worker 里要再split一次,但这个成本比克隆小得多。这是典型的“数据形态影响传输效率”的例子,很多人想不到。

另外Object.create(null)这个写法在 worker 里同样重要。用户填的类别名如果是constructor或者hasOwnProperty,用普通对象就会读到原型上的属性,得到函数而不是你的统计对象,后面sum += undefined直接变NaN。

5.3 实测调优记录

第一版跑完是 4.2 秒,比同步的 6.8 秒只快了 38%,明显不达标。排查过程记录一下,比较有参考价值。

第一轮怀疑是 worker 数不够,从 4 调到 6,降到 3.6 秒,有改善但不够。

第二轮怀疑是数据切分太粗,6 个分片每个 5 万行,尾部等待严重。改成外层切 12 片、内部用Parallel的map再细分,结果反而更慢,因为实例创建开销翻倍了。这条路走不通,回退。

第三轮做了 profile,发现问题在csvText.split('\n')这一步——主线程上对整个 30 万行文本做一次split就花了 600 毫秒。改成先按大致字符位置找换行符边界,只切出分片的起止偏移,把整个split挪到 worker 里做,省下了这 600 毫秒。

第四轮发现Promise.all里所有 worker 同时启动,内存峰值到了 400MB 以上,触发了垃圾回收的频繁停顿。改成限制并发为 3 个一组,总耗时反而从 2.9 秒降到 2.6 秒。这个现象挺典型:并行不等于全部同时开,资源竞争的代价经常被低估。

最终版本 2.6 秒,相比同步 6.8 秒提升 2.6 倍。看起来不高,但要注意这个任务里有大量时间花在字符串解析上,而字符串操作在 V8 里是高度优化的,并行化的相对收益本来就不如纯数值计算。

6. 排查速查表与避坑经验

6.1 报错与异常对照表

现象最可能的原因处理方式
函数内变量undefined或直接 ReferenceError引用了闭包中的外部变量全部走env传参或内联函数体
then拿到空对象{}返回值不可结构化克隆,比如class实例、函数、Promise只返回纯数据,需要类型就在主线程重建
reduce结果每次运行不一致累积函数不满足结合律,比如字符串拼接、浮点累加改整数化累加,或带索引回主线程排序
reduce抛Reduce of empty array没传memo,且map结果为空显式传初始值
require加载后模块变量不存在模块是 CJS/ESM,没有挂全局换 UMD 格式,或把逻辑内联
结果数组顺序错乱用分片内索引做了全局排序在数据预处理时打入全局 ID
页面比不用 worker 还卡maxWorkers拉满,抢占了渲染资源减一到两个 worker,给主线程留核
内存持续上涨不释放大数组反复克隆,加上闭包持有引用用ArrayBuffer传,处理完手动置 null

6.2 性能不升反降的排查路径

遇到并行比串行慢,我会按这个顺序挨个排除,基本能在十分钟内定位:

先量一下纯计算时间。在函数里打时间戳,把单条数据的处理耗时乘以数据量,得到理论总计算量。如果这个数小于 50 毫秒,直接放弃并行,没救。

再量序列化时间。把数据传出去再传回来,不做任何计算,看耗时多少。这一步经常被我忽略,但它是隐藏的大头。超过总耗时 30% 就得优化数据格式。

然后看worker 启动次数。如果你在处理流程里反复new Parallel(),每次都要重新起线程,那就该把多次小任务合并成一次大任务。这个坑我在一个轮询场景里踩过,每秒起一次 worker,CPU 有 40% 花在启动上。

最后看并发峰值。用任务管理器或者性能面板观察,如果所有核瞬间跑满然后长时间空闲,说明分片不均,需要人工调整切分策略,把重的元素和轻的元素混在一起。

6.3 几条踩出来的经验

第一条,别在 worker 里做 DOM 相关的任何事。这个错误新手会犯,因为函数体看起来是在主线程写的。worker 里没有document,也没有window,只有self和全局作用域。Parallel.js内部会给 worker 造一个假的global对象应付,但别指望它完整。

第二条,开发环境跑通不代表生产能跑。打包工具对Function.prototype.toString的处理、对 worker 文件的路径重写、对importScripts里 URL 的解析,在 dev 和 prod 下行为经常不一致。我的做法是构建之后一定要在真实浏览器里跑一遍完整流程,别只看 dev server 的表现。

第三条,给每个任务加超时兜底。worker 里的死循环会让整个页面僵住,而且没有任何报错。我一般在主线程用Promise.race加一个超时,超时了就提示用户并允许重新操作。虽然没法真的杀掉那个 worker(Parallel.js没暴露这个能力),但至少 UI 不会彻底无响应。

第四条,记住这是个轻量工具,别把它当分布式计算框架用。我曾经想用它做一个上万任务的流水线调度,结果被内存和调度开销教育了。它的舒适区就是「一个不太大的数据集合 + 一个纯函数 + 一次并行执行」。超出这个范围,要么自己在原生 Worker 上写调度,要么考虑别的方案。

第五条,测试用例一定要覆盖空数据、单元素、含null元素的三种边界。map遇到null时如果函数里没做保护会直接抛,reduce在空数组上的行为跟有没有memo强相关,单元素时很多分片逻辑会退化到不切分的路径。这三种情况在真实数据里出现的概率远比你想象的高。

最后分享一个小技巧:调试 worker 里的代码时,我会在函数体开头加一行return { __debug: typeof self, __env: typeof global.env };,先确认执行环境长什么样、env有没有挂上,确认没问题再换成真正逻辑。比对着报错瞎猜快得多。这个方法看着粗糙,但每次能省我十几分钟。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进