Rust vs JavaScript 使用 WebAssembly 实现 76% 的性能提升

了解如何通过 Web WorkersWebAssembly 显著提升 JavaScript 应用的性能,并使用斐波那契算法作为案例分析。

JavaScript 通常运行在单线程上,通常称为“主线程”。这意味着 JavaScript 以同步方式一次执行一个任务。主线程还负责渲染任务,例如页面绘制和布局,以及处理用户交互,这也意味着长时间运行的 JavaScript 任务会导致浏览器变得无响应。这就是为什么当一个耗时的 JavaScript 函数运行时,网页可能会“卡住”,阻碍用户的正常操作。

我们将通过模拟斐波那契算法的繁重计算来演示如何阻塞主线程,并将通过以下几种方法来解决主线程阻塞的问题:

斐波那契算法

在本文的所有案例研究中,我们将使用一个简单且非常常见的斐波那契算法,其时间复杂度为 O(2^n)

const calculateFibonacci = (n: number): number => {
  if (n <= 1) return n;
  return calculateFibonacci(n - 1) + calculateFibonacci(n - 2);
};

单线程

现在,让我们直接在主线程上实现斐波那契算法。当按钮被点击时,简单地调用斐波那契函数。

子组件 Spinner.vue

<template>
  <div class="flex justify-center items-center">
    <div
      class="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-blue-500"
    ></div>
  </div>
</template>

<script lang="ts" setup></script>

<style scoped></style>

父组件 WebAssemblySingle.vue

<template>
  <div
    class="flex flex-col items-center justify-center min-h-screen bg-gray-900 text-white"
  >
    <button
      @click="handleCalculate"
      class="mb-8 px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition"
    >
        计算斐波那契数列    
    </button>
       <Spinner v-if="isLoading" />    
    <p v-else class="text-xl">结果: {{ result }}</p>
  </div>
</template>

<script lang="ts" setup>
import Spinner from "@/components/Spinner.vue";
import { ref } from "vue";

const result = ref<number | null>(null);
const isLoading = ref(false);

const calculateFibonacci = (n: number): number => {
  if (n <= 1) return n;
  return calculateFibonacci(n - 1) + calculateFibonacci(n - 2);
};

const handleCalculate = () => {
  isLoading.value = true;
  const fibonacciResult = calculateFibonacci(42);
  result.value = fibonacciResult;
  isLoading.value = false;
};
</script>

<style scoped></style>

现在,让我们尝试点击 "计算斐波那契数列" 按钮,同时测量性能。要测量代码的性能,我们可以使用 Chrome DevTools 中的性能工具。

单线程

如您在界面中所见,我们的加载动画按钮甚至没有出现,取而代之的是突然显示了计算结果。从性能工具中我们也可以看到,由于斐波那契算法在主线程上的繁重计算,旋转动画被阻塞了大约 2.11 秒。

单线程

多线程(Web Worker

将繁重的计算从主线程移开的常用方法是使用 Web Worker

/**
 * 将斐波那契算法移到 Web Worker 中
 */
self.addEventListener("message", function (e) {
  const n = e.data;

  const fibonacci = (n) => {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
  };

  const result = fibonacci(n);
  self.postMessage(result);
});

子组件 Spinner.vue 保持不变,父组件为 WebAssemblyWorker.vue

<template>
  <div
    class="flex flex-col items-center justify-center min-h-screen bg-gray-900 text-white"
  >
    <button
      @click="handleCalculate"
      class="mb-8 px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition"
    >
      计算斐波那契数列
    </button>
    <Spinner v-if="isLoading" />
    <p v-else class="text-xl">结果: {{ result }}</p>
  </div>
</template>

<script lang="ts" setup>
  import Spinner from "@/components/Spinner.vue";
  import { ref } from "vue";

  const result = ref<number | null>(null);
  const isLoading = ref(false);

  const calculateFibonacci = (n: number): number => {
    if (n <= 1) return n;
    return calculateFibonacci(n - 1) + calculateFibonacci(n - 2);
  };

  const handleCalculate = () => {
    isLoading.value = true;
    const fibonacciResult = calculateFibonacci(42);
    result.value = fibonacciResult;
    isLoading.value = false;
  };
</script>

<style scoped></style>

现在,如果我们进行性能测试,可以看到加载动画平稳运行。这是因为我们将繁重的计算任务移到了工作线程,避免了阻塞主线程。

Worker

可以看到,单线程和工作线程的计算时间都大约是 2 秒。那么问题来了,我们如何进一步优化呢?答案是使用 WebAssembly

Worker

图解说明:动画正在正常运行,此时斐波那契算法已在另一个线程上执行,有效避免了主线程的阻塞。

WebAssembly — AssemblyScript

作为一名前端工程师,若在其他语言上的经验有限并想尝试 WebAssembly,我们通常会选择 AssemblyScript,因为它的开发体验最接近 TypeScript

以下是用 AssemblyScript 编写的等效斐波那契代码。

export function fibonacci(n: i32): i32 {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

编译该代码后,会生成一个 release.wasm 文件。然后我们可以在 JavaScript 代码中使用这个 Wasm 文件。

子组件 Spinner.vue 保持不变,父组件为 AssemblyScript.vue

<template>
  <div
    class="flex flex-col items-center justify-center min-h-screen bg-gray-900 text-white"
  >
    <button
      @click="handleCalculate"
      class="mb-8 px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition"
    >
      计算斐波那契数列
    </button>
    <Spinner v-if="isLoading" />
    <p v-else class="text-xl">结果: {{ result }}</p>
  </div>
</template>

<script lang="ts" setup>
  import Spinner from "@/components/Spinner.vue";

  import { ref } from "vue";

  const result = ref<number | null>(null);
  const isLoading = ref(false);

  const handleCalculate = async () => {
    isLoading.value = true;

    const wasmUrl = new URL("@/assembly/release.wasm", import.meta.url);
    const wasmModule = await fetch(wasmUrl);
    const buffer = await wasmModule.arrayBuffer();
    const module = await WebAssembly.instantiate(buffer);
    const wasm = module.instance.exports;

    result.value = wasm.fibonacci(42);
    isLoading.value = false;
  };
</script>

<style scoped></style>

现在,如果我们再次测量性能,尽管仍在主线程上,但加载动画出现了,并且没有被繁重的计算阻塞。斐波那契算法现在大约耗时 830 毫秒,比仅使用 JavaScript 快了约 60%。

AssemblyScript

AssemblyScript

WebAssembly — Rust

RustWebAssembly 的热门选择之一,Mozilla 的官方文档也推荐了它。让我们尝试用 Rust 实现相同的斐波那契算法。

use wasm_bindgen::prelude::*;

// 通过 WebAssembly 将函数暴露给 JavaScript
#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u32 {
    match n {
        0 => 0,
        1 => 1,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

子组件 Spinner.vue 保持不变,父组件为 AssemblyScript.vue

<template>
  <div
    class="flex flex-col items-center justify-center min-h-screen bg-gray-900 text-white"
  >
    <button
      @click="handleCalculate"
      class="mb-8 px-6 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition"
    >
      计算斐波那契数列
    </button>
    <Spinner v-if="isLoading" />
    <p v-else class="text-xl">结果: {{ result }}</p>
  </div>
</template>

<script lang="ts" setup>
  import Spinner from "@/components/Spinner.vue";

  import { ref } from "vue";
  import init, { fibonacci } from "@/assembly/pkg/hello_wasm.js";

  const result = ref<number | null>(null);
  const isLoading = ref(false);

  const handleCalculate = async () => {
    isLoading.value = true;

    await init();
    result.value = fibonacci(42);

    isLoading.value = false;
  };
</script>

<style scoped></style>

现在,让我们看看使用 Rust 编写的 WebAssembly 的效果。我们仍然在主线程上运行,但使用了 Wasm。和 AssemblyScript 类似,即使在主线程上运行 Wasm,加载动画仍然可以正常显示,不会被阻塞。令人惊讶的是,这个繁重的计算现在仅耗时 490 毫秒,比仅使用 JavaScript 快了 76%。

Rust.gif

Rust.png

总结

  • 繁重的计算会阻塞主线程并停止所有动画。
  • 可以使用 Web Worker 将繁重计算移到后台线程。
  • 通过将计算逻辑重写为 WebAssembly 可以进一步提升性能。使用斐波那契算法作为案例,我们获得了以下结果:
  1. JavaScript: 2 秒
  2. WebAssemblyAssemblyScript:830 毫秒(比 JavaScript 快 60%)
  3. WebAssemblyRust:490 毫秒(比 JavaScript 快 76%)