基於IM場景下的Wasm初探:提升Web應用效能|得物技術

得物技术發表於2024-11-05

一、何為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是解釋型語言,相比於編譯型語言需要在執行時轉換,所以解釋型語言的執行速度要慢於編譯型語言。

編譯型語言和解釋型語言程式碼執行的大致流程如下:

image.png

如上流程圖所示,解釋型語言每次執行都需要把原始碼轉換一次才能執行,而轉換過程非常耗費時間和效能,所以在 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引擎無須監測程式碼。它可以簡單地將這段程式碼的二進位制格式編譯為機器碼。

image.png

從這個流程中我們也可以看出,如果將每種程式語言都直接編譯為機器碼的各個版本,這樣效率是不是更高呢?想法是好的,但實現過程確實複雜不堪的。由於瀏覽器是可以在若干不同的處理器 (比如手機和平板等裝置) 上執行,因此為每個可能的處理器釋出一個WebAssembly程式碼的編譯後版本會很難做到。

我們可以透過替代方法即取得IR程式碼。IR即為中間程式碼(Intermediate Representation),它是編譯器中很重要的一種資料結構。編譯器在做完前端工作以後,首先就生成IR,並在此基礎上執行各種最佳化演算法,最後再生成目的碼。可以簡化為如下流程:

image.png

編譯器將IR程式碼轉換為一種專用位元組碼並放入字尾為.wasm的檔案中。此時Wasm檔案中的位元組碼還不是機器碼,它只是支援WebAssembly的瀏覽器能夠理解的一組虛擬指令。當載入到支援WebAssembly的瀏覽器中時,瀏覽器會驗證這個檔案的合法性,然後這些位元組碼會繼續編譯為瀏覽器所執行的裝置上的機器碼。

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

五、應用場景

在Web開發中,可以使用Wasm來提高應用程式的效能。以下是一些使用Wasm的常見場景:

  • 高效能運算:如果應用程式需要進行大量的數值計算、影像處理或者複雜的演算法運算,可以將這部分程式碼編譯成Wasm模組,以提高計算效能。
  • 遊戲開發:Wasm可以用於建立高效能的HTML5遊戲,透過將遊戲邏輯編譯成Wasm模組,可以實現更流暢的遊戲體驗。
  • 跨平臺應用:使用Wasm可以實現跨平臺的應用程式,無論是桌面還是移動裝置,使用者都可以透過瀏覽器來訪問和使用。
  • 移植現有程式碼:如果已經有用其他程式語言編寫的程式碼,可以透過將其編譯成Wasm模組,將其整合到現有的Web應用程式中,而無需重寫整個應用程式。

六、產品案例

  • 設計工具Figma-Wasm檔案大小為27.7M

image.png

  • Google Earth-Wasm檔案總計大小為192.M
  • 支援各大瀏覽器的3D地圖,而且執行流暢

image.png

  • B站-影片處理和播放也有使用Wasm,Wasm檔案大小為344kb

image.png

  • 跨平臺的OpenGL圖形引擎Magnum-Wasm檔案大小為844kb

image.png

七、實踐案例

這裡我們透過使用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方式可以指定構建的產物,如截圖所示:

image.png

編譯過程效果:影片見得物技術公眾號

編譯完成後,我們會發現根目錄下多了一個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檔案,對模組的使用者非常友好。如下:

image.png

在前端專案中引入使用

'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>
  );
}

image.png

效能比較

在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
}

資料對比分析:

image.png

可以看到,在資料量不大的場景下,Wasm的耗時是比純JavaScript長的,這是因為瀏覽器需要在VM容器中對 Wasm模組進行例項化,這一部分會消耗相當的時間,導致效能不如純JavaScript的執行。但隨著運算規模變大,Wasm的最佳化越來越明顯。這是因為WebAssembly是一種低階別的二進位制格式,經過高度最佳化,並且能夠更好地利用系統資源。相比之下,JavaScript是一種解釋性語言,效能可能會受到直譯器的限制。

八、總結

在大多數場景下我們都不需要用到WebAssembly。因為V8等JS引擎的最佳化帶來了巨大的效能提升,已經足夠讓JavaScript應對絕大多數的普通場景了,如果要做進一步最佳化密集計算任務時使用Web worker也都能解決掉。只有在以上的少數場景下,我們才需要做這種“二次提升”。

WebAssembly雖然有天然的優勢,但也有自己的侷限性,在使用時我們也需要考慮多方面因素,例如生態、開發成本等等。不過我們依然可以持續關注WebAssembly的發展。

*文/WWQ

本文屬得物技術原創,更多精彩文章請看:得物技術

未經得物技術許可嚴禁轉載,否則依法追究法律責任!

相關文章