使用 docker 编译 wasm

Web Assembly 这个技术出来很长时间了,但一直没有用过,主要是不会写后端,也用不到。之前也打算尝试一下,可是安装 emscripten 后测试编译 c 代码失败了。记得好像是版本问题。但我只有一台服务器,也不打算折腾,就放下了。

这几天看到有文章里是用 docker 编译的 wasm。因为之前也看过一点 docker,顿时觉得应该再试试,毕竟 docker 里面怎么折腾也没关系。大不了再实例化一个容器就是了。

安装容器环境

关于用 docker 编译 wasm,在 docker 的官方仓库里有已经配置好的镜像,按照镜像的文档操作就可以了。

首先,在某个文件夹下创建一个 cpp 文件(官方示例是 c++,可以换成自己习惯的语言)。

// helloword.cpp
#include <iostream>
int main() {
  std::cout << "Hello World!" << std::endl;
  return 0;
}

然后,执行命令:

docker run \
  --rm \
  -v $(pwd):/src \
  -u $(id -u):$(id -g) \
  emscripten/emsdk \
  emcc helloworld.cpp -o helloworld.js

关于命令的解释文档里有。

使用 wasm

nignx 配置

对于 wasm 文件,服务端在发送的时候是作为流处理的。但浏览器端需要类型为 application/wasm。因此,需要配置 mime.types 文件。这个文件 nginx/conf 目录下。打开后添加 application/wasm wasm 再重启 nginx 即可。

加载并实例化 wasm

对于 wasm 文件的加载,mdn 里提了两种,一种是 fetch,还有一种是 XMLHttpRequest。对于 wasm 字节码的实例化也有很多方法。比如:

fetch('simple.wasm')
  .then(res =>
    res.arrayBuffer()
  ).then(bytes =>
    WebAssembly.instantiate(bytes, importObject)
  ).then(results => {
    results.instance.exports.exported_func();
  });

使用 WebAssembly.instantiate() 方法需要先将请求的结果转成 ArrayBuffer 才能实例化。

还有一种是 WebAssembly.instantiateStreaming()。这种方法可以直接编译实例化。

WebAssembly.instantiateStreaming(fetch('simple.wasm'), importObject)
.then(obj => obj.instance.exports.exported_func());

因为我需要将 wasm 传到 worker 里面使用,所以用了另一种——WebAssembly.compileStreaming()。

// index.js
/**
 * 加载 wasm
 * @param {string} path 模块路径
 * @param {string} module 模块名称
**/
function loadWasm(path, module) {
  WebAssembly.compileStreaming(fetch(path))
    .then(mod => {
      // 发送到 worker。这里只说明相关 api 用法,所以先省略。
    });
}
// worker.js
WebAssembly.instantiate(mod)
  .then((instance) => {
    console.log(instance)
  })

实例化参数

上面的方法都有第二个可选参数:importObject。这个参数是一个对象,包含需要导入到 wasm 模块的函数、WebAssembly.Memory 对象等。wasm 模块默认的内存(instance.exports.memory)是 16m。这可能不够用,此时就可以通过 importObject 传入一个更大的内存块给模块使用。

wasm 模块函数不存在问题

我根据网上的文章写了一个 c 模块并编译成了 wasm,但实例化后的模块并没有相关函数。

int add (int x, int y) {
  return x + y;
}

int square (int x) {
  return x * x;
}

这是因为默认情况下,emscripten 生成的代码只会调用 main 函数,其他的是为无用代码。为了避免这种情况,需要在函数名前添加 EMSCRIPTEN_KEEPALIVE。

#include <emscripten/emscripten.h>
// 因为 emscripten 编译的时候只保留 main 函数,所以为了让其他函数也导出,需要在其他函数前面加上 EMSCRIPTEN_KEEPALIVE。而这个标识符是在 emscripten.h 中声明的。

int EMSCRIPTEN_KEEPALIVE add (int x, int y) {
  return x + y;
}

int EMSCRIPTEN_KEEPALIVE square (int x) {
  return x * x;
}

demo 代码

// index.js
// 加载 worker
const worker = new Worker('./script/worker.js')

// worker 响应
function handleWorkerMessage(e) {
  // console.log(e)
  if (e.data === 'ready') {
    status.worker = 1
    return
  }
  if (!e.data || !e.data.key) {
    console.log('worker 返回数据错误')
  }
  let task = tasks.get(e.data.key)
  if (task) {
    task.resolve(e.data.result)
  }
}

// worker 监听
worker.addEventListener('message', handleWorkerMessage)

// 任务列表
const tasks = new Map()

// 发送任务
function dispatch(obj) {
  return new Promise((resolve, reject) => {
    let timestamp = Date.now()
    let key = Math.random().toString().substr(2)
    const task = {
      ...obj,
      timestamp,
      key,
      resolve,
      reject
    }
    tasks.set(key, task)
    worker.postMessage({
      ...obj,
      timestamp,
      key,
    })
  })
}

// 状态管理
const status = new Proxy({
  worker: 0, // 0--未就绪,1--就绪
  wasm: 0,
  'c/add': 0
}, {
  set: (obj, prop, value) => {
    let oldValue = obj[prop]
    let handler = null
    obj[prop] = value
    switch (prop) {
      case 'worker':
        console.log('worker change', value)
        break
      case 'c/add':
        console.log('wasm c/add', value)
        break
    }
    // 处理数据监听
    handler = watches.get(`status.${prop}`)
    if (handler) {
      handler(value, oldValue)
    }
  }
})

// 监听
const watches = new Map()
function watch(key, handler) {
  watches.set(key, handler)
}

/**
 * 加载 wasm
 * @param {string} path 模块路径
 * @param {string} module 模块名称
 * @param {object} importObject 导入对象,可选
**/
function loadWasm(path, module, importObject) {
  WebAssembly.compileStreaming(fetch(path))
    .then(mod => {
      dispatch({
        action: 'wasm',
        param: [module, mod, importObject]
      })
        .then(res => {
          if (res) {
            status[module] = 1
          } else {
            console.log(`${module} 模块加载失败`)
          }
        })
    });
}

// 检查 wasm 是否都加载完成
function checkWasmStatus() {
  const modules = ['c/add']
  if (modules.every(module => status[module])) {
    status.wasm = 1
  }
}

// 监听 status.worker 属性
watch('status.worker', (status) => {
  if (status) {
    // 加载 c/add 模块
    loadWasm('./module/c/add.wasm', 'c/add')
  }
})

// 监听 status.c/add
watch('status.c/add', (status) => {
  if (status) {
    // 检查 wasm 是否都加载完成
    checkWasmStatus()
  }
})

// 监听 status.wasm
watch('status.wasm', (status) => {
  if (status) {
    dispatch({
      action: 'printActions',
    })
    dispatch({
      action: 'c/add.add',
      param: [1, 2]
    })
      .then(res => {
        console.log(res)
      })
  }
})
// worker.js
onmessage = async (e) => {
  // console.log(e)
  const { action, key, param = [] } = e.data
  if (!actions[action]) {
    console.log(`操作${action || ''}不存在`)
    return
  } else {
    const res = {
      key,
      result: await actions[action](...param)
    }
    postMessage(res)
  }
}

// 通知准备就绪
postMessage('ready')

/**
 * 加载 wasm
 * @param {string} module 模块名称
 * @param {ArrayBuffer} mod wasm字节码
 * @param {object} importObject 导入对象,可选
**/
async function wasm(module, mod, importObject = {}) {

  return new Promise((resolve, reject) => {
    console.log(importObject)
    WebAssembly.instantiate(mod, importObject)
      .then((instance) => {
        Object.keys(instance.exports).map(key => {
          actions[`${module}.${key}`] = instance.exports[key]
        })
        resolve(true)
      })
  })
}

/**
 * 打印 actions
**/
function printActions() {
  console.log(actions)
}

const actions = {
  wasm,
  printActions
}

至此,一个简单的在 worker 中运行 wasm 的 demo 就算完成了。