使用Web Worker改善性能

开始以前,代码在这里。欢迎各位大神指导。javascript

在 Web Worker 以前,解析 CSS,生成布局,绘制界面以及运行 javascript 脚本都运行在浏览器的一个线程里。若是一个 Web App 运行的 js 脚本一次运行时间过长,就会出现界面卡顿。这样的用户体验是无法用及格来评价的。html

在多核普及的当下,浏览器也大多支持了 Web Worker。这让前端开发有了更多的选择,使用 web worker 来实现真正的多线程。全部阻碍、延迟用户反馈的操做均可以移入一个后台运行的线程中。前端

如何发现性能瓶颈

笔者主要使用 React 开发,因此首先聊一下 React 中如何发现性能出现问题的地方。在 React16.9 中新增了 Profiler API,使用起来也很是简单,具体能够查看文档((https://reactjs.org/docs/prof...java

更通用一点的可使用 chrome 的lighthouse。能够安装插件 lighthouse 插件,或者也能够直接打开开发面板的audits点击run audits,就能看到报表了。console.timeconsole.timeEnd组合。react

可是,以上只适用于开发模式下使用。在生产上使用多多少少会给产品自己带来额外的资源消耗。通常来讲,app 都会有埋点,在埋点点时候如何顺道达成性能消耗点记录就须要具体问题具体分析了。webpack

Web Worker

使用 Web Worke 让阻塞代码在后台运行,天然不会阻塞 UI 线程(main thread)。在例子中所用到的是typescript版本的代码,全部后面若是有必要会给出在 typescript 实现的代码和相应的说明。配置一类的文件请直接移步到代码目录查看,这里就很少说了。c++

在 Worker 的部分使用了webpack + worker-loader的方式。worker-loader的具体内容能够参考这里git

建立一个 Worker

建立一个 Worker 很是的简单,只须要把一段命名脚本传给Worker构造函数就能够。好比 MDN 的一段:github

这是 Worker 脚本:web

// worker.js
self.onmessage = event => {
  console.log("Message received", event.data);
  self.postMessage("Worker done");
};

在 typescript 里,首先须要处理 Worker 的上下文的问题,不然tsc编译不过。

const ctx: DedicatedWorkerGlobalScope = self as any;

ctx.onmessage = (event: MessageEvent) => {
  //...

  ctx.postMessage("done");

  // Close the worker when jobs done
  ctx.close();
};

ctx.onerror = (event: ErrorEvent): any => {
  console.error("Error in worker", event.message);
  ctx.close();
};

export default null as any;

注意:这里须要使用DedicatedWorkerGlobalScope不能直接食欲哦那个Worker,由于Worker的定义里面没有close方法。这是由于close方法deprecated

TS2339: Property 'close' does not exist on type 'Worker'.

还有在建立 Worker 的最后,须要一个export语句:

export default null as any;

建立 Worker:

// Main thread
var myWorker = new Worker("worker.js");

myWorker.postMessage([first.value, second.value]);

myWorker.onmessage = function(e) {
  result.textContent = e.data;
  console.log("Message received from worker");
};

Typescript:

import SimpleWorker from "./simple.worker";
const worker = new SimpleWorker();

两个线程之间(上例是 UI thread 和一个 worker)能够经过postMessageonmessage或者(addEventListener('message', () => {})的方式来传递消息。

线程之间的通讯是基于事件的。那么错误的处理也是一样道理,例如:

// UI thread
var myWorker = new Worker("worker.js");

myWorker.onerror = function() {
  console.log("There is an error with your worker!");
};
// Inside worker
self.onerror = err => {
  console.error("Error in worker", err);
};

引入外部脚本

importScripts(); /* imports nothing */
importScripts("foo.js"); /* imports just "foo.js" */
importScripts("foo.js", "bar.js"); /* imports two scripts */
importScripts(
  "//example.com/hello.js"
); /* You can import scripts from other origins */

注意:下载顺序能够是任意顺序,可是执行的顺序是按照脚本在importScripts方法里出现的顺序。

由于使用了worker-loader,在引入外部代码的时候,和通常的import差很少:

import { ab2str, str2ab } from "./lib/utils"; // 引入内部依赖
import * as _ from "lodash"; // 引入外部依赖

ctx.onmessage = (event: MessageEvent) => {
  // ...
  const dataStr = ab2str(dataBuff); // 使用内部依赖

  // ...
  const target = JSON.parse(dataStr || '[]');
  const v = _.get(target, 'a.b', 'N/A');  // 使用外部依赖

  // ...

关闭一个 Worker

Worker 也占用和消耗资源,因此在不用的时候就要关闭它。

关闭一个 Worker 有两种方法:一种是直接在 UI thread 里面使用terminate方法,一种是在 Worker 的内部调用close方法。

// In main thread
const worker = new Worker("myworker.js");

// If it's the time to terminate a worker
worker.terminate();

在调用了terminate方法以后,Worker 会被马上终止,即便是还在运行中的也是同样。可是通常状况下仍是但愿在 Worker 执行完成以后才去关闭。这个时候就要用到 Worker 的close方法。

// In a worker
self.onmessage = event => {
  self.close();
};

如上文所说,close方法就要被废弃了,如今是在deprecated的状态。具体看 MDN 的这里

要被废弃是由于,在一个 worker 出了做用域以后就会被回收。因此有没有close这个方法并无太大的必要。

Inline Worker

Worker 的建立须要获得脚本的 URL 地址。通常状况下,这段脚本是放在 server 上的。这就须要网络的传输。若是只是一个简单的须要放到后台执行的脚本,若是能够打包到一块儿直接发布到客户浏览器会节省不少的时间。这个时候就须要 inline Worker。

它的建立也很简单,并无什么特别的地方。只是在得到 URL 的时候使用了Blob这个工具,如:

// URL.createObjectURL
window.URL = window.URL || window.webkitURL;

// "Server response", used in all examples
var response = "self.onmessage=function(e){postMessage('Worker: '+e.data);}";

var blob;
try {
  blob = new Blob([response], { type: "application/javascript" });
} catch (e) {
  // Backwards-compatibility
  window.BlobBuilder =
    window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder;
  blob = new BlobBuilder();
  blob.append(response);
  blob = blob.getBlob();
}
var worker = new Worker(URL.createObjectURL(blob));

// Test, used in all examples:
worker.onmessage = function(e) {
  alert("Response: " + e.data);
};
worker.postMessage("Test");

在 react hook 和 Worker 结合的一个 npm 包里就有过使用这种方法的代码。简单的把用户的 task(一个方法)转成字符串,以后经过Blob获得一个 URL 来建立出一个 Worker。其余使用 react hook 的工做机制通知 task 执行的结果。很是的简单有效。代码在这里

节选部分代码,以飨读者:

const createWorker = func => {
  if (func instanceof Worker) return func;
  if (typeof func === "string" && func.endsWith(".js")) return new Worker(func);
  const code = [
    `self.func = ${func.toString()};`,
    "self.onmessage = async (e) => {",
    "  const r = self.func(e.data);",
    "  if (r[Symbol.asyncIterator]) {",
    "    for await (const i of r) self.postMessage(i)",
    "  } else if (r[Symbol.iterator]){",
    "    for (const i of r) self.postMessage(i)",
    "  } else {",
    "    self.postMessage(await r)",
    "  }",
    "};"
  ];
  const blob = new Blob(code, { type: "text/javascript" });
  const url = URL.createObjectURL(blob);
  return new Worker(url);
};

如今这部分代码都交给 webpack 都插件来作了。

Web Worker 不能作什么

首先 Web Worker 不能访问 UI thread 的 UI,也就是 DOM。
若是一个 Web Worker 能够访问 DOM,那加上 UI thread 就是两个或者两个以上的 Worker 能够访问 DOM 了,那就会出现很是麻烦的多线程特有的问题,并且调试困难。因此 DOM 确定是不能访问的。

其余的还有不少限制能够参考这里

可是,仍是能够发出网络请求,能够setTimeout, setInterval,仍是可使用CacheIndexedDB等等一些功能等。

Worker 虽好,也不能开的太多。Worker 是真正系统级的线程,要运行起来就须要有支撑的资源。在 Worker 之间传输的数据不能太大。为了不多个 Thread 共享内存而致使的多线程问题,WeW Worker 传输数据的时候使用了两个方式:

  1. 在多个 Worker 之间传输的数据是拷贝传输的。开发者不须要考虑这段数据的锁保护之类的事情。
  2. 以拷贝的方式传输数据,数据量过大的时候拷贝消耗的资源也会很大。这个时候就要考虑使用Transferable Object。这种类型的数据在传输的时候基本不存在复制的动做,能够认为是 c++里的引用传递。不一样的是 Worker 的Transferable Object在传递出去以后就当前上下文里即不可访问。
// Create a 32MB "file" and fill it.
var uInt8Array = new Uint8Array(1024 * 1024 * 32); // 32MB
for (var i = 0; i < uInt8Array.length; ++i) {
  uInt8Array[i] = i;
}

worker.postMessage(uInt8Array.buffer, [uInt8Array.buffer]);

举个栗子

咱们来把一个字符串反转屡次来模拟 CPU “繁重”的任务。这个栗子分为三部分一个是运行在 UI thread 上看看会有多卡,一个是运行在Promise里,看看会有什么不一样的结果。数据所有都是基于咱们的栗子来获得,对于读者来讲因为有些网络、硬件等状况不一样或者不彻底可控会有不一样,定量分析不会那么准确,定性分析有必定的表明性。

同时,这个试验和样本的数量关系十分密切。在样本足够打的时候,试验只会收到异常。

测试数据是怎么来的

const ITERATE_COUNT = 1000;
const STR_LEN = 3;

let queue: TaskQueue | null = null;

function prepareData(count: number = 1000, length: number = 10) {
  const data: Array<DataType> = [];
  for (let i = 0; i < count; i++) {
    const item = RandomString.generate(length);
    data.push({ key: `Key - ${i}`, val: item });
  }

  return data;
}

const rawData = prepareData(ITERATE_COUNT, STR_LEN);
(window as any).rawData = rawData;

上面的方法生成了 1000 个长度是 10 的字符串。在后面的例子里会把这些字符串所有反转。以此来模拟某种业务场景下繁重的 CPU 任务。

例1、在主线程

代码:

// Demo 1: execute reverse string in ui thread
function execTaskSync() {
  console.time("sync task in ui thread");

  const target = rawData;
  for (let el of target) {
    const { val } = el;
    reverseString(val);
  }

  console.timeEnd("sync task in ui thread");
}

(window as any).execTaskSync = execTaskSync;

这个任务量其实不够大,只会产生一个和后面例子对比的效果。先运行一下看看结果:

ui thread

运行结果看起来很快,若是须要更慢一些只须要把字符串数量或者字符串的长度调大就能够。运行的结果基本都在 0.xx ms 的范围内,只有一个是 2.27 ms。这也许只是一个现象,也许就很值得深究了。

在 Worker 运行

是时候让这个功能在 worker 里面运行一次了:

ctx.onmessage = (event: MessageEvent) => {
  console.time("worker timer");

  const { target } = event.data as { target: DataType[] };
  for (let el of target) {
    const { val } = el;
    reverseString(val);
  }

  console.timeEnd("worker timer");

  ctx.postMessage("done");

  // Close the worker when jobs done
  self.close();
};

数据所有传过来以后,在 worker 连运行。结果是这样的:
in a worker

在 Micro Queue 运行

看起来是一个 queue,不过是一个个 Promise 接连运行的。在本例中只有一个 Promise 运行。

Queue 是什么样的 Queue:

class Queue {
  private _startExec() {
    const task = this._queue.shift();
    if (task) task.run();
  }

  next() {
    if (this._queue.length === 0) {
      return;
    }

    this._startExec();
  }

  async addTask(
    fun: (param: any) => any,
    data: any,
    resolve: (val: any) => void,
    reject: (err: any) => void
  ) {
    const run = async () => {
      try {
        const ret = await fun(data);
        resolve(ret);
      } catch (e) {
        reject(e);
      }

      this.next();
    };

    this._queue.push({ run } as Task);
    this._startExec();
  }
}

这个是在 Queue 里添加 task 的方法,在添加的时候就会在 task 运行完成以后调用 Queue 的 next 方法来开始下一个 task。

在数据量一样的状况下运行的结果:
In queue

看起来和在主线程的运行结果至关的接近了。咱们来把数据量加大看看会有什么结果。

const ITERATE_COUNT = 100000;
const STR_LEN = 300;

先把数量级提高到这个程度。

屡次运行以后,主线程和放在 Promise 里的方式差异依然不大,只是在按钮点击以后明显的增长了等待的时间。在 Worker 里运行的花费时间比之主线程依然更多,可是按钮点击以后的等待时间并无相应的更多等待。

Run in Worker with Buffer

这就体现出 Worker 存在的意义了。相应用户点击的速度必定会快不少。这个时候就须要Buffer出场了。咱们来测试一下使用了 Buffer 的 Worker 会出现什么样的惊喜。
worker-buffer

明显在第一次消耗了不少时间以后,每次的调用都消耗了比直接调用 Worker 的postMessage更少的时间。使用 Buffer 来实现不一样 Worker 之间传输数据就像是 C/C++的引用传递同样,这里不会涉及到数据的拷贝操做。因此节省了时间。

可是,在代码里:

const dataStr = JSON.stringify(data);
const dataBuff = str2ab(dataStr);

const worker = new CachedWorker();
worker.postMessage(dataBuff, [dataBuff]);

其实包含了数据->字符串(json)->buffer 的转化过程。第一次花费的时间不少是在这些转化的过程当中消耗的。可是后面,笔者认为是浏览器作了优化,还要继续查一下资料,因此花费的时间只有直接传输 buffer 花费的时间,因此大量减小。

注意:使用 Buffer 传输数据能够很大,好比在 Google 的某个例子中是 30M 多。可是,上文的例子中,传输的数据的大小受到了很大的限制。主要是在把 Buffer 的数据转化为 Object 的时候会出现异常。有兴趣的各位能够把数据的大小继续往大调这个异常就会出现。_因此,如何使用须要看具体的场景,好比,上例能够改成在 Worker 里请求获得二进制数据再作处理。_

Transferable Object

传递 Buffer 的时候是按照 Transferable Object 传递的。这种数据是实现了Transferable接口的数据。这个接口就是一个标记的做用,代表实现了这个接口的数据能够如引用通常传递。

可是,此处的引用和 C/C++的引用是两回事。Transferable object 在完成不一样的执行上下文(execution context)传输以后就再也不可用了。H5 委员会为了 Worker 能够普及,默默的解决了多少使用多线程可能会出现的问题。

屡次执行就不用说了,只执行一次的代码缓存起来也存粹是浪费空间。缓冲的命中率是说缓存的结果会被用到。若是缓存不会再被屡次执行的某个功能用到,那么也是没有意义的。

在本例中,缓存的做用基本上大打折扣。字符串是随机生成的。用随机字符串为 Key 缓存的结果,基本上备用到的几率很小,并且随机字符串的数量比较大(这里是 1000)。那么在查找缓存字符串的时候也要便利 map 的大部分 Key。反而形成了没必要要的多余计算。

因此,缓存须要根据代码的执行逻辑和缓存的命中率来判断是否须要。

Worker 的使用离不开特定的场景

使用 Worker 或者不使用 Worker 都是要看具体的某个场景。新技术的产生必定是解决某个特定的问题的。在使用这项新技术以前至少要尽可能真实的模拟须要解决的场景,来验证这个新的技术是否可行。好比,在本文使用的例子就是为了模拟笔者想要解决的问题的场景设立的。遇到的最大的问题是若是数据量达到某个临界值的时候,在 Worker 内部反序列化并组成 Object 的时候就会出现异常。而混存,由于 Key 值极大的多是重复的,因此混存的使用就很是的有必要。在以上各类场景的模拟以后可使用的各类技术的结合必然是缓存和使用Buffer传输数据。可是,数据量须要控制,不能出现反序列化的问题。

或者,直接从 Worker 里请求获得 JSON 的二进制串,好比发送和接收二进制数据

因此,各类技术都有在特定场合下使用的优劣。这就须要咱们具体结合场景具体分析。

相关文章
相关标签/搜索