天天看點

WebAssembly原理及入門

文章目錄

    • 1. WebAssembly是什麼
    • 2. 為什麼需要 WebAssembly
    • 3. WebAssembly 原理
    • 4. 編寫 WebAssembly
      • 4.1 AssemblyScript 初體驗
      • 4.2 為什麼選 AssemblyScript 作為 WebAssembly 開發語言
      • 4.3 接入 Webpack 建構
      • 4.4 WebAssembly 相關檔案格式
    • 5. WebAssembly 相關工具
      • 5.1 WebAssembly JS API
      • 5.2 WebAssembly 調 JS
    • 6. 不止于浏覽器
      • 6.1 直接執行 wasm 二進制檔案
      • 6.1 在 Node.js 中運作
    • 7. WebAssembly 展望
    • 8. 結束語
    • 9. 參考資料

1. WebAssembly是什麼

WebAssembly/wasm WebAssembly 或者 wasm 是一個可移植、體積小、加載快并且相容 Web 的全新格式,是由主流浏覽器廠商組成的 W3C 社群團體 制定的一個新的規範。

WebAssembly具有以下特點:

  • 高效

    WebAssembly 有一套完整的語義,實際上 wasm 是體積小且加載快的二進制格式, 其目标就是充分發揮硬體能力以達到原生執行效率

  • 安全

    WebAssembly 運作在一個沙箱化的執行環境中,甚至可以在現有的 JavaScript 虛拟機中實作。在web環境中,WebAssembly将會嚴格遵守同源政策以及浏覽器安全政策。

  • 開放

    WebAssembly 設計了一個非正常整的文本格式用來、調試、測試、實驗、優化、學習、教學或者編寫程式。可以以這種文本格式在web頁面上檢視wasm子產品的源碼。

  • 标準

    WebAssembly 在 web 中被設計成無版本、特性可測試、向後相容的。WebAssembly 可以被 JavaScript 調用,進入 JavaScript 上下文,也可以像 Web API 一樣調用浏覽器的功能。當然,WebAssembly 不僅可以運作在浏覽器上,也可以運作在非web環境下。

2. 為什麼需要 WebAssembly

自從 JavaScript 誕生起到現在已經變成最流行的程式設計語言,這背後正是 Web 的發展所推動的。Web 應用變得更多更複雜,但這也漸漸暴露出了 JavaScript 的問題:

  • 文法太靈活導緻開發大型 Web 項目困難;
  • 性能不能滿足一些場景的需要。

針對以上兩點缺陷,近年來出現了一些 JS 的代替語言,例如:

  • 微軟的 TypeScript 通過為 JS 加入靜态類型檢查來改進 JS 松散的文法,提升代碼健壯性;
  • 谷歌的 Dart 則是為浏覽器引入新的虛拟機去直接運作 Dart 程式以提升性能;
  • 火狐的 asm.js 則是取 JS 的子集,JS 引擎針對 asm.js 做性能優化。

以上嘗試各有優缺點,其中:

  • TypeScript 隻是解決了 JS 文法松散的問題,最後還是需要編譯成 JS 去運作,對性能沒有提升;
  • Dart 隻能在 Chrome 預覽版中運作,無主流浏覽器支援,用 Dart 開發的人不多;
  • asm.js 文法太簡單、有很大限制,開發效率低。

三大浏覽器巨頭分别提出了自己的解決方案,互不相容,這違背了 Web 的宗旨; 是技術的規範統一讓 Web 走到了今天,是以形成一套新的規範去解決 JS 所面臨的問題迫在眉睫。

于是 WebAssembly 誕生了,WebAssembly 是一種新的位元組碼格式,主流浏覽器都已經支援 WebAssembly。 和 JS 需要解釋執行不同的是,WebAssembly 位元組碼和底層機器碼很相似可快速裝載運作,是以性能相對于 JS 解釋執行大大提升。 也就是說 WebAssembly 并不是一門程式設計語言,而是一份位元組碼标準,需要用進階程式設計語言編譯出位元組碼放到 WebAssembly 虛拟機中才能運作,浏覽器廠商需要做的就是根據 WebAssembly 規範實作虛拟機。

3. WebAssembly 原理

要搞懂 WebAssembly 的原理,需要先搞懂計算機的運作原理。 電子計算機都是由電子元件組成,為了友善處理電子元件隻存在開閉兩種狀态,對應着 0 和 1,也就是說計算機隻認識 0 和 1,資料和邏輯都需要由 0 和 1 表示,也就是可以直接裝載到計算機中運作的機器碼。 機器碼可讀性極差,是以人們通過進階語言 C、C++、Rust、Go 等編寫再編譯成機器碼。

由于不同的計算機 CPU 架構不同,機器碼标準也有所差别,常見的 CPU 架構包括 x86、AMD64、ARM, 是以在由進階程式設計語言編譯成可自行代碼時需要指定目标架構。

WebAssembly 位元組碼是一種抹平了不同 CPU 架構的機器碼,WebAssembly 位元組碼不能直接在任何一種 CPU 架構上運作, 但由于非常接近機器碼,可以非常快的被翻譯為對應架構的機器碼,是以 WebAssembly 運作速度和機器碼接近,這聽上去非常像 Java 位元組碼。

相對于 JS,WebAssembly 有如下優點:

  • 體積小:由于浏覽器運作時隻加載編譯成的位元組碼,一樣的邏輯比用字元串描述的 JS 檔案體積要小很多;
  • 加載快:由于檔案體積小,再加上無需解釋執行,WebAssembly 能更快的加載并執行個體化,減少運作前的等待時間;
  • 相容性問題少:WebAssembly 是非常底層的位元組碼規範,制訂好後很少變動,就算以後發生變化,也隻需在從進階語言編譯成位元組碼過程中做相容。可能出現相容性問題的地方在于 JS 和 WebAssembly 橋接的 JS 接口。

每個進階語言都去實作源碼到不同平台的機器碼的轉換工作是重複的,進階語言隻需要生成底層虛拟機(LLVM)認識的中間語言(LLVM IR), LLVM 能實作:

  • LLVM IR 到不同 CPU 架構機器碼的生成;
  • 機器碼編譯時性能和大小優化。

除此之外 LLVM 還實作了 LLVM IR 到 WebAssembly 位元組碼的編譯功能,也就是說隻要進階語言能轉換成 LLVM IR,就能被編譯成 WebAssembly 位元組碼,目前能編譯成 WebAssembly 位元組碼的進階語言有:

  • AssemblyScript:文法和 TypeScript 一緻,對前端來說學習成本低,為前端編寫 WebAssembly 最佳選擇;
  • c\c++:官方推薦的方式,詳細使用見 文檔;
  • Rust:文法複雜、學習成本高,對前端來說可能會不适應。詳細使用見 文檔;
  • Kotlin:文法和 Java、JS 相似,語言學習成本低,詳細使用見 文檔;
  • Golang:文法簡單學習成本低。但對 WebAssembly 的支援還處于未正式釋出階段,詳細使用見 文檔 。

通常負責把進階語言翻譯到 LLVM IR 的部分叫做編譯器前端,把 LLVM IR 編譯成各架構 CPU 對應機器碼的部分叫做編譯器後端; 現在越來越多的進階程式設計語言選擇 LLVM 作為後端,進階語言隻需專注于如何提供開發效率更高的文法同時保持翻譯到 LLVM IR 的程式執行性能。

4. 編寫 WebAssembly

4.1 AssemblyScript 初體驗

接下來詳細介紹如何使用 AssemblyScript 來編寫 WebAssembly,實作斐波那契序列的計算。 用 TypeScript 實作斐波那契序列計算的子產品 f.ts 如下:

export function f(x: i32): i32 {
    if (x === 1 || x === 2) {
        return 1;
    }
    return f(x - 1) + f(x - 2)
}
           

在按照 AssemblyScript 提供的安裝教程 成功安裝後, 再通過

asc f.ts -o f.wasm
           

就能把以上代碼編譯成可運作的 WebAssembly 子產品。

為了加載并執行編譯出的 f.wasm 子產品,需要通過 JS 去加載并調用子產品上的 f 函數,為此需要以下 JS 代碼:

fetch('f.wasm') // 網絡加載 f.wasm 檔案
    .then(res => res.arrayBuffer()) // 轉成 ArrayBuffer
    .then(WebAssembly.instantiate) // 編譯為目前 CPU 架構的機器碼 + 執行個體化
    .then(mod => { // 調用子產品執行個體上的 f 函數計算
    console.log(mod.instance.f(50));
    });
           

以上代碼中出現了一個新的内置類型 i32,這是 AssemblyScript 在 TypeScript 的基礎上内置的類型。 AssemblyScript 和 TypeScript 有細微差別,AssemblyScript 是 TypeScript 的子集,為了友善編譯成 WebAssembly 在 TypeScript 的基礎上加了更嚴格的 類型限制 ,差別如下:

  • 比 TypeScript 多了很多更細緻的内置類型,以優化性能和記憶體占用,詳情 文檔;
  • 不能使用 any 和 undefined 類型,以及枚舉類型;
  • 可空類型的變量必須是引用類型,而不能是基本資料類型如 string、number、boolean;
  • 函數中的可選參數必須提供預設值,函數必須有傳回類型,無傳回值的函數傳回類型需要是 void;
  • 不能使用 JS 環境中的内置函數,隻能使用 AssemblyScript 提供的内置函數 。

總體來說 AssemblyScript 比 TypeScript 又多了很多限制,編寫起來會覺得局限性很大; 用 AssemblyScript 來寫 WebAssembly 經常會出現 tsc 編譯通過但運作 WebAssembly 時出錯的情況,這很可能就是你沒有遵守以上限制導緻的;但 AssemblyScript 通過修改 TypeScript 編譯器預設配置能在編譯階段找出大多錯誤。

AssemblyScript 的實作原理其實也借助了 LLVM,它通過 TypeScript 編譯器把 TS 源碼解析成 AST,再把 AST 翻譯成 IR,再通過 LLVM 編譯成 WebAssembly 位元組碼實作; 上面提到的各種限制都是為了友善把 AST 轉換成 LLVM IR。

4.2 為什麼選 AssemblyScript 作為 WebAssembly 開發語言

AssemblyScript 相對于 C、Rust 等其它語言去寫 WebAssembly 而言,好處除了對前端來說無額外新語言學習成本外,還有對于不支援 WebAssembly 的浏覽器,可以通過 TypeScript 編譯器編譯成可正常執行的 JS 代碼,進而實作從 JS 到 WebAssembly 的平滑遷移。

4.3 接入 Webpack 建構

任何新的 Web 開發技術都少不了建構流程,為了提供一套流暢的 WebAssembly 開發流程,接下來介紹接入 Webpack 具體步驟。

  1. 安裝以下依賴,以便讓 TS 源碼被 AssemblyScript 編譯成 WebAssembly。
{
  "devDependencies": {
    "assemblyscript": "github:AssemblyScript/assemblyscript",
    "assemblyscript-typescript-loader": "^1.3.2",
    "typescript": "^2.8.1",
    "webpack": "^3.10.0",
    "webpack-dev-server": "^2.10.1"
  }
}
           
  1. 修改 webpack.config.js,加入 loader:
module.exports = {
    module: {
        rules: [
            {
                test: /\.ts$/,
                loader: 'assemblyscript-typescript-loader',
                options: {
                    sourceMap: true,
                }
            }
        ]
    },
};
           
  1. 修改 TypeScript 編譯器配置 tsconfig.json,以便讓 TypeScript 編譯器能支援 AssemblyScript 中引入的内置類型和函數。
{
  "extends": "../../node_modules/assemblyscript/std/portable.json",
  "include": [
    "./**/*.ts"
  ]
}
           

配置直接繼承自 assemblyscript 内置的配置檔案。

4.4 WebAssembly 相關檔案格式

前面提到了 WebAssembly 的二進制檔案格式 wasm,這種格式的檔案人眼無法閱讀,為了閱讀 WebAssembly 檔案的邏輯,還有一種文本格式叫 wast; 以前面講到的計算斐波那契序列的子產品為例,對應的 wast 檔案如下:

func $src/asm/module/f (param f64) (result f64)
(local i32)
  get_local 0
  f64.const 1
  f64.eq
  tee_local 1
  if i32
    get_local 1
  else
    get_local 0
    f64.const 2
    f64.eq
  end
  i32.const 1
  i32.and
  if
    f64.const 1
    return
  end
  get_local 0
  f64.const 1
  f64.sub
  call 0
  get_local 0
  f64.const 2
  f64.sub
  call 0
  f64.add
end
           

這和彙編語言非常像,裡面的 f64 是資料類型,f64.eq f64.sub f64.add 則是 CPU 指令。

為了把二進制檔案格式 wasm 轉換成人眼可見的 wast 文本,需要安裝 WebAssembly 二進制工具箱 WABT , 在 Mac 系統下可通過 brew install WABT 安裝,安裝成功後可以通過指令 wasm2wast f.wasm 獲得 wast;除此之外還可以通過 wast2wasm f.wast -o f.wasm 逆向轉換回去。

5. WebAssembly 相關工具

除了前面提到的 WebAssembly 二進制工具箱,WebAssembly 社群還有以下常用工具:

  • Emscripten: 能把 C、C++代碼轉換成 wasm、asm.js;
  • Binaryen: 提供更簡潔的 IR,把 IR 轉換成 wasm,并且提供 wasm 的編譯時優化、wasm 虛拟機,wasm 壓縮等功能,前面提到的 AssemblyScript 就是基于它。

5.1 WebAssembly JS API

目前 WebAssembly 隻能通過 JS 去加載和執行,但未來在浏覽器中可以通過像加載 JS 那樣

去加載和執行 WebAssembly,下面來詳細介紹如何用 JS 調 WebAssembly。

JS 調 WebAssembly 分為 3 大步: 加載位元組碼 > 編譯位元組碼 > 執行個體化 ,擷取到 WebAssembly 執行個體後就可以通過 JS 去調用了,以上 3 步具體的操作是:

  • 對于浏覽器可以通過網絡請求去加載位元組碼,對于 Nodejs 可以通過 fs 子產品讀取位元組碼檔案;
  • 在擷取到位元組碼後都需要轉換成 ArrayBuffer 後才能被編譯,通過 WebAssembly 通過的 JS API WebAssembly.compile 編譯後會通過 Promise resolve 一個 WebAssembly.Module ,這個 module 是不能直接被調用的需要;
  • 在擷取到 module 後需要通過 WebAssembly.Instance API 去執行個體化 module,擷取到 Instance 後就可以像使用 JS 子產品一個調用了。

其中的第 2、3 步可以合并一步完成,前面提到的 WebAssembly.instantiate 就做了這兩個事情。

WebAssembly.instantiate(bytes).then(mod=>{
  mod.instance.f(50);
})
           

5.2 WebAssembly 調 JS

之前的例子都是用 JS 去調用 WebAssembly 子產品,但是在有些場景下可能需要在 WebAssembly 子產品中調用浏覽器 API,接下來介紹如何在 WebAssembly 中調用 JS。

WebAssembly.instantiate 函數支援第二個參數 WebAssembly.instantiate(bytes,importObject),這個 importObject 參數的作用就是 JS 向 WebAssembly 傳入 WebAssembly 中需要調用 JS 的 JS 子產品。舉個具體的例子,改造前面的計算斐波那契序列在 WebAssembly 中調用 Web 中的 window.alert 函數把計算結果彈出來,為此需要改造加載 WebAssembly 子產品的 JS 代碼:

WebAssembly.instantiate(bytes,{
  window:{
    alert:window.alert
  }
}).then(mod=>{
  mod.instance.f(50);
})
           

對應的還需要修改 AssemblyScript 編寫的源碼:

// 聲明從外部導入的子產品類型
declare namespace window {
    export function alert(v: number): void;
}

function _f(x: number): number {
    if (x == 1 || x == 2) {
        return 1;
    }
    return _f(x - 1) + _f(x - 2)
}

export function f(x: number): void {
    // 直接調用 JS 子產品
    window.alert(_f(x));
}
           

修改以上 AssemblyScript 源碼後重新用 asc 通過指令 asc f.ts 編譯後輸出的 wast 檔案比之前多了幾行:

(import "window" "alert" (func $src/asm/module/window.alert (type 0)))

(func $src/asm/module/f (type 0) (param f64)
    get_local 0
    call $src/asm/module/_f
    call $src/asm/module/window.alert)
           

多出的這部分 wast 代碼就是在 AssemblyScript 中調用 JS 中傳入的子產品的邏輯。

除了以上常用的 API 外,WebAssembly 還提供一些 API,你可以通過這個 d.ts 檔案 去檢視所有 WebAssembly JS API 的細節。

6. 不止于浏覽器

WebAssembly 作為一種底層位元組碼,除了能在浏覽器中運作外,還能在其它環境運作。

6.1 直接執行 wasm 二進制檔案

前面提到的 Binaryen 提供了在指令行中直接執行 wasm 二進制檔案的工具,在 Mac 系統下通過 brew install binaryen 安裝成功後,通過 wasm-shell f.wasm 檔案即可直接運作。

6.1 在 Node.js 中運作

目前 V8 JS 引擎已經添加了對 WebAssembly 的支援,Chrome 和 Node.js 都采用了 V8 作為引擎,是以 WebAssembly 也可以運作在 Node.js 環境中;

V8 JS 引擎在運作 WebAssembly 時,WebAssembly 和 JS 是在同一個虛拟機中執行,而不是 WebAssembly 在一個單獨的虛拟機中運作,這樣友善實作 JS 和 WebAssembly 之間的互相調用。

要讓上面的例子在 Node.js 中運作,可以使用以下代碼:

const fs = require('fs');

function toUint8Array(buf) {
    var u = new Uint8Array(buf.length);
    for (var i = 0; i < buf.length; ++i) {
        u[i] = buf[i];
    }
    return u;
}

function loadWebAssembly(filename, imports) {
    // 讀取 wasm 檔案,并轉換成 byte 數組
    const buffer = toUint8Array(fs.readFileSync(filename));
    // 編譯 wasm 位元組碼到機器碼
    return WebAssembly.compile(buffer)
        .then(module => {
            // 執行個體化子產品
            return new WebAssembly.Instance(module, imports)
        })
}

loadWebAssembly('../temp/assembly/module.wasm')
    .then(instance => {
        // 調用 f 函數計算
        console.log(instance.exports.f(10))
    });
           

在 Nodejs 環境中運作 WebAssembly 的意義其實不大,原因在于 Nodejs 支援運作原生子產品,而原生子產品的性能比 WebAssembly 要好。 如果你是通過 C、Rust 去編寫 WebAssembly,你可以直接編譯成 Nodejs 可以調用的原生子產品。

7. WebAssembly 展望

從上面的内容可見 WebAssembly 主要是為了解決 JS 的性能瓶頸,也就是說 WebAssembly 适合用于需要大量計算的場景,例如:

  • 在浏覽器中處理音視訊, flv.js 用 WebAssembly 重寫後性能會有很大提升;
  • React 的 dom diff 中涉及到大量計算,用 WebAssembly 重寫 React 核心子產品能提升性能。Safari 浏覽器使用的 JS 引擎 JavaScriptCore 也已經支援 WebAssembly,RN 應用性能也能提升;
  • 突破大型 3D 網頁遊戲性能瓶頸, 白鹭引擎已經開始探索用 WebAssembly 。

8. 結束語

WebAssembly 标準雖然已經定稿并且得到主流浏覽器的實作,但目前還存在以下問題:

  • 浏覽器相容性不好,隻有最新版本的浏覽器支援,并且不同的浏覽器對 JS WebAssembly 互調的 API 支援不一緻;
  • 生态工具不完善不成熟,目前還不能找到一門體驗流暢的編寫 WebAssembly 的語言,都還處于起步階段;
  • 學習資料太少,還需要更多的人去探索去踩坑。

總之現在的 WebAssembly 還不算成熟,如果你的團隊沒有不可容忍的性能問題,那現在使用 WebAssembly 到産品中還不是時候, 因為這可能會影響到團隊的開發效率,或者遇到無法輕易解決的坑而阻塞開發。

9. 參考資料

  • WASM中文網

    https://www.wasm.com.cn/

  • WebAssembly 現狀與實戰

    https://developer.ibm.com/zh/articles/wa-lo-webassembly-status-and-reality/

繼續閱讀