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中数据源所占用的内存。