Web Worker传输大量Transferable对象时的性能问题
2018年1月23日

最近正在做一个基于浏览器 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

将测试结果做成图表可以更直观地观察性能随着行数的变化情况:

从上述测试结果可以看出:

  1. Chrome 和 Edge 的 Transferable 传输性能会随着数组元素的增加呈指数级别下降,基本只要数组长度上千,性能就不如拷贝传输了。
  2. Firefox 的 Transferable 传输性能变化与拷贝传输基本的变化相当,都是呈线性增长。

当 Tranferrable 对象过多时,Chrome 和 Edge 应该是慢在花很多时间来解析 Transferable 数组成员,来和postMessage()方法中的第一个参数的数据成员做一一关系映射。很显然,Firefox 应该是对这块逻辑做过相应的优化。

总结

当 Web Worker 需要传输大量数据时,如果数据集中于少数变量中,那么可以放心地使用 Transferable 来传输。如果数据分散于成百上千个元素中,这时候使用 Transferable 传输在 Chrome 和 Edge 中会有比较明显的性能问题,用拷贝传输反而更快。如果使用了拷贝传输,我们需要在 Web Worker 中断开其它 JS 变量对数据源的引用,这样才可以使浏览器在下一次垃圾回收时释放 Web Worker 中数据源所占用的内存。