浏览器中执行 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
用浏览器访问服务器,即可看到代码执行的结果:
在 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({ initial: 1024, maximum: 2 * 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 中要做的事为:
因为 MD5 值是 128 bits,所以申请 8 Bytes 的空间
p_result
,作为参数调用MD5_Final
;读取
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
加载完成后,可以调用 Module
的 ccall
方法来调用 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.HEAP8
、Module.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 的计算,但却有一个严重的问题:我们一次性整个文件读进了内存中,若文件过大会导致浏览器崩溃。
在实际应用中,我们需要分段地读取文件。
Emscripten FS
Emscripten 提供了一套文件系统,实现了 libc 中的 file I/O。在浏览器环境中,可以使用 Emscripten 的 WORKERFS 文件系统,根据其描述:
This file system provides read-only access to
File
andBlob
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 中查看效果:
这里有一个值得注意的点,在浏览器中使用 FileReader
读取文件明明是异步的操作,为什么这里 calculateMd5Sync
是一个没有 callback、没有 Promise 的同步的代码?
在 emcc 生成的 cutils.js 中可以看到:
var WORKERFS = {
mount: function(mount) {
assert(ENVIRONMENT_IS_WORKER);
if (!WORKERFS.reader) WORKERFS.reader = new FileReaderSync();
//...
},
stream_ops: {
read: function (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)
// ...
}
测试结果:
对比 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 archive* a;
struct archive_entry* entry;
// ...
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 源码,效果如下:
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 的文件会报错:
报错信息提示与 seek 有关。
查看 Emscripten 中 libc 的 stdio.h (/system/include/libc/stdio.h#L75):
int fseek(FILE *, long, int);
fseek
第二个参数 offset
是 long
类型,可以看到:
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_callbacks* archive_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 源码。
效果:
总结
以上简单介绍了如何使用 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)
往期精彩
﹀
﹀
﹀