vlambda博客
学习文章列表

浏览器中执行 C 语言?WebAssembly 实践


浏览器中执行 C 语言?WebAssembly 实践

wyf


网易游戏高级开发工程师,负责基础架构平台产品的前端开发工作。


在最近的一个项目开发中,需要在前端实现计算文件 MD5 和解析压缩包文件目录的功能。在库的选择上,JavaScript 有 spark-md5 库用于计算 MD5,但是对于上 G 大小的文件经常需要数十秒甚至几分钟的计算时间。至于解析压缩文件,虽然一些库可以来解压 zip,而项目还需解析 7z 格式,这方面没有现成的 JavaScript 库可以使用。

对于 MD5 计算等计算密集的任务、文件解压等基础功能,往往能找到相应的 C 语言的库,而各个主流浏览器在 2017 年开始支持的 WebAssembly 为 C 在浏览器中执行其他语言提供了可能。

WebAssembly 是除了 JavaScript 外,另一种可以运行在浏览器中的语言,它是一种低级的类汇编语言,可以接近原生的性能运行。WebAssembly 作为诸如 C/C++/Rust 等语言的编译目标,使它们可以以 WebAssembly 的形式在浏览器中执行。

在项目中最终使用 WebAssembly 实现了 MD5 的计算,计算耗时减少了 60%,并且使用了 libarchive 进行了压缩文件解析。下面对这个过程中的一些经验和问题进行回顾。

(点击阅读原文可以查看 demo)

Hello World

我们尝试一个简单的例子,首先编写一段 C 的 hello world 代码:

/* main.c */
#include <stdio.h>

int main() {
  printf("hello world\n");
  return 0;
}

使用 Emscripten 编译。Emscripten 的安装可以参考文档,为了方便和统一我们使用一个已经构建好的 docker 镜像 trzeci/emscripten。

docker run --rm -v $(pwd):/working trzeci/emscripten \
  emcc /working/main.c -o /working/index.html

emcc 即 Emscripten 提供的类似 gcc 的编译器,这里我们设置输出为 index.html,则 emcc 会输出:

  • index.wasm,由 C 编译生成的 WebAssembly 二进制文件

  • index.js,Emscripten 提供的封装了浏览器 WebAssembly API 的 JavaScript 代码

  • index.html,Emscripten 提供的 html 入口

编译成功后,启动一个静态服务器:

# 使用 Node.js
npx serve .
# 或使用 Python
python -m SimpleHTTPServer 8000

用浏览器访问服务器,即可看到代码执行的结果:

浏览器中执行 C 语言?WebAssembly 实践

在 2017 年 Firefox、Chrome、Safari 和 Edge 等浏览器均支持了 WebAssembly API。WebAssembly API 主要包括:

  • WebAssembly 的加载和执行

  • 函数的 import / export

    WebAssembly 可以 import JS 中的函数,JS 也可以执行 WebAssembly export 的函数

  • 内存

    WebAssembly 使用的是线性内存,相当于 C 的堆。在 JavaScript 中,WebAssembly 的内存即一个 ArrayBuffer

    const memory = new WebAssembly.Memory({ initial1024maximum2 * 1024 })
    console.log(memory.buffer instanceof ArrayBuffer// true

    在 JavaScript 中,可以任意地读写这块内存:

    const uint8Array = new Uint8Array(memory.buffer)
    // 在 0xFF 写入 'A'
    uint8Array[0xFF] = 'A'.charCodeAt(0)
    console.log(uint8Array[0xFF]) // 65

Emscripten 生成的 .js 文件中针对 C/C++ 的特性封装了 WebAssembly API,提供了更方便的加载执行、函数调用、内存操作、类型转换等功能。

加载 WebAssembly

若要在我们自己的前端应用中加载 Emscripten 编译后的 WebAssembly,直接引入输出的 .js 文件即可。

这里我们添加一些参数:

docker run --rm -v $(pwd):/working trzeci/emscripten \
  emcc /working/main.c -o /working/cutils.js \
  -s MODULARIZE=1 \
  -s EXPORT_NAME=CUtils

MODULARIZE=1 会使 Emscripten 以 UMD 模块格式输出 JavaScript,模块名为 EXPORT_NAME 所定义的名称。

若输出的是 .js 后缀,则不会生成 html 文件,仅生产 .js 和 .wasm

将 main.js 通过 script 标签、或所用打包工具的方式引用到前端:

<script src="path/to/cutils.js"></script>
<script>
  const Module = CUtils({
      onRuntimeInitialized() => {
        // loaded
      },
  })
</script>

JavaScript 调用 C 函数

以计算文件的 MD5 为例。

我们使用这里的 MD5 代码,根据 md5.h 可以知道大致的使用方式为:

MD5_CTX md5_ctx;

MD5_Init(&md5_ctx);
MD5_Update(md5_ctx, buff, buff_size);
MD5_Final(p_result, md5_ctx);

那么我们在 JavaScript 中要做的事为:

  1. 因为 MD5 值是 128 bits,所以申请 8 Bytes 的空间 p_result,作为参数调用 MD5_Final

  2. 读取 p_result 的内容,即 MD5 值。

首先我们编译 md5.c:

docker run --rm -v $(pwd):/working trzeci/emscripten \
  emcc /working/md5.c -o /working/cutils.js \
      -s MODULARIZE=1 -s EXPORT_NAME=CUtils \
      -s EXPORTED_FUNCTIONS='["_MD5_Init", "_MD5_Update", "_MD5_Finish", "_malloc", "_free"]' \
      -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall"]'

根据文档,我们需要在编译参数中通过 EXPORTED_FUNCTIONS 声明需要用到的 C 的函数(函数名前需添加下划线),通过 EXTRA_EXPORTED_RUNTIME_METHODS 声明需要用到的 Emscripten Module 提供的方法。

在浏览器中加载 cutils.js,待 Module 加载完成后,可以调用 Moduleccall 方法来调用 C 中的函数,如:

const STRUCT_MD5_CTX_SIZE = 152
const pMd5Ctx = Module.ccall(
  'malloc',      // 函数名
  'number',      // 返回值类型
  ['number'],    // 参数类型
  [SIZE_OF_MD5_CTX] // 参数值
)

若 C 中的函数经常被调用,可以先用 cwrap 来封装:

const malloc = Module.cwrap('malloc''number', ['number'])
const pMd5Ctx = malloc(STRUCT_MD5_CTX_SIZE)

接下来我们看一下如何读取文件并得到 buff

内存与 ArrayBuffer

在浏览器中需要使用 FileReader 读取文件:

const reader = new FileReader()
// file 为通过 <input type="file"> 选择的文件
// 这里将文件读为 ArrayBuffer
reader.readAsArrayBuffer(file) 
reader.addEventListener('load', e => {
  const arrayBuffer = e.target.result
  calculateMd5(arrayBuffer)
})

这里文件被读为了 ArrayBuffer 格式。

ArrayBuffer 是一段固定长度的原始的二进制数据,不能直接进行读写。若要读写 ArrayBuffer,需要创建一个 typed array,如 Int8Array, UInt32Array,用其特定的格式作为这段二进制数据的 view,从而进行读写操作。

WebAssembly 的内存也是一个 ArrayBuffer,Emscripten 封装的 Module 提供了 Module.HEAP8Module.HEAPU8 等各种 view。

我们为文件的 ArrayBuffer 申请一段空间,并将文件的 ArrayBuffer 加进 WebAssembly 的内存中:

const pBuff = malloc(ab.byteLength)
Module.HEAP8.set(new Int8Array(ab), pBuff)

下面是完整的 MD5 计算代码:

// 封装 C 中的函数
const c = {
  malloc: Module.cwrap('malloc''number', ['number']),
  free: Module.cwrap('free'null, ['number']),
  md5_init: Module.cwrap('MD5_Init''number', ['number']),
  md5_update: Module.cwrap('MD5_Update'null, ['number''number''number']),
  md5_final: Module.cwrap('MD5_Final'null, ['number'])
}
function calculateMd5(ab{
  // 申请空间
  const pMd5Ctx = c.malloc(STRUCT_MD5_CTX_SIZE)
  const pBuff = c.malloc(ab.byteLength)
  const pResult = c.malloc(MD5_BYTE_SIZE) // 即 128 / 8
  // 将文件 buffer 设置到内存中
  Module.HEAP8.set(new Int8Array(ab), pBuff)
  // 计算 MD5
  c.md5_init(pMd5Ctx)
  c.md5_update(pMd5Ctx, pBuff, ab.byteLength)
  c.md5_final(pResult, pMd5Ctx)
  // 从内存中获得 MD5 值
  const result = getMd5(Module, pResult)
  // 释放空间
  c.free(pMd5Ctx)
  c.free(pBuff)
  c.free(pResult)
  return result
}

上面的代码中 MD5_Final 将 MD5 写进了内存中,那么如何获得 MD5 的字符串格式?因为 MD5 总共为 128 bit,我们可以通过无符号的 8 位整型数组(Int8Array)来获得这段内存每一字节的值,依次转为 16 进制表示:

cosnt MD5_BYTE_SIZE = 128 / 8
function getMd5(Module, pResult{
  let result = ''
  for (let i = 0; i < MD5_BYTE_SIZE; i++) {
    const hex = Module.HEAPU8[pResult + i].toString(16)
    result += '00'.concat(hex).slice(-2)
  }
  return result
}

这样我们就完成了 MD5 的计算,但却有一个严重的问题:我们一次性整个文件读进了内存中,若文件过大会导致浏览器崩溃。

浏览器中执行 C 语言?WebAssembly 实践

在实际应用中,我们需要分段地读取文件。

Emscripten FS

Emscripten 提供了一套文件系统,实现了 libc 中的 file I/O。在浏览器环境中,可以使用 Emscripten 的 WORKERFS 文件系统,根据其描述:

This file system provides read-only access to File and Blob objects inside a worker without copying the entire data into memory and can potentially be used for huge files.

WORKERFS 可以用来读取文件,并且是分段加载,无需将文件全部加载在内存中。

我们在 C 中实现一个读取文件并计算 MD5 的函数:

void md5(char* path, unsigned char* md5_result) {
  int CHUNK_SIZE = 16 * 1024 * 1024;
  char* buff = malloc(CHUNK_SIZE);
  MD5_CTX md5_ctx;
  FILE* stream = fopen(path, "r");
  size_t read_size;

  MD5_Init(&md5_ctx);
  while (1) {
    read_size = fread(buff, 1, CHUNK_SIZE, stream);
    if (read_size > 0) {
      MD5_Update(&md5_ctx, buff, read_size);
    } else {
      break;
    }
  }
  MD5_Final(md5_result, &md5_ctx);

  fclose(stream);
  free(buff);
}

编译后(需要设置 EXPORTED_FUNCTIONS 函数)在 JavaScript 中挂载文件:

(Emscripten 的 WORKERFS 文件系统要求在 Worker 环境下使用,以下代码需要在 Web Worker 中运行。)

function calculateMd5Sync({
  const MOUNT_DIR = '/working'
  Module.FS.mount(Module.FS.filesystems.WORKERFS, { files: [file] }, MOUNT_DIR)
  // ...
}

调用 md5:

function calculateMd5Sync({
  // ...
  c.md5 = Module.cwrap('md5'null, ['string''number'])
  const pResult = c.malloc(MD5_BYTE_SIZE)
  c.md5(`${MOUNT_DIR}/${file.name}`, pResult)
  const result = getMd5(Module, pResult)
  c.free(pResult)
}

在 Demo 中查看效果:

浏览器中执行 C 语言?WebAssembly 实践

这里有一个值得注意的点,在浏览器中使用 FileReader 读取文件明明是异步的操作,为什么这里 calculateMd5Sync 是一个没有 callback、没有 Promise 的同步的代码?

在 emcc 生成的 cutils.js 中可以看到:

var WORKERFS = {
  mountfunction(mount{
    assert(ENVIRONMENT_IS_WORKER);
    if (!WORKERFS.reader) WORKERFS.reader = new FileReaderSync();
    //...
  },
  stream_ops: {
    readfunction (stream, buffer, offset, length, position{
      if (position >= stream.node.size) return 0;
      var chunk = stream.node.contents.slice(position, position + length);
      var ab = WORKERFS.reader.readAsArrayBuffer(chunk);
      buffer.set(new Uint8Array(ab), offset);
      return chunk.size;
    },
  // ...
};

WORKERFS 使用的是 FileReaderSync,由于其同步读取的特性会阻塞线程,因此标准限制仅能在 Worker 中使用,这也就解释了为什么 Emscripten 的 WORKERFS 只能在 Worker 中使用。

并行优化

在上一例的截图中可以看到,计算文件 IO 的时间将近占了总时间的一半。实际上,上例代码的执行顺序是

读文件区块 #1 -> 计算区块 #1 -> 读文件区块 #2 -> 计算区块 #2 -> ...

但如果在计算区块 #1 时能够同时读取文件区块 #2,可以缩短计算任务的总耗时。

Demo 中实现了 makeBlobIterator 用于生成一个读取文件的 AsyncGenerator,这个生成器通过 Web Worker 在另一个线程读取文件,并在每次 yield 文件内容前,提前获取下一个区块的内容。

我们在 JavaScript 中通过 makeBlobIterator 分块读取文件,并将每一块内容放在内存中,再由 C 获取并计算:

async function calculateMd5(file{
  // ...
  c.md5_init(pMd5Ctx)
  for await (const chunk of makeBlobIterator(file, CHUNK_SIZE)) {
    Module.HEAP8.set(new Int8Array(chunk), pBuff)
    c.md5_update(pCtx, pBuff, chunk.byteLength)
  }
  c.md5_final(pResult, pCtx)
  const result = getMd5(Module, pResult)
  // ...
}

测试结果:

浏览器中执行 C 语言?WebAssembly 实践

对比 JavaScript 计算 MD5(spark-md5),使用 WebAssembly 计算 MD5 耗时减少 65% 左右,综合其他 IO 等其他耗时,总耗时减少 57%。

使用第三方库

通过 WebAssembly,浏览器可以使用其他语言的生态中成熟的工具、算法库。在 Emscripten 中若要使用需要编译的第三方库,需要使用 emsdk 编译到 Emscripten 支持的环境。

以解压库 libarchive 为例,根据 libarchive 的 Build Instruction,编译 libarchive 需要执行:

./configure
make
make check
make install

则对应的 Emscripten 环境,需要执行:

emconfigure ./configure
emmake make
emmake make check
emmake make install

具体的编译命令请参考 Demo 源码中的 Dockerfile

C 调用 JavaScript 函数

使用 libarchive 解析压缩文件时,需要通过遍历的方式获得每一个文件的信息,每当遍历到一个文件时,需要「通知」JavaScript 文件的信息。这种「通知」机制在 JavaScript 中很常见,通常是通过回调函数的方式实现的,那么我们可以在 JavaScript 中给 C 传递一个回调函数吗?

Emscripten 提供了多种在 C 中调用 JavaScript 的方式,其中包括函数指针。

若有了函数指针,我们可以这样调用 libarchive:

// C

void extract(char* path, void (*on_pathname)(const char*){
  struct archivea;
  struct archive_entryentry;
  // ...
  archive_read_open_filename(a, path, 10240);
  while (archive_read_next_header(a, &entry) == ARCHIVE_OK) {
    on_pathname(archive_entry_pathname(entry));
  }
  // ...
}
// JavaScript

function handlePathname(pathname{
  console.log(pathname)
}
const handlePathnamePtr = ??
Module.ccall('extract'null, ['string''number'], [path, handlePathnamePtr])

那么如何传递一个函数指针呢?Emscripten 的 Module 提供了 addFunction 方法,接收一个 JavaScript 函数,为其生成一个函数指针:

const handlePathnamePtr = Module.addFunction(handlePathname)

若要使用 addFunction,需要在编译时添加 -s RESERVED_FUNCTION_POINTERS=20 参数为函数指针预留空间。

详细的代码可以查看 Demo 源码,效果如下:

浏览器中执行 C 语言?WebAssembly 实践

Emscripten 还提供了其他的 C 调用 JavaScript 函数的方式,如可以使用通过宏定义的 JavaScript 代码,由 JavaScript 通过 eval 执行:

#include <emscripten.h>

EM_JS(void, alert, (const char* str), { alert(str) });

int main() {
  alert("hello world")
}

64 位与 JavaScript

上面通过文件系统实现的例子若尝试解压大于 2GB 的文件会报错:

浏览器中执行 C 语言?WebAssembly 实践

报错信息提示与 seek 有关。

查看 Emscripten 中 libc 的 stdio.h (/system/include/libc/stdio.h#L75):

int fseek(FILE *, longint);

fseek 第二个参数 offsetlong 类型,可以看到:

printf("%lu\n"sizeof(long)); // 4

long 长度为 32 位,作为 offset 无法描述超过 2GB 的偏移量。因此若需解析超过 2GB 的文件,则不能使用文件系统。

libarchive 支持自定义读取的方式,那么我们通过之前例子中在 JavaScript 端读取文件存入内存中的方式,自己实现 libarchive 所要求的 archive_read_callback, archive_skip_callback, archive_seek_callback

实现过程中会遇到另一个问题,libarchive 中 skip callback 和 seek callback 的偏移量参数是 64 位整型:

typedef la_int64_t archive_skip_callback(struct archive *, void *_client_data, la_int64_t request);

但由于 JavaScript 中最多只能用 53 位表示整型,在 C 调用 JavaScript 函数时,Emscripten 会将参数中的数字类型以 32 位进行转换,JavaScript 函数接收到的数字并不准确。因此虽然在 C 这一端可以正确表示超过 2GB 的偏移量了,但是 JavaScript 无法获得这个数值。

好在 JavaScript 可以读取 WebAssembly 的内存,C 可以将需要传递的 int64_t 数字的指针作为参数传递给 JavaScript 而不是直接传递数字,接着 JavaScript 根据这个指针从内存中读出 64bit 的内容。由于这里的 offset 不会超过一个文件的大小,实际中使用的文件大小也不会超出 JavaScript 的 53 位的限制,因此我们可以将这 64 bits 安全地解析为一个可以被 JavaScirpt 表示的数字。

Emscripten 使用补码表示负数,使用小端序存储,由此可以确定从内存中存取 64 位整型的算法:

function setInt64(Module, ptr, num{
  let bytes = new Uint8Array(8)
  const BASE = 256
  const isNegative = num < 0
  let n = isNegative ? -num : num
  // 进制转换:除 n 取余,获得每一字节
  for (let i = 0; i < 7; i++) {
    bytes[i] = n % BASE
    n = Math.floor(n / BASE)
  }
  // 负数需要取反加一
  if (isNegative) {
    bytes = bytes.map(x => ~x)
    for (let i = 0; i < 7; i++) {
      bytes[i] += 1
      if (bytes[i] !== 0) {
        break
      }
    }
  }
  Module.HEAPU8.set(bytes, ptr)
  return bytes
}

function getInt64(Module, ptr{
  const bytes = Module.HEAPU8.slice(ptr, ptr + 8)
  const isNegative = bytes[7] === 1
  const sum = bytes
    .map(x => (isNegative ? ~x : x))
    .reduce((sum, x, index) => sum + (x === 0 ? 0 : x * (2 ** 8) ** index), 0)
  return isNegative ? -(sum + 1) : sum
}
// C

// 定义的一个结构体,会被 libarchive 作为 client_data 传递给各个 callback
struct archive_callbacks {
  // JavaScript 中 skipCallback 的函数指针
  void (*skip_callback)(int64_t* request, int64_t* result);
};

// 提供给 libarchive 的 skip callback,用于告诉 reader 需要将偏移量增加多少 byte
int64_t skip_callback(struct archive* a, void* client_data, int64_t request) {
  struct archive_callbacksarchive_callbacks = client_data;
  int64_t result;
  // 申请 int64 大小的空间,用于 JavaScript 填写结果
  int64_t* p_result = malloc(sizeof(int64_t));
  // 将 request 和 result 的地址传递给 JavaScript,JavaScript 直接在内存中读写数字
  (*archive_callbacks->skip_callback)(&request, p_result);
  // JavaScript 将偏移结果写入了之前申请好的地址中
  result = *p_result;
  free(p_result);
  return result;
}
// JavaScript

let offset = 0
function skipCallback(pRequest, pResult{
  const request = getInt64(Module, pRequest)
  const result = offset + request >= file.size ? file.size - offset : request
  offset += result
  setInt64(Module, pResult, result)
}
const skipCallbackPtr = Module.addFunction(skipCallback)

详细代码请查看 Demo 源码。

效果:

浏览器中执行 C 语言?WebAssembly 实践

总结

以上简单介绍了如何使用 Emscripten 将 C 编译为 WebAssembly,以及实践过程中遇到的有关文件系统、内存管理、编译第三方库、64 位值传递等问题。

WebAssembly 已经有了很多应用场景,如在浏览器中运行 DOS 游戏(https://dos.zczc.cz/)、运行 Linux/Windows(http://copy.sh/v86/)、实现高性能的数据组件(https://github.com/finos/perspective) 等等。

未来在浏览器外,WebAssembly 还可以通过 WASI (WebAssembly System Interface) 在他系统环境执行,Docker 创始人也表示「悔创 Docker」服务端 WebAssembly 值得期待。

(点击阅读原文可以查看 demo


往期精彩