最近正在做一个基于浏览器 File Reader API 的文本解析工具。为了让繁重的文本解析工作不影响页面线程的性能,使用 Web Worker 来负责处理文本解析应该是最优的。Web Worker 需要将解析后的每行文本传回给页面,当需要传输的文本行数达到数十万、百万行时,性能问题就变得尤为重要,一方面是传输的耗时,一方面是内存的消耗。多数浏览器实现了结构克隆,允许你对 Web Worker 传入、传出更复杂的数据类型,如:File, Blob, ArrayBuffer, JSON 对象等。然而当你使用postMessage()
方法传输这些数据时,数据会被拷贝一份再进行传输,所以当你传输 100MB 的数据时,主进程和 Worker 进程都会增加 100MB 的内存使用,并且复制 100MB 的数据需要的时间可能达到几百毫秒。为了解决这个问题,postMessage()
方法也支持传输 Transferable 数据类型,使用 Transferable 传输时,会直接把数据从一个执行环境(Worker 线程或主线程)传输到另一个执行环境,这样不会额外增加一份内存消耗,并且传输速度极快因为不需要数据拷贝。可是在实际使用中,如果需要传输大量的 Transferable 数据时,这种方法仍存在显著的性能问题。
拷贝传输
首先我们来看最简单的使用 Web Worker 直接传输 500MB 的数据。
var data = new Uint8Array(500 * 1024 * 1024);
self.postMessage(data);
浏览器的测试结果:
浏览器 | 传输耗时 | 最终内存 |
---|---|---|
Chrome | 149ms | 1042MB |
Edge | 455ms | 1048MB |
Firefox | 380ms | 1079MB |
传输后浏览器进程内存增长到了 1GB,因为data
对象被复制了一份传输到主进程,传输后我们在 worker 进程和主进程都可以访问到这 500MB 的数据。
Transferable 对象传输
接下来我们来使用 Transferable 对象传输 500MB 数据。
var data = new Uint8Array(500 * 1024 * 1024);
self.postMessage(data, [data.buffer]);
浏览器的测试结果:
浏览器 | 传输耗时 | 最终内存 |
---|---|---|
Chrome | 1ms | 531MB |
Edge | 0ms | 537MB |
Firefox | 0ms | 549MB |
使用 Transferable 传输后,内存始终维持在 500MB 左右,这是因为data
数据从 worker 进程传到了主进程,这时候你在 worker 上下文中执行data.length
会得到 0。因为不需要复制,所以postMessage()
执行非常快。
大量 Transferable 对象的传输问题
根据上述测试结果,当需要传输单一一个大数据变量时,如果在原有的上下文环境中不再需要访问该对象时,那么使用 Transferable 对象传输是一个明智的选择。
然而我最近在做的一个文本解析的库,需要在 Web Worker 中解析出文本文件中的每一行,然后把若干行数据返回到主进程。当返回的行数特别多时,我发现 Transferable 传输的性能会显著下降。
看下面的测试代码,transferByLine
方法接受 2 个参数,第一个是测试的行数,第二个是是否使用 Transferable 传输。
function transferByLine(line, transferable) {
var arr = [];
var bufferArr = [];
for (var i = 0; i < line; i++) {
arr[i] = new Uint8Array(100);
if (transferable) {
bufferArr.push(arr[i].buffer);
}
}
console.log('Successfully created the array. The array has ' + line + ' items, each item size is 100 bytes');
console.log('Start transferring...');
var startTime = new Date().getTime();
if (!transferable) {
self.postMessage(arr);
} else {
self.postMessage(arr, bufferArr);
}
var timeTaken = new Date().getTime() - startTime;
console.log('Tranfer completed in ' + timeTaken + 'ms.');
}
下面是我在三个浏览器中的测试结果。
2000 行 | 4000 行 | 6000 行 | 8000 行 | 10000 行 | 20000 行 | 40000 行 | 60000 行 | 80000 行 | 100000 行 | 200000 行 | |
---|---|---|---|---|---|---|---|---|---|---|---|
Chrome (拷贝) | 1 | 2 | 2 | 3 | 5 | 9 | 36 | 40 | 53 | 65 | 170 |
Chrome (Transferrabl 输) | 3 | 12 | 21 | 35 | 44 | 109 | 383 | 789 | 1408 | 2015 | 7609 |
Edge (拷贝) | 1 | 3 | 3 | 3 | 4 | 8 | 15 | 22 | 27 | 46 | 108 |
Edge (Transferable) | 6 | 19 | 37 | 63 | 88 | 329 | 1266 | 2814 | 4940 | 7759 | 30341 |
Firefox (拷贝) | 2 | 5 | 8 | 10 | 14 | 28 | 58 | 96 | 149 | 174 | 362 |
Firefox (Transferable) | 2 | 5 | 7 | 10 | 14 | 34 | 64 | 122 | 174 | 222 | 488 |
将测试结果做成图表可以更直观地观察性能随着行数的变化情况:
从上述测试结果可以看出:
- Chrome 和 Edge 的 Transferable 传输性能会随着数组元素的增加呈指数级别下降,基本只要数组长度上千,性能就不如拷贝传输了。
- Firefox 的 Transferable 传输性能变化与拷贝传输基本的变化相当,都是呈线性增长。
当 Tranferrable 对象过多时,Chrome 和 Edge 应该是慢在花很多时间来解析 Transferable 数组成员,来和postMessage()
方法中的第一个参数的数据成员做一一关系映射。很显然,Firefox 应该是对这块逻辑做过相应的优化。
总结
当 Web Worker 需要传输大量数据时,如果数据集中于少数变量中,那么可以放心地使用 Transferable 来传输。如果数据分散于成百上千个元素中,这时候使用 Transferable 传输在 Chrome 和 Edge 中会有比较明显的性能问题,用拷贝传输反而更快。如果使用了拷贝传输,我们需要在 Web Worker 中断开其它 JS 变量对数据源的引用,这样才可以使浏览器在下一次垃圾回收时释放 Web Worker 中数据源所占用的内存。