浅析 mapbox-gl 的 Actor 模型

405 阅读4分钟

演员(Actor)模型 是一种基于消息的计算的数学模型,它简化了多个“实体”(或“演员”)相互通信的方式。 演员通过相互发送消息(事件)来进行通信。 Actor的本地状态是私有的,除非它希望通过将其作为事件发送来与另一个 Actor 共享,常用来解决单线程下的并行计算。

mapbox 中的大致组织形式如下图所示:

Untitled Diagram.drawio

Dispatcher

上层实现了一个调度器Dispatcher用于管理其下的 Actor,他在 style.js 创建,这里需要注意的是 getWorkerPool这里如无特殊设置返回的全局只有一个WorkerPool(同一页面不管有几个Map 实例都共享同一个WorkerPool):

this.dispatcher = new Dispatcher(getWorkerPool(), this, map._getMapId());

this.dispatcher.broadcast('setReferrer', getReferrer());

WorkerPool

Dispatcher紧接着创建一个 worker 线程池,主要代码如下:

acquire(mapId: number | string): Array<WorkerInterface> {
  if (!this.workers) {
    // Lazily look up the value of mapboxgl.workerCount so that
    // client code has had a chance to set it.
    this.workers = [];
    while (this.workers.length < WorkerPool.workerCount) {
      this.workers.push(new WebWorker());
    }
  }

  this.active[mapId] = true;
	return this.workers.slice();
}

线程池的数量一般会根据hardwareConcurrency 来计算,一般为 hardwareConcurrency的一半:

const hardwareConcurrency = typeof window !== 'undefined' ? (window.navigator.hardwareConcurrency || 4) : 0;
const workerCount = Math.max(Math.floor(hardwareConcurrency / 2), 1);

Actor

Actor 是在获取线程池后创建的,数量和workers保持一致,每个Actor 管理一个worker,worker 之间的通信通过 MessageChannel来通信:

const workers = this.workerPool.acquire(this.id);
for (let i = 0; i < workers.length; i++) {
  const worker = workers[i];
  const actor = new Dispatcher.Actor(worker, parent, this.id);
  actor.name = `Worker ${i}`;
  this.actors.push(actor);
}

何处使用

mapbox中除了broadcast广播类的使用,最常见的是在调用 addSource 创建 Source实例的时候使用,以VectorTileSource来说,在内部调用loadTile 的时候会取出一个可用的Actor:

tile.actor = this._tileWorkers[url] = this._tileWorkers[url] || this.dispatcher.getActor();

相关的数据请求和解析都在 worker中处理,处理完成后以回调的方式返回数据,这样大大减小了主线程的压力,每个瓦片的加载和解析可能分配在不同的 worker中处理。

worker 的打包:

我们先看 rollup 配置

export default [
  // 第一次构建
  {
    // First, use code splitting to bundle GL JS into three "chunks":
    // - rollup/build/index.js 主模块,包含所有未被共享的模块
    // - rollup/build/worker.js: worker 模块
    // - rollup/build/shared.js: 共享模块
    input: ['src/index.js', 'src/source/worker.js'],
    output: {
        dir: 'rollup/build/mapboxgl',
        format: 'amd',
        sourcemap: 'inline',
        indent: false,
        chunkFileNames: 'shared.js'
    },
    treeshake: production,
    plugins: plugins(minified, production, false, bench)
  },
  // 第二次构建
  {
      // Next, bundle together the three "chunks" produced in the previous pass
      // into a single, final bundle. See rollup/bundle_prelude.js and
      // rollup/mapboxgl.js for details.
      input: 'rollup/mapboxgl.js',
      output: {
          name: 'mapboxgl',
          file: outputFile,
          format: 'umd',
          sourcemap: production ? true : 'inline',
          indent: false,
          intro: fs.readFileSync(fileURLToPath(new URL('./rollup/bundle_prelude.js', import.meta.url)), 'utf8'),
          banner
      },
      treeshake: false,
      plugins: [
          // Ingest the sourcemaps produced in the first step of the build.
          // This is the only reason we use Rollup for this second pass
          sourcemaps()
      ],
  }
];

两步打包,先生成 amd 包,首次构建共有两个入口:

  1. 主模块
  2. worker 模块

如果 worker 和主线程代码有共享内容,会生成 shader.js,主要包含的是在主模块和 worker 模块共同依赖的代码,比如 util 工具类、ajax 模块等。这样能够减少重复代码减小最终构建的包体积。

构建产物共有三个:

  1. rollup/build/mapboxgl/index.js
  2. rollup/build/mapboxgl/shared.js
  3. rollup/build/mapboxgl/worker.js

第二次构建入口只有一个文件rollup/mapboxgl.js,其内容主要是导入第一步的构建产物,并且添加了一个头文件rollup/bundle_prelude.js,大致相当于构建的内容如下:

/* eslint-disable */

var shared, worker, mapboxgl;
// define gets called three times: one for each chunk. we rely on the order
// they're imported to know which is which
function define (_, chunk) {
  if (!shared) {
    shared = chunk;
  } else if (!worker) {
    worker = chunk;
  } else {
    var workerBundleString = 'self.onerror = function() { console.error(\'An error occurred while parsing the WebWorker bundle. This is most likely due to improper transpilation by Babel; please see https://docs.mapbox.com/mapbox-gl-js/guides/install/#transpiling\'); }; var sharedChunk = {}; (' + shared + ')(sharedChunk); (' + worker + ')(sharedChunk); self.onerror = null;'

    var sharedChunk = {};
    shared(sharedChunk);
    mapboxgl = chunk(sharedChunk);
    if (typeof window !== 'undefined') {
      mapboxgl.workerUrl = window.URL.createObjectURL(new Blob([workerBundleString], {type: 'text/javascript'}));
    }
  }
}

import './build/mapboxgl/shared';
import './build/mapboxgl/worker';
import './build/mapboxgl/index';

export default mapboxgl;

并且注意这一步单独添加了一个 rollup 插件 rollup-plugin-sourcemaps,主要是为了处理多步构建的sourcemap

最终打包后的产物内容如下:

image-20230222145817158

mapbox 的这种处理方式有个缺陷就是我们只能在主模块使用默认导出export default,不可以使用 export;如果使用了export导出相关模块,`shader共享模块会出问题。

但是需要注意的是我们这样生成的类库在使用的时候可能无法很好的进行 Tree Shaking。我们看下 mapbox的主入口文件:

import Map from './ui/map.js';

const exported = {
	Map,
	...
};
export default exported;

当然除此方式还可以使用以下方式来处理 webworker

  1. 独立构建 worker 代码,使用 importScripts 导入,但是这样容易出现找不到对应 webworker 文件的情况(cesium 的 worker 文件都是独立的,通过cesiumWorkerBootstrapper.js来动态加载文件)。
  2. 使用 rollup-plugin-web-worker-loader 或者 webpack4 的 worker-loader,在 webpack5 之后可以直接使用,不再需要loader 处理,详细内容请查看web-workers