写点什么

基于 IM 场景下的 Wasm 初探:提升 Web 应用性能|得物技术

作者:得物技术
  • 2024-11-05
    上海
  • 本文字数:6161 字

    阅读完需:约 20 分钟

基于IM场景下的Wasm初探:提升Web应用性能|得物技术

一、何为 Wasm ?

Wasm,全称 WebAssembly,官网描述是一种用于基于堆栈的虚拟机的二进制指令格式。Wasm 被设计为一个可移植的目标,用于编译 C/C++/Rust 等高级语言,支持在 Web 上部署客户端和服务器应用程序。


Wasm 的开发者参考文档:https://developer.mozilla.org/en-US/docs/WebAssembly


简单的来说就是使用 C/C++/Rust 等语言编写的代码,经过编译后得到汇编指令,再通过 JavaScript 相关 API 将文件加载到 Web 容器中,一句话解释就是运行在 Web 容器中的汇编代码。Wasm 是一种可移植、体积小、加载快速的二进制格式,可以将各种编程语言的代码编译成 Wasm 模块,这些模块可以在现代浏览器中直接运行。尤其在涉及到 GPU 或 CPU 计算时优势相对比较明显。

二、为什么需要 Wasm ?

JavaScript 是解释型语言,相比于编译型语言需要在运行时转换,所以解释型语言的执行速度要慢于编译型语言。


编译型语言和解释型语言代码执行的大致流程如下:



如上流程图所示,解释型语言每次执行都需要把源码转换一次才能执行,而转换过程非常耗费时间和性能,所以在 JavaScript 背景下,Web 执行一些高性能应用是非常困难的,如视频剪辑、3D 游戏等。


Wasm 具有紧凑的二进制格式,可以接近原生的性能运行,并为 C/C++等语言提供一个编译目标,以便它们可以在 Web 上运行。被设计为可以与 JavaScript 共存,允许两者一起工作。在特定的业务场景下可以完美的弥补 JavaScript 的缺陷。

三、优势和限制

优势:


  • 性能优异:相比 JavaScript 代码,Wasm 使用节省内存,快速加载和解释的二进制代码,具备更快执行速度,它是直接在底层虚拟机中运行的。这使得 Web 应用程序可以更高效地处理复杂的计算任务,例如图形渲染、物理模拟等。

  • 跨平台兼容:Wasm 可以在几乎所有现代浏览器中运行,兼容性可参考 caniuse,无论是桌面还是移动设备。这意味着开发者可以使用各种编程语言来编写 Web 应用程序,而不仅仅局限于 JavaScript。

  • 安全性:Wasm 运行在沙箱环境中,提供了良好的安全性。使用了一系列安全措施,如内存隔离和沙箱限制,以防止恶意代码对系统的攻击。

  • 模块化:Wasm 模块可以作为独立的组件进行开发和部署,开发者可以更好地管理和维护代码库。模块化的设计也为将来的性能优化和增量更新提供了便利。


局限性:


  • 生态系统不够完善:尽管 Wasm 已经成为 Web 开发中的关键技术之一,但生态系统仍然不够完善。Wasm 的工具、框架和库的数量远不如 JavaScript。

  • 开发门槛较高:Wasm 的开发门槛相对较高。Wasm 需要使用一种新的语言来编写,如 C 或 C++等。这使得学习和使用 Wasm 的成本相对较高。尤其是在内存管理等方面会增加开发的复杂性。

  • 与 JavaScript 集成问题:Wasm 与 JavaScript 之间的集成问题是一个挑战。开发人员需要解决如何在 Web 应用程序中同时使用 Wasm 和 JavaScript 的问题。

  • 兼容性问题:虽然现代浏览器已经开始支持 Wasm,但是在一些老旧的浏览器中可能存在兼容性问题,需要开发者进行额外的处理来确保代码的兼容性。

四、Wasm 工作原理

通过上述的编译型语言和解释型语言代码执行的大致流程我们可以知道 Wasm 是不需要被解释的,是由开发者提前编译为 WebAssembly 二进制格式,如下图所示。由于变量类型都是预知的,因此浏览器加载 WebAssembly 文件时,JavaScript 引擎无须监测代码。它可以简单地将这段代码的二进制格式编译为机器码。



从这个流程中我们也可以看出,如果将每种编程语言都直接编译为机器码的各个版本,这样效率是不是更高呢?想法是好的,但实现过程确实复杂不堪的。由于浏览器是可以在若干不同的处理器 (比如手机和平板等设备) 上运行,因此为每个可能的处理器发布一个 WebAssembly 代码的编译后版本会很难做到。


我们可以通过替代方法即取得 IR 代码。IR 即为中间代码(Intermediate Representation),它是编译器中很重要的一种数据结构。编译器在做完前端工作以后,首先就生成 IR,并在此基础上执行各种优化算法,最后再生成目标代码。可以简化为如下流程:



编译器将 IR 代码转换为一种专用字节码并放入后缀为.wasm 的文件中。此时 Wasm 文件中的字节码还不是机器码,它只是支持 WebAssembly 的浏览器能够理解的一组虚拟指令。当加载到支持 WebAssembly 的浏览器中时,浏览器会验证这个文件的合法性,然后这些字节码会继续编译为浏览器所运行的设备上的机器码。


更加详情的原理和使用方式可以前往https://developer.mozilla.org/en-US/docs/WebAssembly/JavaScript_interface查阅。


五、应用场景


在 Web 开发中,可以使用 Wasm 来提高应用程序的性能。以下是一些使用 Wasm 的常见场景:


  • 高性能计算:如果应用程序需要进行大量的数值计算、图像处理或者复杂的算法运算,可以将这部分代码编译成 Wasm 模块,以提高计算性能。

  • 游戏开发:Wasm 可以用于创建高性能的 HTML5 游戏,通过将游戏逻辑编译成 Wasm 模块,可以实现更流畅的游戏体验。

  • 跨平台应用:使用 Wasm 可以实现跨平台的应用程序,无论是桌面还是移动设备,用户都可以通过浏览器来访问和使用。

  • 移植现有代码:如果已经有用其他编程语言编写的代码,可以通过将其编译成 Wasm 模块,将其集成到现有的 Web 应用程序中,而无需重写整个应用程序。

六、产品案例

  • 设计工具 Figma-Wasm 文件大小为 27.7M



  • Google Earth-Wasm 文件总计大小为 192.M

  • 支持各大浏览器的 3D 地图,而且运行流畅



  • B 站-视频处理和播放也有使用 Wasm,Wasm 文件大小为 344kb



  • 跨平台的 OpenGL 图形引擎 Magnum-Wasm 文件大小为 844kb

七、实践案例

这里我们通过使用 Rust + Wasm 实现 Wasm 与 JavaScript 之间的数据调用,理解 Rust 和 Wasm 的交互过程。


使用 Rust 就需要做一些前置的环境配置,详情的步骤可参考 Rust 官网:


https://www.rust-lang.org/zh-CN/tools/install


安装 wasm-pack,wasm-pack 是一个构建、测试和发布 Wasm 的 Rust CLI 工具,我们将使用 wasm-pack 相关的命令来构建 Wasm 二进制内容。这有助于将代码编译为 WebAssembly,并生成在浏览器中使用的正确包。

Rust 项目初始化

执行 cargo new rust_wasm 初始化 Rust 项目,自动生成配置文件 Cargo.toml,项目结构如下:


/Users/admin/RustroverProjects/rust_wasm├── Cargo.lock├── Cargo.toml├── src|  └── lib.rs└── target   ├── CACHEDIR.TAG   └── debug      ├── build      ├── deps      ├── examples      └── incremental
复制代码

配置包文件

我们可以在 Cargo.toml 文件中加上下列代码并保存,保存之后 Cargo 会自动下载依赖。


  • crate-type = ["cdylib"],表示编译时候使用 C 标准的动态库。

  • #[wasm_bindgen]是一个属性宏,来自于 wasm_bindgen 这个 crate,是一个简化 Rust WASM 与 JS 之间交互的库。


[lib]crate-type = ["cdylib"]
[dependencies]wasm-bindgen = { version = "0.2.89", features = [] }
复制代码

编写代码

编写代码之前我们先明确 Rust 中 crate 包的概念,Rust 中包管理系统将 crate 包分为二进制包(Binary)和库包(Library)两种,二者可以在同一个项目中同时存在。


二进制包:


  • main.rs 是二进制项目的入口

  • 二进制项目可直接执行

  • 一个项目中二进制包可以有多个,所以在 Cargo.toml 中通过双方括号标识 [[bin]]


库包:


  • lib.rs 是库包的入口

  • 库项目不可直接执行,通常用来作为一个模块被其他项目引用

  • 一个项目中库包仅有 1 个,在 Cargo.toml 中通过单方括号标识 [lib]


因为我们这里希望将 Wasm 转为一个可以在 JS 项目中使用的模块,所以需要使用库包 lib.rs 的命名,代码如下。


use wasm_bindgen::prelude::*;#[wasm_bindgen]pub extern "C" fn rust_add(left: i32, right: i32) -> i32 {    println!("Hello from Rust!");    left + right}
复制代码

执行编译

这里我们要使用到 wasm-pack,将上述的 Rust 代码编译为能够被 JS 导入的模块,根据 wasm-pack 提供的 target 方式可以指定构建的产物,如截图所示:



编译过程效果:视频见得物技术公众号


编译完成后,我们会发现根目录下多了一个 pkg/ 文件夹,里面就是我们的 Wasm 产物所在的 npm 包了。目录结构如下:


/Users/admin/RustroverProjects/rust_wasm/pkg├── package.json├── rust_wasm.d.ts├── rust_wasm.js├── rust_wasm_bg.wasm└── rust_wasm_bg.wasm.d.ts
复制代码


rust_wasm.d.ts 文件内容:


/* tslint:disable *//* eslint-disable *//*** @param {number} num* @returns {string}*/export function msg_insert(num: number): string;/*** @param {number} left* @param {number} right* @returns {number}*/export function rust_add(left: number, right: number): number;/***/export function rust_thread(): void;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput { readonly memory: WebAssembly.Memory; readonly msg_insert: (a: number, b: number) => void; readonly rust_add: (a: number, b: number) => number; readonly rust_thread: () => void; readonly __wbindgen_add_to_stack_pointer: (a: number) => number; readonly __wbindgen_free: (a: number, b: number, c: number) => void;}
export type SyncInitInput = BufferSource | WebAssembly.Module;/*** Instantiates the given `module`, which can either be bytes or* a precompiled `WebAssembly.Module`.** @param {SyncInitInput} module** @returns {InitOutput}*/export function initSync(module: SyncInitInput): InitOutput;
/*** If `module_or_path` is {RequestInfo} or {URL}, makes a request and* for everything else, calls `WebAssembly.instantiate` directly.** @param {InitInput | Promise<InitInput>} module_or_path** @returns {Promise<InitOutput>}*/export default function __wbg_init (module_or_path?: InitInput | Promise<InitInput>): Promise<InitOutput>;
复制代码


wasm-pack 打包不仅输出一个 ESM 规范的模块,而且还支持自动生成 d.ts 文件,对模块的使用者非常友好。如下:


在前端项目中引入使用

'use client'/* * @Author: wangweiqiang * @Date: 2024-06-18 17:03:34 * @LastEditors: wangweiqiang * @LastEditTime: 2024-06-18 23:09:55 * @Description: app.tsx */import Image from "next/image";import { useCallback, useEffect, useState } from "react";import init, * as rustLibrary from 'rust_wasm'export default function Home() {  const [addResult, setAddResult] = useState<number | null>(null)  const [calculateTime, setCalculateTime] = useState<string>('')
const initRustLibrary = useCallback(() => { init().then(() => { const result = rustLibrary.rust_add(5, 6) const timeStamp = rustLibrary.msg_insert(50000) setCalculateTime(timeStamp) setAddResult(result) }) }, [])
useEffect(() => { initRustLibrary() }, [initRustLibrary]);
return ( <main className="flex min-h-screen flex-col items-center p-24"> {/* .... */} <div className="mt-32 grid text-center lg:mb-0 lg:w-full lg:max-w-5xl lg:grid-cols-4 lg:text-left"> <div> rust代码计算结果:{addResult} </div> <div style={{ marginTop: '20px' }}> 二分法方式{calculateTime} </div> </div> </main> );}
复制代码


性能比较

在 IM 场景下,聊天消息中核心的处理流程在于数据的排序、去重,大量的数据查找会非常耗时,在这里我们通过二分法的方式对 Rust 和 JavaScript 两种实现方式的耗时进行一个简单的对比,Rust 代码如下:


use chrono::{DateTime, Utc};use rand::Rng;
#[derive()]#[allow(dead_code)]struct Data { content: String, from: String, head: String, msg_id: String, seq: i32, sid: String, topic: String, ts: DateTime<Utc>,}
impl Data { fn new( content: String, from: String, head: String, msg_id: &str, seq: i32, sid: String, topic: String, ts: DateTime<Utc>, ) -> Self { Data { content, from, head, msg_id: msg_id.to_string(), seq, sid, topic, ts, } }}
// 获取原始数据fn get_origin_data(num: i32) -> Vec<Data> { let mut data: Vec<Data> = vec![]; // 存储数据的向量 .... // 创建 num 个数据 data}// 初始化结构体数据fn init_struct_data(num: i32, text: &str) -> Data { let mut rng = rand::thread_rng(); let content = format!("{}_{}", rng.gen_range(1000..=9999), text).to_string(); .... let ts = Utc::now(); Data::new(content, from, head, &msg_id.as_str(), seq, sid, topic, ts)}
// 二分法插入fn binary_insert(data: &mut Vec<Data>, new_data: Data) { let _insert_pos = match data.binary_search_by_key(&new_data.seq, |d| d.seq) { Ok(pos) => { data[pos] = new_data; pos } Err(pos) => { data.insert(pos, new_data); pos } };}#[wasm_bindgen]pub extern "C" fn msg_insert(num: i32) -> String { let mut data: Vec<Data> = get_origin_data(1000); let test_mode = [num]; let start_time = Utc::now().naive_utc().timestamp_micros(); for test_num in 0..test_mode.len() { for num in 0..test_mode[test_num] { let data_list = init_struct_data(num, "test"); binary_insert(&mut data, data_list); } } let duration = Utc::now().naive_utc().timestamp_micros() - start_time; let result = format!("插入{}条数据执行耗时:{}微秒", num, duration); result}
复制代码


数据对比分析:



可以看到,在数据量不大的场景下,Wasm 的耗时是比纯 JavaScript 长的,这是因为浏览器需要在 VM 容器中对 Wasm 模块进行实例化,这一部分会消耗相当的时间,导致性能不如纯 JavaScript 的执行。但随着运算规模变大,Wasm 的优化越来越明显。这是因为 WebAssembly 是一种低级别的二进制格式,经过高度优化,并且能够更好地利用系统资源。相比之下,JavaScript 是一种解释性语言,性能可能会受到解释器的限制。

八、总结

在大多数场景下我们都不需要用到 WebAssembly。因为 V8 等 JS 引擎的优化带来了巨大的性能提升,已经足够让 JavaScript 应对绝大多数的普通场景了,如果要做进一步优化密集计算任务时使用 Web worker 也都能解决掉。只有在以上的少数场景下,我们才需要做这种“二次提升”。


WebAssembly 虽然有天然的优势,但也有自己的局限性,在使用时我们也需要考虑多方面因素,例如生态、开发成本等等。不过我们依然可以持续关注 WebAssembly 的发展。


*文/WWQ


本文属得物技术原创,更多精彩文章请看:得物技术


未经得物技术许可严禁转载,否则依法追究法律责任!

用户头像

得物技术

关注

得物APP技术部 2019-11-13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
基于IM场景下的Wasm初探:提升Web应用性能|得物技术_rust_得物技术_InfoQ写作社区