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 世界带来了新的能量,让我们拭目以待它的未来发展吧。

完。

相关推荐
Boilermaker199240 分钟前
【Java EE】SpringIoC
前端·数据库·spring
中微子1 小时前
JavaScript 防抖与节流:从原理到实践的完整指南
前端·javascript
天天向上10241 小时前
Vue 配置打包后可编辑的变量
前端·javascript·vue.js
芬兰y1 小时前
VUE 带有搜索功能的穿梭框(简单demo)
前端·javascript·vue.js
好果不榨汁1 小时前
qiankun 路由选择不同模式如何书写不同的配置
前端·vue.js
小蜜蜂dry1 小时前
Fetch 笔记
前端·javascript
拾光拾趣录1 小时前
列表分页中的快速翻页竞态问题
前端·javascript
小old弟2 小时前
vue3,你看setup设计详解,也是个人才
前端
Lefan2 小时前
一文了解什么是Dart
前端·flutter·dart
Patrick_Wilson2 小时前
青苔漫染待客迟
前端·设计模式·架构