使用 C++ 构建 WebAssembly:提升前端开发的性能和功能

发布时间 2023-11-09 09:43:43作者: nodejs

最近项目中遇到一个文档解析的场景,目标是在浏览器端能预览markdown文件。

拿到这个需求,相信很多前端同学会想到使用开源的库,比如github上很受欢迎的marked,当然,是一个简单而有效的方案。

但是如果你了解webassembly一点点的话,相信你也会觉得,像这种数据处理的活交给C++来干,没错。

好吧,我们抱着这个猜想开始下面的尝试吧。

搭建环境

为了把C++代码编译成能在浏览器上运行的wasm,我们需要使用 Emscripten
安装Emscripten依赖如下几个工具:Git、CMake、GCC、Python 2.7.x。

编译 Emscripten:

git clone https://github.com/juj/emsdk.git
cd emsdk
./emsdk install sdk-incoming-64bit binaryen-master-64bit
./emsdk activate sdk-incoming-64bit binaryen-master-64bit
source ./emsdk_env.sh

创建项目

推荐如下的目录结构:

.
├── build
├── build.sh
├── include
│   └── sundown
│       ├── autolink.c
│       ├── autolink.h
│       ├── buffer.c
│       ├── buffer.h
│       ├── houdini.h
│       ├── houdini_href_e.c
│       ├── houdini_html_e.c
│       ├── html.c
│       ├── html.h
│       ├── html_blocks.h
│       ├── markdown.c
│       ├── markdown.h
│       ├── stack.c
│       └── stack.h
├── src
│   ├── index.cc
│   └── wasm.c
└── web
    ├── index.html
    ├── index.js
    ├── index.wasm
    └── test.md

这里为了测试,我是直接使用了通过C解析markdown文档开源库sundown。就是目录中的include/sundown。

我们需要一个入口文件,取一个名字wasm.c

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <emscripten/emscripten.h>

#include "markdown.h"
#include "html.h"
#include "buffer.h"

#define READ_UNIT 1024
#define OUTPUT_UNIT 64

const char*
EMSCRIPTEN_KEEPALIVE wasm_markdown(char* source)
{
	struct buf *ib, *ob;
	struct sd_callbacks callbacks;
	struct html_renderopt options;
	struct sd_markdown *markdown;

	ib = bufnew(READ_UNIT);
	bufgrow(ib, READ_UNIT);
	size_t char_len = strlen(source);
	bufput(ib, source, char_len);

	ob = bufnew(OUTPUT_UNIT);
	sdhtml_renderer(&callbacks, &options, 0);
	markdown = sd_markdown_new(0, 16, &callbacks, &options);
	sd_markdown_render(ob, ib->data, ib->size, markdown);
	sd_markdown_free(markdown);

	/* cleanup */
	bufrelease(ib);
	bufrelease(ob);

	return (char *)(ob->data);
}

入口文件是调用lib的方法实现md字符解析,输出html格式的字符。完成编码部分,接下来就可以构建了。

这是我的build脚本:

emcc src/wasm.c \
-O3 \
./include/sundown/markdown.c \
./include/sundown/buffer.c \
./include/sundown/autolink.c \
./include/sundown/html.c \
./include/sundown/houdini_href_e.c \
./include/sundown/houdini_html_e.c \
./include/sundown/stack.c \
-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap", "ccall"]' \
-s TOTAL_MEMORY=67108864 \
-s TOTAL_STACK=31457280 \
-o build/index.js -I./include/sundown \

cp build/index.js build/index.wasm web/

解释下其中的几个参数:

  • EXTRA_EXPORTED_RUNTIME_METHODS,允许js通过cwrap和ccall的方式调用c函数。
  • TOTAL_MEMORY,分配内存大小。TOTAL_MEMORY,分配栈大小。因为md文档比较大,默认的5M不够用。

测试

启动一个web Server,因为webAssembly不支持file协议下加载。

emrun --port 3000 ./web

emrun是Emscriptem自带的webServer工具,你也可以使用你喜欢的。

初始化并调用C接口。

<script src="http://127.0.0.1:3000/markdown.js"></script>
<script>
const wasm_markdown = Module.cwrap('wasm_markdown', 'string', ['string']);
console.log(wasm_markdown('# hello wasm'));
// 输出:<h1>hello wasm</h1>
</script>

多线程

先看DEMO,分析在代码之后。

const mdUrl = 'http://127.0.0.1:3000/markdown.js';

class MarkdownParse {
	isInited = false;
	worker = undefined;
	async init(url) {
		if (this.isInited) {
			return;
		}
		return new Promise(rs => {
			const workerScripts = `
				addEventListener('message', async(e) => {
				  if (e.data == "startWorker") {
				    importScripts("${url}");
				    postMessage({ type: 'init' });
				  } else if (e.data.type === 'parseData') {
				  	await markdown.ready;
				  	const data = markdown.parse(e.data.input);
				  	postMessage({ type: 'parseSuccess', data }); 
				  }
				}, false)`;
			this.worker = new Worker(window.URL.createObjectURL(new Blob([workerScripts])));
			this.worker.addEventListener('message', e => e.data.type === 'init' ? rs() : '');
			this.worker.postMessage("startWorker");
			this.isInited = true;
		})
	}
	async parse(input) {
		if (!this.isInited) {
			await this.init(mdUrl);
		}
		return new Promise(resolve => {
			this.worker.addEventListener('message', 
				e => e.data.type === 'parseSuccess' ? 
					resolve(e.data.data) : null
			);
			this.worker.postMessage({ type: 'parseData', input });
		});
	}
};

(async() => {
	const md = new MarkdownParse();
	// // 触发多次解析
	// const html = [
	// 	await md.parse('# Hello Markdown'),	
	// 	await md.parse('- [ ] Todo1'),
	// 	await md.parse('- [ ] Todo2'),
	// 	await md.parse('- [x] Todo3'),
	// 	await md.parse('> Date.now()'),
	// 	await md.parse('`const a = Date.now();`'),
	// ];
	// document.querySelector('#markdown-body').innerHTML = html.join('');

	md.parse('123');
	const text = await (await fetch('test.md')).text();
	const testJS = () => {
		const a = Date.now();
		// marked 是JS版本的markdown解析库
		marked(text);
		return Date.now() - a;
	};
	const testWasm = async() => {
		const a = Date.now();
		await md.parse(text);
		return Date.now() - a;
	};
	const vs = async() => {
		const result = {
			js_parse_time: testJS(),
			wasm_parse_time: await testWasm(),
		};
		result.speed = result.js_parse_time / result.wasm_parse_time;
		// 显示wasm和JS的解析速度对比
		document.querySelector('#markdown-body').innerHTML = JSON.stringify(result);
	}
	await vs();
	// 输出markdown的HTML
	// document.querySelector('#markdown-body').innerHTML = await md.parse(text);

})();

解析下思路,线抽线一个类 MarkdownParse 来实现wasm的加载和初始化以及api。
默认情况下, web worker是不允许跨域的,但是,有方案的。web worker内部提供了一个importScripts方法来加载非同源的JS。

收获&总结

到此我们完成了今天的构建webassembly应用实例,有如下收获:

  • wasm比JS,效率高出3倍左右
  • 通过web worker去处理wasm的加载,初始化,数据计算处理等,不会占用浏览器的主线程

总结,本文可能只是一个很小的场景,而且单从效率这点来看,JS的200ms对比wasm的50ms,其实对于前端来说,并没有特别惊艳的优势。BUT,这只是一个开始,wasm对前端带来的性能提升会百花齐放,我们拭目以待吧~