WebAssembly 概念
WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.
翻译下就是:
WebAssembly(缩写为 Wasm)是一种基于堆栈的虚拟机的二进制指令格式。 Wasm 被设计为编程语言的可移植编译目标,支持在网络上部署客户端和服务器应用程序。
概念直接看可能有点懵。我们单独理解下里面的关键词。
Binary instruction format
二进制指令格式,用于表示 wasm
的文件格式。二进制表示的好处是无需做解析,对比就是 JS 解释执行的过程:
- 词法分析:分词 token
- 语法分析:解析成 AST(抽象语法树),以理解代码的结构
- 解释执行:解释器从语法树的根节点开始执行代码。执行过程中针对热点代码,编译成字节码(中间产物),再进行 JIT(即时编译)编译成机器码,以便提升热点代码的执行性能
二进制是固定指令集的表示,无需做词法分析和语法分析,直接在 wasm 虚拟机上解释执行。
这里也能解释 "贴近硬件速度",多了一层 wasm 虚拟机,虽然执行很快速,但是和 native 应用,例如 C/C++ 编译出的可执行文件或使用汇编语言编写的直接操控硬件的程序,相比还是慢一些的。
Stack-based virtual machine
在基于栈的虚拟机中,计算和操作的过程主要依赖于栈数据结构。指令通常包括将数据压入栈、从栈中弹出数据并执行操作,然后将结果再次压入栈中的操作。这种模型简单而高效,适用于许多不同类型的计算。
虚拟机的诞生多用于跨平台、安全受限等场景。例如 JVM(for Java)
、V8(for JavaScript)
等。 总之,Stack-based virtual machine
是 WebAssembly 采用的虚拟机类型,其中计算和操作依赖于栈数据结构,用于高效执行WebAssembly的二进制指令。这种模型在Web浏览器中实现了高性能和安全的代码执行。
Portable compilation target
可移植性是 wasm 重要的特性之一。可移植性指的是代码可以在不同的平台和环境中无需修改即可运行。wasm 程序可以在任何支持Wasm标准的环境中运行,而无需对其进行修改或重新编译。
打个比方,C++ 代码编译出的产物就做不到,其产物直接对应在硬件执行的机器码,不同的平台和硬件需要重新编译、链接。 wasm 最初是设计用于在Web浏览器中执行的,但随着时间的推移,它已经开始在许多其他领域和环境中广泛使用。例如: Node.js、物联网的嵌入式系统、云计算等。
实战:基于 C++ 构建 wasm 程序
为何选择 C++:
- 生态:软件世界里的基石,大多数语言和应用都基于其进行开发,有庞大的生态,便于我们复用一些开源软件
- 稳定:相对于 Rust 其发版频率更低
- 有一定的基础:科班出生的同学都有学习过
编写一个斐波那契函数
以下为使用 c++ 编写的一段代码:
cpp
#include <iostream>
// 递归方式计算斐波那契数列的第n个数
int fibonacci(int n)
{
if (n <= 1)
return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
int main()
{
int n;
std::cout << "请输入斐波那契数列的项数:";
std::cin >> n;
std::cout << fibonacci(n);
return 0;
}
使用 g++
进行编译,后执行:
bash
g++ fib.cpp -o fib && ./fib
需要你输入一个项数,然后 fibonacci
函数执行,后输出结果:
bash
请输入斐波那契数列的项数:20
6765
将 C++ 代码编译成 wasm
以上示例是使用 g++ 编译器,将 C++ 代码编译成本机的可执行文件。 编译成 wasm 我们需要借助另一个编译器 emscripten。
Emscripten 概念
Emscripten 是一个完整的 WebAssembly 编译器工具链,使用 LLVM,特别关注速度、大小和 Web 平台。
这里又引入了一个新概念:LLVM。LLVM(Low-Level Virtual Machine)
是一个开源的编译器基础设施项目,它提供了一组模块化的编译器和工具,用于构建、优化和执行程序。LLVM 最初是为编译器开发而设计的,但它已经演变成一个通用的编译器基础设施,可以用于多种编程语言和领域。
LLVM 的主要组件包括:
- 前端:LLVM 支持多种编程语言的前端,包括 C/C++、Rust、Swift、Python 等。这些前端将源代码转换成一种称为
LLVM IR(Intermediate Representation)
的中间代码。 - 中间表示(LLVM IR):LLVM IR 是一种类似汇编语言的中间代码,它是 LLVM 的核心。所有支持的编程语言都被翻译成 LLVM IR,这样可以在后续的优化阶段进行通用的编译器优化。
- 优化器:LLVM 包含强大的优化器,它可以在不改变程序语义的情况下提高代码的性能。这些优化包括死代码删除、内联函数、循环优化等。类似 JS 构建领域的代码压缩器。
- 后端:LLVM 还包括用于不同目标架构的后端代码生成器。这意味着你可以使用 LLVM 来生成针对不同硬件架构的机器码,从而实现跨平台的编译。
- 工具:LLVM 提供了许多辅助工具,用于调试、分析和测试代码。
对于软件工程师来说,LLVM 在编译器开发、代码优化和跨平台编译方面具有重要意义。它还被广泛用于编程语言的实现和各种编译器项目中。
Emscripten 安装
安装地址:emscripten.org/docs/gettin...。 官网有多种安装方式,这里我们使用推荐的 emsdk
。
bash
# Get the emsdk repo
git clone https://github.com/emscripten-core/emsdk.git
# Enter that directory
cd emsdk
# Download and install the latest SDK tools.
./emsdk install latest
# Make the "latest" SDK "active" for the current user. (writes .emscripten file)
./emsdk activate latest
# Activate PATH and other environment variables in the current terminal
# 对于 Mac 这一步可以放在 .bashrc 或 .zshrc 文件中,便于新创建的 terminal 也能及时生效
source ./emsdk_env.sh
在浏览器跑起来
使用如下命令将 fib.cpp 构建成 js 代码,wasm 代码使用 base64 内联。
shell
em++ fib.cpp -o fib.js -sEXPORTED_FUNCTIONS=_fibonacci -sEXPORT_NAME="fib" -sMODULARIZE=1 -sENVIRONMENT=web -sSINGLE_FILE=1
各参数的含义:
-o
构建产物名称-sXXX
构建参数,XXX
代表参数名称,格式为key=value
的形式EXPORTED_FUNCTIONS
为导出的函数,会在胶水打码中体现 `EXPORT_NAME
为模块名称MODULARIZE
是否模块化的形式导出ENVIRONMENT
可以制定构建产物运行环境,可以减少一部分代码SINGLE_FILE
仅保留js
代码,去除中间产物.wasm
然而,当我们跑上述命令后,会出错:
shell
em++: error: undefined exported symbol: "_fibonacci" [-Wundefined] [-Werror]
这是因为 C++ 经过编译后,函数名称会被混淆,要保留原有的函数名,需要使用到 extern "C"
。其多用于 C、C++ 的互相调用时存在。这个回答解释的很好:stackoverflow.com/questions/1... 基于此修改后的代码如下:
cpp
#include <iostream>
extern "C"
{
int fibonacci(int n)
{
if (n <= 1)
return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
}
使用上述构建命令即可成功在同目录下生成 fib.js 文件。
继续编写 HTML 示例,在浏览器里跑一下,关键代码:
html
<script src="fib.js"></script>
<script>
window.Module = {
print: console.log,
}
fib(window.Module);
</script>
控制台运行正常:
引入外部模块
经过上述章节,我们成功在浏览器里跑起来了 wasm。但是实际场景中,我们大概率需要用到一些三方模块,在前端生态里,我们有 npm 来管理模块,在 C++ 里,可能有一些难度。这里我们以一个需求入手:根据用户选择的文件生成 MD5 (内容摘要算法),一步步引入支持 MD5 算法的 C++ 模块。
这里我们使用此 C++ 实现:github.com/JieweiWei/m...。我们 copy md5.cpp、md5.h 文件到工作区的 include 文件里,如下:
使用 g++ 编译执行
新建入口逻辑,对 md5 进行调用:
cpp
#include "include/md5.h"
#include <string>
#include <iostream>
using namespace std;
uint8_t *md5(uint8_t *ptr)
{
string str = "";
// 通过循环遍历指针,直到遇到null终止符
while (*ptr != '\0')
{
str += *ptr;
// 移动指针到下一个字符
++ptr;
}
cout << "input: " << str << endl;
MD5 md5Str(str);
string md5Result = md5Str.toStr();
cout << "output: " << md5Result << endl;
// 动态分配内存并复制数据
uint8_t *result = new uint8_t[md5Result.size()];
std::memcpy(result, md5Result.c_str(), md5Result.size());
return result;
}
int main(int argc, char const *argv[])
{
const char *str = "Hello, World!";
uint8_t *out = md5((uint8_t *)str);
cout << "pointer: " << &out << endl;
return 0;
}
以上代码做的事情很简单:
- main 函数作为入口
- md5 函数输入字符串,输出 md5 后的字符串指针
- 打印过程中的输入、输出
运行命令如下:
shell
g++ -std=c++17 -Wall -Iinclude include/md5.cpp md5-demo.cpp -o md5-demo && ./md5-demo
输出:
shell
input: Hello, World!
output: 65a8e27d8879283831b664bd8b7f0ad4
pointer: 0x7ff7bf72aec0
上述命令参数的含义:
- -std=c17 标识使用的标准库版本
- -Wall 启用 warning 信息,发现潜在问题
- -Iinclude 告知编译器,在指定目录查找文件,当前表示为 include 目录
- include/md5.cpp md5-demo.cpp 都为输入文件
- -o md5-demo 为输出的可执行文件名
使用 em++ 编译为 wasm
如下命令:
shell
em++ -std=c++17 -Wall -Iinclude include/md5.cpp md5-demo.cpp -o md5-demo.js -sEXPORTED_FUNCTIONS=_md5 -sEXPORT_NAME="md5" -sMODULARIZE=1 -sENVIRONMENT=web -sSINGLE_FILE=1
参数没有任何变化,em++
g++
编译工具可以无缝切换。
JS 与 WASM 通信
上小节当中,我们将使用了三方模块的 c++ 代码,编译为了 wasm,但是有个关键问题没解决:如何将 JS 与 wasm 当中的数据互传?本节将介绍具体实现。
wasm 可以理解成独立工作的 Worker 线程,但是 JS 的线程模型和 Java 这种多线程模型的语言不同,JS 内创建的一些对象、字符串等都存在于 V8 等虚拟机的堆内存里,外部线程无法直接操作。而 Java 为代表的多线程语言,线程共享同一块内存区域,可以任意读写(但是存在并发问题)。
斐波那契函数的示例,不存在此问题,因为 wasm 虚拟机本身就支持 int 32/int64/float 等整数、浮点数数据类型的传递。
字符串的互传
对于想要对数据进行 md5 的诉求,我们需要将数据保存在 ArrayBuffer 的堆外内存当中,传递数据指针(整数),然后在 wasm 中读取。 构建命令:
shell
em++ -std=c++17 -Wall -Iinclude include/md5.cpp md5-demo.cpp -o md5-demo.js -sEXPORTED_FUNCTIONS=_md5,_malloc,_free -sEXPORT_NAME="md5" -sMODULARIZE=1 -sENVIRONMENT=web -sSINGLE_FILE=1 -sEXPORTED_RUNTIME_METHODS=stringToUTF8,UTF8ToString,writeArrayToMemory,lengthBytesUTF8
增加了一些配置:
-sEXPORTED_FUNCTIONS=_md5,_malloc,_free
malloc 和 free 分别用于分配和释放内存-sEXPORTED_RUNTIME_METHODS
用于使用一些 JS 胶水代码
html 新增如下代码:
html
<script src="md5-demo.js"></script>
<script>
window.Module = {
print: console.log,
};
md5(window.Module);
Module.ready.then(() => {
const writeString = (str) => {
const length = Module.lengthBytesUTF8(str);
// 分配多一个字符,存放 0 控制字符
const ptr = Module._malloc(length + 1);
Module.stringToUTF8(str, ptr, length + 1);
return ptr;
};
const ptr = writeString('yangcong');
const md5Ptr = Module._md5(ptr);
const md5Str = Module.UTF8ToString(md5Ptr);
console.table({ ptr, md5Ptr, md5Str });
// 使用完毕,记得回收指针数据
Module._free(ptr);
Module._free(md5Ptr);
});
</script>
浏览器控制台输出: 可以看到我们的代码正常运行,JS 拿到的数据也和 C++ 里的输出一致。 需要解释下以上代码的关键流程:
- JS 和 wasm 函数调用输入输出都为指针,即 int 类型数据
- JS 里将字符串转为 UTF8,在 wasm 分配对应长度的数据,并将字符使用 usigned int 8 写入到 wasm 中的 ArrayBuffer 里
- wasm 里读取指针对应的字符串,转换为 md5 字符串,并将对应的指针输出到 JS
- JS 拿到指针后,读取 wasm 中的 ArrayBuffer 的数据,转换为 UTF8 字符串
ArrayBuffer 的互传
可以看到,上述流程当中,最关键的就 2 个核心要素:
- JS 与 wasm 互传的数据只能是整数、浮点数
- wasm 有暴露共享的 ArrayBuffer 供 JS 操作,以达到互传数据的目的
所以,对于 ArrayBuffer 互传,当然也是非常类似的,能满足文件 md5 的诉求。
javascript
Module.ready.then(async () => {
const writeBuffer = (buffer) => {
// 分配多一个字符,存放 0 控制字符
const ptr = Module._malloc(buffer.length + 1);
Module.writeArrayToMemory(buffer, ptr);
return ptr;
};
// mock 一个文件
const blob = new Blob(['yangcong']);
const buffer = await blob.arrayBuffer();
const ptr = writeBuffer(new Uint8Array(buffer));
const md5Ptr = Module._md5(ptr);
const md5Str = Module.UTF8ToString(md5Ptr);
console.table({ ptr, md5Ptr, md5Str });
// 使用完毕,记得回收指针数据
Module._free(ptr);
Module._free(md5Ptr);
});
writeArrayToMemory
本身逻辑非常简单,即将 TypedArray 写入到 wasm 共享的 ArrayBuffer 数据中。
大文件的性能对比
采用纯 js 实现的 md5 算法与 wasm 做性能对比。 具体实现:
- js 实现使用 js-md5
- wasm 实现继续采用上述的算法
- 通过 benchmark.js 做批量的操作性能对比
完整代码如下:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="md5-demo.js"></script>
<script
src="https://registry.npmmirror.com/lodash/4.17.21/files/lodash.js"
crossorigin="anonymous"
></script>
<script
src="https://registry.npmmirror.com/benchmark/2.1.2/files/benchmark.js"
crossorigin="anonymous"
></script>
<script
src="https://registry.npmmirror.com/js-md5/0.8.3/files/src/md5.js"
crossorigin="anonymous"
></script>
<script>
window.Module = {
print: console.log,
};
md5wasm(window.Module);
Module.ready.then(async () => {
const writeBuffer = (buffer) => {
// 分配多一个字符,存放 0 控制字符
const ptr = Module._malloc(buffer.length + 1);
Module.writeArrayToMemory(buffer, ptr);
return ptr;
};
// mock 一个文件,8M
const blob = new Blob(['yangcong'.repeat(1024 * 1024)]);
const buffer = await blob.arrayBuffer();
const suite = new Benchmark.Suite();
suite.add('wasm', () => {
const ptr = writeBuffer(new Uint8Array(buffer));
const md5Ptr = Module._md5(ptr);
const md5Str = Module.UTF8ToString(md5Ptr);
// 使用完毕,记得回收指针数据
Module._free(ptr);
Module._free(md5Ptr);
});
suite.add('js', () => {
md5(buffer);
});
suite.on('cycle', function (event) {
console.log(String(event.target));
});
suite.run();
});
</script>
</body>
</html>
但是,当我们在浏览器里运行时,会展示如下错误:
这个错误信息表明 wasm 试图分配一个超出内存限制的数组大小,导致内存耗尽(Out of Memory,OOM)错误。报错上显示 16M OOM,但是我分配的数据大概为 8M,排查下来原因是 c++ 里把 char* 转为 string 类会开辟一块新的内存,导致双倍内存,所以 OOM 了。这个实现不合理,所以我们引入新的 md5 实现,入参最好是 char* 指针类数据,内部读取即可。
但是大文件测试下来性能甚至还不如纯 JS 的 md5 实现,有了解的同学欢迎讨论。
使用 Makefile 简化构建流程
真实的 wasm 项目上可能会存在非常多的文件,都手动一个个敲命令太烦了,Makefile 能帮助我们简化构建流程,且能增量构建,未变更的文件二次构建时会直接跳过。
Makefile 本身非常简单,最关键的格式就是:
makefile
target:deps1 deps2 ...
shell command
# 伪目标,直接执行,不需要关心是否存在目标文件
.PHONY: deps1 ...
入门可以查看:阮一峰的 blog
我们的 Makefile 可以这样写:
makefile
# 设置编译器和编译选项
CC := emcc
CXX := em++
CFLAGS := -std=c99 -Wall -Iinclude
CXXFLAGS := -std=c++17 -Wall -Iinclude
# 源码目录
SRC_DIR := .
# 获取所有的 .cpp 文件
SRCS_CPP := $(wildcard $(SRC_DIR)/*.cpp)
# 获取 include 目录下的 .c 文件
INCLUDE_C := $(wildcard include/*.c)
# 获取 include 目录下的 .cpp 文件
INCLUDE_CPP := $(wildcard include/*.cpp)
# 生成的目标文件和可执行文件
OBJS_C := $(INCLUDE_C:.c=.o)
OBJS_CPP := $(SRCS_CPP:.cpp=.o) $(INCLUDE_CPP:.cpp=.o)
TARGET := md5-demo
# 缺省的构建目标
build: $(TARGET)
# 生成可执行文件
# $(TARGET): $(INCLUDE_C) $(INCLUDE_CPP) $(SRCS_CPP)
$(TARGET): $(OBJS_C) $(OBJS_CPP)
# $(TARGET): $(SRCS_CPP)
$(CXX) $(CXXFLAGS) $^ -o $(TARGET).js -sEXPORTED_FUNCTIONS=_md5,_malloc,_free -sEXPORT_NAME="md5wasm" -sMODULARIZE=1 -sENVIRONMENT=web -sSINGLE_FILE=1 -sEXPORTED_RUNTIME_METHODS=stringToUTF8,UTF8ToString,writeArrayToMemory,lengthBytesUTF8
# 生成 C 目标文件
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# 生成 C++ 目标文件
%.o: %.cpp
$(CXX) $(CXXFLAGS) -c $< -o $@
# 清理中间文件和可执行文件
clean:
rm -rf $(wildcard */*.o) $(TARGET).js
# 由于 all clean 不是真正的文件,所以需要声明为伪目标
# 否则,如果当前目录下存在 all clean 这样的文件,make 将不会执行命令
.PHONY: build clean
$ make
构建效果: 解释下输出的结果:
- 首次没有依赖项,全量构建
- 二次发下依赖项都有了,跳过构建
- build 是伪目标,直接构建
总结
以上,介绍了 wasm 基本概念,并通过实战构建了第一个斐波那契函数的 wasm 模块,之后深入了如何引入外部模块,如何 JS 与 wasm 进行通信,以及最终的使用 Makefile 简化构建流程。
以上只是进入 wasm 世界的第一步,一个新领域不仅仅需要了解可用的编程语言,更需要了解其运行的平台能力。wasm 为 Web 世界带来了新的能量,让我们拭目以待它的未来发展吧。
完。