WebAssembly 入门 - 从零构建生产环境应用

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 解释执行的过程:

  1. 词法分析:分词 token
  2. 语法分析:解析成 AST(抽象语法树),以理解代码的结构
  3. 解释执行:解释器从语法树的根节点开始执行代码。执行过程中针对热点代码,编译成字节码(中间产物),再进行 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++:

  1. 生态:软件世界里的基石,大多数语言和应用都基于其进行开发,有庞大的生态,便于我们复用一些开源软件
  2. 稳定:相对于 Rust 其发版频率更低
  3. 有一定的基础:科班出生的同学都有学习过

编写一个斐波那契函数

以下为使用 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 的主要组件包括:

  1. 前端:LLVM 支持多种编程语言的前端,包括 C/C++、Rust、Swift、Python 等。这些前端将源代码转换成一种称为 LLVM IR(Intermediate Representation) 的中间代码。
  2. 中间表示(LLVM IR):LLVM IR 是一种类似汇编语言的中间代码,它是 LLVM 的核心。所有支持的编程语言都被翻译成 LLVM IR,这样可以在后续的优化阶段进行通用的编译器优化。
  3. 优化器:LLVM 包含强大的优化器,它可以在不改变程序语义的情况下提高代码的性能。这些优化包括死代码删除、内联函数、循环优化等。类似 JS 构建领域的代码压缩器。
  4. 后端:LLVM 还包括用于不同目标架构的后端代码生成器。这意味着你可以使用 LLVM 来生成针对不同硬件架构的机器码,从而实现跨平台的编译。
  5. 工具: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;
}

以上代码做的事情很简单:

  1. main 函数作为入口
  2. md5 函数输入字符串,输出 md5 后的字符串指针
  3. 打印过程中的输入、输出

运行命令如下:

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,_freemalloc 和 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++ 里的输出一致。 需要解释下以上代码的关键流程:

  1. JS 和 wasm 函数调用输入输出都为指针,即 int 类型数据
  2. JS 里将字符串转为 UTF8,在 wasm 分配对应长度的数据,并将字符使用 usigned int 8 写入到 wasm 中的 ArrayBuffer 里
  3. wasm 里读取指针对应的字符串,转换为 md5 字符串,并将对应的指针输出到 JS
  4. JS 拿到指针后,读取 wasm 中的 ArrayBuffer 的数据,转换为 UTF8 字符串

ArrayBuffer 的互传

可以看到,上述流程当中,最关键的就 2 个核心要素:

  1. JS 与 wasm 互传的数据只能是整数、浮点数
  2. 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 做性能对比。 具体实现:

  1. js 实现使用 js-md5
  2. wasm 实现继续采用上述的算法
  3. 通过 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 构建效果: 解释下输出的结果:

  1. 首次没有依赖项,全量构建
  2. 二次发下依赖项都有了,跳过构建
  3. build 是伪目标,直接构建

总结

以上,介绍了 wasm 基本概念,并通过实战构建了第一个斐波那契函数的 wasm 模块,之后深入了如何引入外部模块,如何 JS 与 wasm 进行通信,以及最终的使用 Makefile 简化构建流程。

以上只是进入 wasm 世界的第一步,一个新领域不仅仅需要了解可用的编程语言,更需要了解其运行的平台能力。wasm 为 Web 世界带来了新的能量,让我们拭目以待它的未来发展吧。

完。

相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang2 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、4 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui