「Worker+WASM」你也许不知道的文件上传优化

5,666 阅读7分钟

自我介绍

大家好,我叫JetTsang,之前都是在掘金潜水来着,现在偶尔做一些内容输出吧。

问题引出

有一天产品小姐姐跑过来说,这个上传附件📎的功能不好用

什么?在实现了断点续传秒传两大杀手级功能之后,还有哪个用户(刁民)敢不满意?

一番拉扯之后,发现是反馈扫描文件的速度太慢了。

奇怪了,扫描是计算md5呢,应该不会很慢啊。(md5是一种哈希算法,可以得到文件的标识,文件的标识简单理解为文件的身份证号码即可)

看了一下生产上的数据,发现这个用户居然上传了1G多的文件???(说好的用户只会传小文件呢?)

1G多的文件计算md5肯定慢啰

之前测试的时候用的最大都只是几十m,所以就没发现有这个问题(别问这么明显为啥没考虑到,问就是时间紧任务急,先上线再优化)

思考

既然计算md5慢,那不计算md5行不行呢?

首先回顾一下文件上传的步骤

  1. 通过用户的选择,拿到文件的file对象
  2. file的slice进行分片,并逐个转成ArrayBuffer,用sparkMd5计算MD5
  3. 将md5请求后端是否需要上传?或者已经传了多少了,只传剩下的,或者能不能传
  4. 转成formData并且加上信息 随后 并发开启上传请求
  5. 告诉后端传完了,可以合并了,后端合并之后返回个结果

计算md5是实现秒传必须要的,不计算的同时也会带来其他的问题:比如服务端接收到的文件在传输途中损坏等等

那就只能从计算md5这方面去入手了,这就不得不请出今天的主角儿:WebAssembly

WebAssembly

它是什么呢?

我们的浏览器现在只能运行JavaScript,但JavaScript的效率其实并不高,可以设计一个虚拟微处理器,它可以将任何高级语言转换成可以在所有主流浏览器上运行的机器码,那浏览器环境下不就以跑它了吗?

说白了,WASM不是一种编程语言,它是一种将其他编程语言编写的代码转换为浏览器可理解的机器代码的技术。或者说它是一种被设计为其他语言的编译目标,允许服务器端代码(如C或C++/rust代码)被编译成WASM并在浏览器中执行。

它的优点有:

  • 性能高
  • 跨平台(wasm只是一种编译目标,它其实是一种二进制文件)
  • 独立沙箱环境

缺点:

  • 多线程还不是很友好 ?
  • 兼容性?

说了这么多,让我们看看大厂的应用场景:视频处理编码解码、3D渲染、多媒体游戏、加密计算和AR/VR实时应用程序等

关于它的发展演变、常用语言/编译工具链、编译过程以及未来趋势等等就不细嗦了,相信难不倒各位看官们

小调研

qq邮箱计算md5的时候 image.png 也用到了wasm image.png

PS:我可没看盗版阿凡达啊,只是下载来测试的(认真脸)

再比如 '一家把我们用户体验放在第一位的公司”啊逼:WebAssembly 软解 HEVC 在 B 站的实践

突然想到还有个原因,是因为计算md5的时候直接是同步拿到文件就直接计算的,这样会导致计算的时间内浏览器无法处理用户的交互,所以会导致用户更加烦躁。这样又让我想到了多线程,可以利用Worker来开启一个或多个辅助线程去做计算,来解决计算期间用户无响应以及优化md5的问题。

好!说干就干

解决方案

于是乎,解决方案初步成型:WebAssembly + Web Worker

那么肯定需要先验证一下这个方案可不可行,再上生产环境啊。

测试过程

测试当然要尽量的科学完备,这里采用控制变量法吧。思路大概是 对比有无wasm、是否分片、是否启用worker辅助线程下 计算同样文件的速度表现,速度用同一文件下计算得到Md5所需的时间来衡量

准备

肯定需要一个能算md5的wasm包,恰巧go官方支持wasm,所以就用go了

打包出来体积还是有点大啊(可能是因为go设计之初的多端能力导致GC依赖甩不掉,难以做到轻量),生产上还是不能用,不过先用来测试倒是无妨。

image.png

go当中的代码其实也是引入的md5的依赖并且暴露方法,具体如何操作这里不展开了(如果有兴趣可以另外开文章聊一聊)

然后罗列一下测试情况

  1. 主线程计算(不分片)
  2. 主线程分片计算
  3. 辅助线程计算
  4. 辅助线程分片计算

测试机器:MacBookPro 14 Apple M1 Pro

测试文件:1m - 100m - 1G 3个数量级

环境:chrome safari

demo界面介绍

image.png 有选取文件的input ,外加右边有一排按钮,点击回调里进行 分片`` 计算md5``创建worker 统计时间 等操作。 用一个setInterval 去1s 渲染页面 ,可以观察是否页面是否无响应卡顿了

结果

js主线程计算(不分片)

image.png

其实就是整个放进去fileReader读取成ArrayBuffer,读完就开始算

image.png 有点夸张啊,效率高4倍,有点东西看来

加入分片计算之后呢?

主线程分片计算

通过按钮点击之后开始计算 image.png

image.png

image.png

image.png 嗯。。。分片之后更慢也是理所当然,因为分片也要时间呢。

辅助线程分片计算

那么在辅助线程当中呢?

image.png

image.png

这里暂时用hashwasm在worker当中跑,后续更新

看来效果拔群啊,有4倍左右的速度提升呢!

最后再测一下1m 100m 这两个量级

image.png

image.png

猜测小文件用worker传输过程当中损耗的时间占比还比较大。但毫无疑问,有了wasm的加持下,速度有4倍的左右的提升。

结论

经过了非严谨测试,用上了wasm之后,速度明显有了4倍多的提升。

可以先放到生产上用用了,改成配置启用兜底即可,后续通过生产上的数据采集来对比前后时间差就知道此次优化的效果了。

另外

如果要在生产上,可以使用它: hash-wasm

它是由c语言写的

image.png

支持主流的很多加密算法

image.png

它的使用方法:

// cdn引入 (也可以npm 安装的方式,根据自己需求)
<script src="https://cdn.jsdelivr.net/npm/hash-wasm"></script>

const chunkSize = 64 * 1024 * 1024; // 分片大小
const fileReader = new FileReader();
let hasher = null;

function hashChunk(chunk) {
  return new Promise((resolve, reject) => {
    fileReader.onload = async(e) => {
      const view = new Uint8Array(e.target.result);
      // 添加片段
      hasher.update(view);
      resolve();
    };

    fileReader.readAsArrayBuffer(chunk);
  });
}

const readFile = async(file) => {
  if (hasher) {
    // 加载之后,每次计算都要重新初始化哦,不然计算结果有问题
    hasher.init();
  } else {
     // 引入
    hasher = await hashwasm.createMD5();
  }

  const chunkNumber = Math.floor(file.size / chunkSize);

  for (let i = 0; i <= chunkNumber; i++) {
    const chunk = file.slice(
      chunkSize * i,
      Math.min(chunkSize * (i + 1), file.size)
    );
    await hashChunk(chunk);
  }
  // 这里拿到hash值
  const hash = hasher.digest();
  return Promise.resolve(hash);
};



实装之后,测试一下速度,还是很快滴,顺便提一下大小是4k(gzipped),在生产上使用还是毫无鸭梨。

image.png

总结

在大文件计算md5的场景里,用wasm来进行加速是值得一试的。但用它也要权衡到体积、兼容性等等因素,才能用到生产环境当中。

挖坑

  1. 详细分析一下worker与主线程和js与wasm之间传输损耗的时间
  2. 不同浏览器的兼容性以及cpu内存情况
  3. 是否能采用worker线程池来更好的优化?
  4. 如果要支持多文件上传,是否升级http2.0会有更好的收益
  5. 如果用requestIdleCallback之类的时间切片思想,也能做到计算md5不影响用户交互
  6. 抽样hash计算md5也是一个比较快的选择

本期所用代码