优化实战 第 04 期 - 专用Worker优化文件的MD5计算

工作线程(Web Worker)允许开发人员编写能够脱离主线程、长时间运行而不被用户所中断的后台程序,去执行 计算密集型高延迟 的事务或者逻辑,并同时保证页面对用户的及时响应

web_worker.jpg

Web Worker 分为 专用线程 Dedicated Worker 和 共享线程 Shared Worker

浏览器作为宿主环境提供了多线程运行 JS 的能力,可通过并行的方式高效地提升执行效率

contrast.jpeg

专用 worker 仅能被首次创建它的页面使用,本期我们将基于专用 Worker 来设计符合业务场景的优化方案

dedicated_worker.jpg

DedicatedWorker 简称 Worker,其专用线程只能与一个页面渲染进程 Render Process 进行绑定和通信,不能多页面进行共享

render_process.png

主线程环境

  • 创建一个专用worker

    const worker = new Worker('hash.worker.js', { name: 'hash_worker' })
    复制代码
  • 消息的发送和接收

    worker.postMessage()
    worker.addEventListener('message', ({ data }) => {
      // event.data <=> data
    })
    复制代码
  • 终止主线程

    worker.terminate()
    复制代码

    注:立即终止,并不会等待工作线程去完成它剩余的操作

工作线程环境

  • 消息的接收和发送
    self.addEventListener('message', ({ data }) => {
      // event.data <=> data
    })
    self.postMessage()
    复制代码
  • 关闭当前工作线程
    self.close()
    复制代码
  • 加载外部脚本或第三方库
    self.importScripts(urlA, urlB)
    复制代码
    注:所引入的脚本与库都会绑定在子线程的全局对象上,即 selfthis

注意事项

  • 同源策略的限制

    Worker 线程中运行的脚本文件必须与主线程的脚本文件同源

  • 操作 DOM 的限制

    Worker 线程中无法读取主线程所在视图的 DOM 对象,也无法使用 windowdocument 对象,但可以使用他们的子对象

  • 本地文件的限制

    Worker 线程中无法读取本地文件,因此它所加载的脚本必须来自于网络

  • 消息通信

    Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息的发送和接收来通信

场景示例

  • 业务场景

    由于业务需要,需要前端来计算选取文件的 MD5 值,然后传递给后端做业务逻辑的处理。受文件大小以及文件数量的影响,主线程会被阻塞,导致页面卡顿,甚至浏览器卡死

    page_jank.png

    由于 JS 单线程的特性,导致大量计算不仅会阻塞 UI 的渲染,还无法充分发挥多核 CPU 的计算能力

  • 生活场景

    单线程

    超市排队结账的时候,当只有一个收银口的时候,所有人都需要排队依次结账。如果某个人买了很多的东西,收银员就得一件一件的进行价格扫描,相对来说要花费的时间就会长一点儿,那么其他的人都得在后边依次等着这个人结完,再依次进行结账,这样就会增加其他人的等候时间

    多线程

    如果现在收银口有多个或添加自助结账的通道,我们就可以到其他人少的地方去结账,这样可以更快的结账,避免等候时间太长

    self_checkout.jpg

专用线程计算文件哈希值

  • 工作线程(worker/hash.worker.js)

    引用第三方脚本库并创建实例

    importScripts(`${location.origin}/worker/lib/browser-md5-file.min.js`)
    const bmf = new self.browserMD5File()
    复制代码

    browser-md5-file

    计算文件的哈希值

    const computeHash = ({ file, data }) => {
      return new Promise((resolve, reject) => {
        bmf.md5(file, (err, md5) => {
            data.metamd5 = md5
            err ? reject(err) : resolve({ file, data })
          }, progress => {
            console.log('md5 progress number:', progress)
          }
        )
      })
    }
    复制代码

    接收文件相关信息进行处理并将结果返回给主线程

    self.addEventListener('message', async ({ data }) => {
      try {
        const response = await Promise.all(data.map(item => computeHash(item)))
        self.postMessage(response)
      } catch(error) {
        console.error(error.message)
      }
    })
    复制代码
  • 主线程封装(worker/md5.index.js)

    const getHashByWorker = data => {
      return new Promise((resolve, reject) => {
        const worker = new Worker(`${location.origin}/worker/hash.worker.js`, {
          name: 'hash_worker' 
        })
        worker.postMessage(data)
        worker.addEventListener('message', ({ data }) => {
          worker.terminate()
          resolve(data)
        })
        worker.addEventListener('error', err => reject(err))
      })
    }
    export default getHashByWorker
    复制代码
  • 主线程处理

    import getHashByWorker from './worker'
    
    /*
     * {getHashByWorker} Function 获取文件的 MD5 值
     * {upload} Function 上传文件 
    */
    const [hashData, fileData] = await Promise.all([getHashByWorker, upload])
    复制代码

    上传文件至文件服务器和计算文件的哈希值并行执行,全部执行完成后,将结果数据传输入库

性能分析

  • 基于专用 Worker 的设计缺陷

    通过 Promise.all() 并发获取文件的 MD5 值。当上传的文件达到一定的量级的时候,此时内存的消耗会特别的大,不仅会出现内存溢出,还有可能会导致浏览器崩溃

  • 浏览器崩溃

    喔唷,崩溃啦!

    错误代码:Out of Memory

进一步的优化方案

  • 全文件计算改为抽样计算
    const computeHash = ({ file, data }) => {
      return new Promise((resolve, reject) => {
        bmf.md5(file.slice(0, 2 * 1024 * 1024),  // 文件抽样
          (err, md5) => {
            data.metamd5 = md5
            err ? reject(err) : resolve({ file, data })
          }, 
          progress => {
            console.log('md5 progress number:', progress)
          }
        )
      })
    }
    复制代码
  • 优化工作线程,将并发改为队列
    self.addEventListener('message', async ({ data }) => {
      try {
        const response = await asyncThrottling({ list: data, handler: computeHash })
        self.postMessage(response)
      } catch(error) {
        console.error(error.message)
      }
    })
    复制代码
    使用 第 03 期 - 对异步任务并发量进行限流 的方案实现 并发式依次队列,问题完美解决

浏览器兼容性

  • 在现代浏览器和移动端上的支持性相当的理想

    web_workers.png

    专用 Worker 是最早实现并被最广泛支持的多线程方案

  • 判断浏览器对专用 Worker 的支持性

    if (window.hasOwnProperty('Worker')) {
      console.log('支持,请放心使用!')
    } else {
      console.log('不支持,请优雅降级!')
    }
    复制代码
  • 一起交流学习

    加群交流看沸点