在瀏覽器前端執行 Go/TinyGo 語言程式: 簡單實戰 WebAssembly(WASM)應用(以及,它真的有比 JavaScript 快嗎?)

本篇的兩個完整專案原始碼可在這裡下載。

什麼是 WebAssembly?

在新世代的高效能後端語言如 Rust、Go 崛起之際,前端環境的語言仍幾乎是 JavaScript 的天下。自從 1995 年 Netscape 瀏覽器首次內建 JS 引擎以來,JavaScript 這個語法模仿 Java、本質上卻和 Python 一樣是直譯式語言的東西就是能在各家瀏覽器運行的唯一語言。

有很多後端語言可以撰寫伺服器程式,在使用者送出請求時傳回它們要的東西,你也能完全透過後端來創造使用者看到的內容。在 2010 年代這麼做其實挺常見的;然而,隨著網路流量與使用者的增加,這種運算負擔全集中在伺服器的模式已經不再實務,勢必得將一大部分運算挪到前端。近年來前端框架與 Node.js 的流行也正反映了這種趨勢。

問題就在於:即使有最佳化技術,JavaScript 在某些運算的效能仍有其極限的。因此 Mozilla 在 2012 年開發 asm.js 函式庫來試圖解決這種問題,要求 JavaScript 原始碼寫成特定的格式,好避開過去難以最佳化的地方。同時,Mozilla 以 LLVM 開發了 Emscripten 編譯器,可以將 C/C++ 語言編譯為 asm.js 可執行的版本。

眾家瀏覽器商如 Chrome、Edge、Firefox 等看出了 asm.js 的潛力,也意識到統一標準遠比各自發展更有好處,因此在 2015 年組成 W3C WebAssembly Working Group。WebAssembly(簡稱 WASM)即為 asm.js 的後繼者,其最早的展示應用之一是以 Unity3D 開發的遊戲 Angry Bots

2019 年 12 月,W3C 宣布 WASM 成為可在瀏覽器內執行的第四個語言,絕大部分瀏覽器及 Node.js 都已經支援它。

WASM 並不是設計給使用者撰寫的語言,更像是個編譯器。它和 asm.js 的差別在於,WASM 要求程式先編譯成 .wasm 二進位位元組碼,然後在 JS 引擎中讀取和直接執行;既然少了編譯階段,WASM 執行起來(理論上)就會快得多,有時甚至能直逼 .wasm 的來源原生語言。

但「WASM 比 JS 快」這種說法,其實仍是有爭議的。當我自己發現事情並非如此時也很訝異;見本文稍後討論。

這樣看起來好像 WASM 會成為 JS 殺手,但 WASM 在前端的執行仍然得仰賴 JS 呼叫。JavaScript 之父 Brendan Eich 也說了:WebAssembly 是要用來讓 JavaScript 更強, 而不是用來取代後者。換言之,WASM 有可能帶來以下好處:

  1. 讓前端負擔後端程式運算,而且可以藉由抽換檔案的方式快速更新功能。
  2. 提高前端程式運算密集部分的效能。
  3. 讓開發者運用自己熟悉的程式語言撰寫前端功能,甚至將現有函式庫移植到前端環境。

WASM 將觸角伸向多種語言的特點,也帶來額外的好處。Mozilla 設計了 WASI(WebAssembly System Interface),允許從前端以外的環境呼叫 WASM 程式,這使得有人把 WebAssembly 當成雲端版的 Docker 使用。Docker 的共同開發者 Solomon Hykes 就說:

若 WASM + WASI 在 2008 年就問世,那麼我們就不需要開發 Docker 了。它就是這麼重要。在伺服器執行 WebAssembly 是未來的運算趨勢。

既然 WASM 仍是在 JS 沙盒內執行,這就能確保 WASM 程式無法隨意竄改系統。有些企業其實就使用這種方式來執行第三方開發商的程式。

實作:將 Go 語言編譯成 WASM

Photo by Ross Findon on Unsplash

目前支援將程式編譯成 WASM 的語言很多,有些是內建功能,有些則是透過第三方工具。由於筆者我稍早剛好做了一本 Go 語言書,所以這裡我探討的對象就是 Golang。

雖然就前陣子 WebAssembly 的調查顯示,目前使用最多的前三名語言是 Rust、C++ 和 AssemblyScript(針對 TypeScript 的 WASM 編譯器)。

目前 Go 語言對 WASM 的支援仍是實驗性階段,這表示本文展示的內容有可能在未來有進一步變更。不過這可以展示你如何能將一個現成的 Go 語言函式「匯入」到前端環境呼叫,而且還可以透過 Go 語言去控制 HTML DOM 物件。

本文我們欲展示的目的為:

  1. 包裝一個現成的 Go 程式給 JavaScript 呼叫。
  2. 將 Go 程式編譯為 WASM 檔。
  3. 啟動伺服器後,從 HTML 載入並呼叫 WASM 函式。

本篇使用的 Go 語言環境為 1.16.6。安裝請見官方的安裝指南

在本例中,我們使用的專案有以下結構:

go-wasm  → 專案根目錄
/assets → 存放網站需要的檔案
/src → 要編譯成 WASM 的 main.go 主原始檔
/covid → Go 語言套件所在處

由於 Go 語言內建有 HTTP 伺服器功能,所以我們後面也會在根目錄下寫一個 server.go 來開檔案伺服器。不過,基本上用任何類似的伺服器都可以。

既然專案會用到內部套件,我們得建立 go.mod 檔來提供模組路徑(在此將專案的模組名稱取為 go-wasm):

go-wasm> go mod init go-wasm
go: creating new go.mod: module go-wasm
go: to add module requirements and sums:
go mod tidy

接著到 Go 語言安裝目錄底下的 /misc/wasm 找到 wasm_exec.js,把它拷貝到專案的 assets 目錄內。這是 Go 語言的 WASM 輔助函式庫,我們在後面的 HTML 網頁會匯入它。

Photo by quokkabottles on Unsplash

這裡我們要使用的 Go 套件 covid 只有一個檔案 covid.go,當中唯一功能是根據指定的國家名稱去查詢約翰霍普金斯大學提供的新冠肺炎最新案例數。當你呼叫 QueryCovidCase() 時,它會用 http.Get() 下載報表、走訪 CSV 內容,並在找到相符名稱後傳回案例數值:

// covid.gopackage covidimport (
"encoding/csv"
"errors"
"io"
"net/http"
"strconv"
"strings"
)
// COVID-19 全球病例線上報表路徑
const url = "https://raw.githubusercontent.com/CSSEGISandData/COVID-19/master/csse_covid_19_data/csse_covid_19_time_series/time_series_covid19_confirmed_global.csv"
// QueryCovidCase 會搜尋並傳回指定國家的病例數
func QueryCovidCase(region string) (int, error) {
// 用 HTTP GET 取回 CSV 檔內容
r, err := http.Get(url)
if err != nil {
return 0, err
}
defer r.Body.Close()
reader := csv.NewReader(r.Body)
header := true
// 走訪 CSV 檔, 找到相符國家名稱就傳回病例數
// 有錯誤或找不到則傳回 0
for {
record, rowErr := reader.Read()
if rowErr == io.EOF {
break
} else if rowErr != nil {
return 0, rowErr
}
// 跳過 CSV 標頭
if header {
header = false
continue
}
// 用小寫國名比對, 並跳過個別省分的資料
if strings.ToLower(record[1]) == strings.ToLower(region) &&
record[0] == "" {
cases, convErr := strconv.Atoi(record[len(record)-1])
if convErr != nil {
return 0, convErr
}
return cases, nil
}
}
return 0, errors.New("查無該國名稱")
}

約翰霍普金斯大學的 CSV 報表,第一欄是省或州等等的地區名稱,第二欄才是國名。後面的欄位代表從疫情爆發以來每一天的案例數資料,所以最末欄就是最新一筆。

Photo by Clay Banks on Unsplash

可以發現 covid.go 是個很正常的 Go 語言套件。我們甚至可替它寫個單元測試 covid_test.go:

// covid_test.gopackage covidimport (
"testing"
)
// 單元測試
func TestQueryCovidCase(t *testing.T) {
regions := []string{"US", "Japan", "Taiwan*"}
for _, region := range regions {
result, err := QueryCovidCase(region)
if err != nil {
// err 不為 nil 時印出訊息並回報測試失敗
t.Errorf("error: %v", err)
}
t.Logf("result for %v = %v\n", region, result)
}
}

單元測試會傳入三個國名,只要都沒有產生錯誤就代表通過(用 go test 時要加上 -v 來印出 log 訊息):

PS go-wasm> go test -v ./src/covid
=== RUN TestQueryCovidCase
covid_test.go:16: result for US = 34945468
covid_test.go:16: result for Japan = 914718
covid_test.go:16: result for Taiwan* = 15662
--- PASS: TestQueryCovidCase (0.52s)
PASS
ok go-wasm/src/covid 0.638s

接著,我們得把 QueryCovidCase() 轉成 JavaScript 可呼叫的版本,辦法是使用 Go 提供的 syscall/js 套件:

// main.gopackage mainimport (
"syscall/js" // WASM 函式庫
"go-wasm/src/covid" // 用專案模組路徑取得 covid 套件
)
func main() { // 註冊 JavaScript 函式
js.Global().Set("queryCovidCase",
js.FuncOf(covid.QueryCovidCase))
// 用空 select 卡住主程式
select {}
}

這是最簡單的版本,不過會有些問題,等等就會談到。

如果你使用 VS Code 之類的編輯器,syscall/js 這行可能會顯示錯誤。這只是因為它適用的編譯系統及平台不符你目前的環境。見後面的「編譯出 WASM 檔」說明。

在主程式中會呼叫 js.Global(),這能讓我們對 WASM 執行時的 JavaScript 環境新增一個全域變數 “queryCovidCase”,其內容是 QueryCovidCase() 以 js.FuncOf() 包裝後的結果。至於程式結尾則使用了個空 select,這會迫使 main() 停下來等待,因為 select 內沒有 case 或 default 可以讓它走下去(參見這裡);這麼做是為了讓 Go 程式別提早結束,讓 JavaScript 需要時可以呼叫它。

所以你也可以建一個零緩衝區的 channel 然後去讀它,這一樣能卡住 main():

<-make(chan bool)
Photo by Denys Argyriou on Unsplash

而看看官方文件對 js.FuncOf() 的定義如下:

func FuncOf(fn func(this Value, args []Value) interface{}) Func

它的 fn 參數是一個函式,你能看到它的規格:第一個參數是 JS 的 this,然後是數量不定的正常參數,兩者的型別都是 js.Value。傳回值是空介面,表示傳什麼回去都可以,但只能有一個值。這表示我們的 QueryCovidCase() 應該要修改成這樣才能被正確呼叫。

此外,這也沒有考慮到錯誤處理 — — 正如你在 QueryCovidCase() 看到的寫法,Go 語言會明確地傳回 error,不論有沒有錯誤發生都一樣,呼叫者必須負擔檢查錯誤的責任。但這在 JavaScript 中是不太可能做到的。

所以,與其刻意改寫原始的 Go 語言函式,我們可以再寫一個包裝函式來扮演 Go 與 JS 的介接角色:

// main.gopackage mainimport (
"fmt"
"syscall/js" // WASM 函式庫
"go-wasm/src/covid" // covid 套件
)
// 傳回 queryCovidCase 的 JavaScript 版函式並處理 Go 語言錯誤
func jsFuncWrapper() js.Func {
// 傳回 JavaScript 函式
return js.FuncOf(func(this js.Value, args []js.Value) interface{} {
// 取得 JavaScript DOM 文件元素
alert := js.Global().Get("alert")
doc := js.Global().Get("document")
label := doc.Call("getElementById", "result")
if !label.Truthy() {
alert.Invoke("網頁未包含 id='result' 元素")
return nil
}
label.Set("innerHTML", "")
// 限制呼叫函式的引數數量
if len(args) != 1 {
alert.Invoke("WASM 函式引數數量錯誤")
return nil
}
// 開一個新的 Goroutine, 以免 http.Get 卡死 js.FuncOf
go func() {
// 呼叫 queryCovidCase
result, err := covid.QueryCovidCase(args[0].String())
if err != nil {
alert.Invoke("WASM 執行錯誤: " + err.Error())
return
}
// 將查詢結果寫到網頁的 DOM 元素
label.Set("innerHTML", fmt.Sprintf("案例數: %v", result))
}()
return nil
})
}
func main() {// 註冊 JavaScript 函式, 結束時釋出資源
jsFunc := jsFuncWrapper()
js.Global().Set("queryCovidCase", jsFunc)
defer jsFunc.Release()
// 用空 select 卡住主程式
select {}
}

現在我們自己寫了個 jsFuncWrapper(),它會傳回一個格式符合的 js.Func 函式,我們也定義了後者要怎麼處理呼叫跟傳值的過程。為安全起見,main() 內也會在它真正結束時呼叫 js.Func 函式的 Release() 來釋放資源。

但由於原始 Go 程式中的 http.Get() 會引起一個意外的問題,所以我們不會把值傳回給 JavaScript,而是直接在這裡修改 HTML。這部分後面會再慢慢說明。

在 jsFuncWrapper() 傳回的函式中,利用 js.Global() 來取得網頁的一些 DOM 物件,其中包括 alert 彈出式對話框,在此我用它來當成顯示錯誤訊息的辦法。假如沒有錯誤,那麼程式會把結果(字串 “案例數: xxx”)寫在網頁上一個名叫 result 的 label 內。

編譯出 WASM 檔

Photo by Max LaRochelle on Unsplash

現在我們要來把 Go 語言檔案(main.go 以及它呼叫的 covid.go)編譯成單一一個 WASM 檔案。這需要指定編譯時的環境變數 GOOS(系統)及 GOARCH(平台)。

在 Linux 上比較容易:

\go-wasm> GOOS=js GOARCH=wasm go build -o ./assets/main.wasm ./src/

Windows 較麻煩一點,因為你得手動切換這兩個環境變數(下面以 64 位元 Windows 10 為例):

\go-wasm> go env -w GOOS=js GOARCH=wasm\go-wasm> go build -o ./assets/main.wasm ./src/\go-wasm> go env -w GOOS=windows GOARCH=amd64  ← 切回原本的設定

你可以執行 go env GOOS GOARCH 來檢視你的系統與平台。

總之這會在 assets 目錄下編譯出一個 main.wasm,這便是網站要使用的 WASM 二進位檔。

從 HTML/JavaScript 呼叫 WASM

在 assets 目錄下建立 index.html,其內容如下:

<!-- index.html --><html><head>
<meta charset="utf-8">
<title>Golang + WebAssembly Example</title>
<!-- 載入 WASM 功能及 .wasm 檔 -->
<script src="wasm_exec.js"></script>
<script src="index.js"></script>
<link rel="stylesheet" href="style.css">
</head><body>
<h1>COVID-19 各國案例查詢 (使用 Golang + WebAssembly)</h1>
<p>資料來源:約翰霍普金斯大學</p>
<hr />
<p>
國名 (英文):<input type="text" id="region" size="10">
<input type="button" value="查詢"
onclick="queryCovidCase(region.value)">
</p>
<p>
<label id="result"></label>
</p>
</body>
</html>
Photo by Thom Milkovic on Unsplash

先來看 index.html 使用的 style.css 如下(這不影響網頁運作,但總歸好看一點):

/* style.css */body {
background-color: beige;
text-align: center;
}
h1 {
color: cadetblue;
}
p {
color: dimgray;
}
#result {
color: indianred;
font-weight: bold;
font-size: 24px;
}

index.js 則只有以下內容:

// index.jsconst go = new Go();WebAssembly.instantiateStreaming(
fetch("main.wasm"),
go.importObject).then((result) => {
go.run(result.instance);
});

上面這段 JavaScript 直接取自官方文件,它會利用 wasm_exec.js 來載入我們的 main.wasm。

現在回過頭來看 index.html 的主要顯示部分:

國名 (英文):<input type="text" id="region" size="10">
<input type="button" value="查詢"
onclick="queryCovidCase(region.value)">
<label id="result"></label>

當使用者按下「查詢」按鈕時,它會呼叫 queryCovidCase() 並傳入輸入框(region)的值。這裡我們不需要傳回值,因為我們會從 Go 語言程式那邊把結果寫入 label(result):

// 取得 DOM 物件
doc := js.Global().Get("document")
// 取得 label
label := doc.Call("getElementById", "result")
...// 呼叫 Go 函式並取得結果
result, err := covid.QueryCovidCase(args[0].String())
// 將字串寫到 label
label.Set("innerHTML", fmt.Sprintf("案例數: %v", result))

最後我用了 fmt 來格式化字串。我發現如果單純用 string() 把數值 result 轉成字串,在網頁顯示就會變成亂碼。

Photo by Ben Wicks on Unsplash

你或許注意到,前面的 main.go 中在呼叫 QueryCovidCase() 時,外面多包了一層 Goroutine。這其實是因為 http 套件的一些功能在 WASM 的實作會呼叫 fetch(),而這會造成 js.FuncOf() 產生死結(這也記載在 Go 官方文件中):

Invoking the wrapped Go function from JavaScript will pause the event loop and spawn a new goroutine. Other wrapped functions which are triggered during a call from Go to JavaScript get executed on the same goroutine.

As a consequence, if one wrapped function blocks, JavaScript’s event loop is blocked until that function returns. Hence, calling any async JavaScript API, which requires the event loop, like fetch (http.Client), will cause an immediate deadlock. Therefore a blocking function should explicitly start a new goroutine.

所以如果想呼叫比如 http.Get(),解法是把它挪到 js.Func() 之外,或者開一個新的 Goroutine,讓 js.Func() 不須等待它。這也是為什麼前面的程式不會傳回值,而是直接修改 HTML — — 若我們用某個方式要 js.Func() 等待新 Goroutine 跑完和傳回結果(比如用 channel 等待傳回值),那麼就一樣會變成死結。

Photo by Lars Kienle on Unsplash

我們可以寫一個簡易的 Go 語言檔案伺服器(在專案根目錄新增 server.go):

package mainimport (
"log"
"net/http"
)
func main() { log.Println("於 http://localhost:8080 啟動伺服器... (按 Ctrl + C 停止)") log.Fatal(http.ListenAndServe(":8080",
http.FileServer(http.Dir("./assets"))))
}

然後執行它即可:

go-wasm> go run server.go

假如你有裝 Python,那麼也可以改用它來跑伺服器:

go-wasm> python -m http.server 8080 --directory ./assets

在 Windows 下執行 python,Linux 則是 python3。

接著在瀏覽器打開 localhost:8080,就會開啟 index.html:

只要輸入國名(可參考約翰霍普金斯大學的資料網站),就能看到結果:

如果輸入名稱查不到(比如 Taiwan 後面其實有個星號,因為之前該網站把台灣列入中國地區而遭抗議,所以加星號當註解),就會跳出對話框:

你可以發現網站本身並沒有重新整理,我們就能獲得想要的資訊。這便可以展示 WASM 如何能將非 JavaScript 語言的功能移植到前端,替使用者帶來更便利的體驗。

以下是本專案內容最終的模樣:

WASM 的編譯大小問題

Go 語言對 WASM 的支援還有另一個問題。若我們檢視 main.wasm 檔案,會發現它體積不小:

其實這也是 Go 語言編譯出單一可執行檔時的一個普遍現象,因為它得夾帶許多和系統、平台有關的資訊,就連 WASM 也不例外。所以這使得使用者仍得下載一個不太小的檔案,感覺有點破壞了 WebAssembly 的美意。

在 Go 官方文件的建議中有兩個解決方法:一是用某種方式壓縮/讓伺服器解壓縮 .wasm,二是使用 TinyGo 來編譯。

有一個看似比較可行的方式是自己用 gzip 壓縮 .wasm,然後在 JavaScript 中用 pako 函式庫解壓。只是可能執行環境的某些 API 已經改變,我沒辦法試成功。附帶一提,以上 main.wasm 用 gzip 最佳壓縮後的大小為原本的 36%。

使用 TinyGo 編譯 Go 程式

Photo by Gabriella Clare Marino on Unsplash

我在 Meduim 寫過幾篇關於 TinyGo 的文章,而簡單地說,TinyGo 其實是個以 LLVM 打造的編譯器,藉由去掉系統與平台的相關資訊來大幅縮小 Go 程式的二進位檔體積。正因為這點,加上 TinyGo 也支援 WASM 編譯,它自然引起 Go 語言圈子不小的關注。

當然,在寫這篇文時,TinyGo 仍有少部分的 Go 函式庫不支援(詳細支援列表見此),其中就包括所有 http 套件。這表示前面的 covid.go 套件目前是無法用 TinyGo 編譯的。

雖然說 TinyGo 文件上寫 net 套件有支援,但這套件只能做簡單的 HTTP 連線,不支援 HTTPS…

起碼我們已經展示,你能將 Go 語言的眾多功能移植到前端環境執行。因此下面我們來寫第二個專案 tinygo-wasm,這回改用一個運算密集的演算法,順便測試在瀏覽器跑 Go 和 JavaScript 的速度差別。

這裡我選的程式是八皇后問題,這是個寫起來其實不算很難的演算法,我用的是單陣列的遞迴解法,詳細的原理就不解釋了:

// nq.gopackage nqueenstype NQ struct {
n uint8
board []uint8
count int
}
func NQueens(qNum uint8) int {
nq := NQ{
n: qNum,
board: make([]uint8, qNum),
}
nq.placeQueen(0)
return nq.count
}
func (nq *NQ) placeQueen(pos uint8) {
if pos >= nq.n {
nq.count++
} else {
for i := range nq.board {
if nq.verifyPos(pos, uint8(i)) {
nq.board[pos] = uint8(i)
nq.placeQueen(pos + 1)
}
}
}
}
func (nq *NQ) verifyPos(checkPos uint8, newPos uint8) bool {
for i := uint8(0); i < checkPos; i++ {
if (nq.board[i] == newPos) || (abs(int16(checkPos)-int16(i)) == abs(int16(nq.board[i])-int16(newPos))) {
return false
}
}
return true
}
// 我不想為了 abs 去匯入 Math 模組,自己寫一個
func abs(x int16) int16 {
if x < 0 {
return -x
}
return x
}

當你呼叫 NQueens() 並傳入一個數值 N,它會計算 N x N 大小的棋盤可以有多少個 N 后組合(這 N 個西洋棋皇后不會吃到彼此的位置就是一個組合)。N 值越來越大時,計算時間也會大幅增長(其時間複雜度為 O(n²))。

Photo by Rafael Rex Felisilda on Unsplash

我也替它寫了個單元測試(這裡不列出程式碼):

tinygo-wasm> go test -v ./src/nqueens
=== RUN TestNQueens
nq_test.go:19: 測試 10 Queens = 724... (0.0052 s)
nq_test.go:19: 測試 12 Queens = 14200... (0.1520 s)
nq_test.go:19: 測試 14 Queens = 365596... (5.6867 s)
--- PASS: TestNQueens (5.84s)
PASS
ok tinygo-wasm/src/nqueens 5.929s

我也寫了一個 JavaScript 版的,內容幾乎一樣,外加一個「測試檔」。而它在 Node.js 14 的測試結果如下:

tinygo-wasm> node ./assets/nqueens_test.js                               
測試 10 Queens = 724... (0.012 s)
測試 12 Queens = 14200... (0.206 s)
測試 14 Queens = 365596... (7.721 s)

可見 Go 語言跑起來比 JavaScript 快。但編譯成 WASM 之後呢?

準備專案

你需要安裝 TinyGo,我的這篇文章有詳細過程。然後你得到 tinygo/targets 目錄下找到 wasm_exec.js,同樣把它拷貝到專案的 assets 子目錄下(tinygo-wasm 的目錄結構跟前面的 go-wasm 一樣)。

tinygo-wasm  → 專案根目錄
/assets → 存放網站需要的檔案
/src → 要編譯成 WASM 的 main.go 主原始檔
/nqueens → Go 語言套件所在處

我使用 TinyGo 0.19.0。你要注意專案中的 wasm_exec.js 得配合你編譯時使用的版本。

我們的 src/main.go 這次很簡單,它只負責呼叫 NQueens() 並傳回計算結果:

package mainimport (
"syscall/js"
"tinygo-wasm/src/nqueens" // nqueens 模組, 包含 nq.go
)
func jsFuncWrapper() js.Func {
return js.FuncOf(func(this js.Value, args []js.Value) interface{} {
return nqueens.NQueens(uint8(args[0].Int()))
})
}
func main() {
jsFunc := jsFuncWrapper()
js.Global().Set("nqueens", jsFunc)
defer jsFunc.Release()
select {}
}
Photo by Hannah Wei on Unsplash

接著用 TinyGo 來將 src 子目錄下的東西編譯成 tiny.wasm,放在 assets 目錄下:

tinygo-wasm> tinygo build -o ./assets/tiny.wasm -target wasm ./src

TinyGo 不需要設定 Go 環境變數,它是以 -target 來指定編譯目標,這點在任何系統或平台上皆同。

值得注意的是,檔案編譯出來只有 66 KB。我也用前面的方式編譯了個正常版的 WASM,大小為 1.31 MB。TinyGo 版的大小僅有 Go 版的 5%

這次我們想同時比較 TinyGo + WASM 與純 JavaScript 的執行速度,所以網頁上會有兩個 labal,id 為 result1 及 result2。此外,順便也引用了 JS 版的 nqueens。

<!-- index.html --><html><head>
<meta charset="utf-8">
<title>TinyGolang + WebAssembly Example</title>
<script src="wasm_exec.js"></script>
<script src="nqueens.js"></script>
<script src="index.js"></script>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>N-Queens 演算法 (使用 TinyGo + WebAssembly)</h1>
<hr />
<p>
N 后數量 (4~255):<input type="text" id="qnum" size="4">
<input type="button" value="計算" onclick="runNQueens(qnum.value);">
</p>
<p>
<label id="result1"></label>
</p>
<p>
<label id="result2"></label>
</p>
</body>
</html>

按下畫面的按鈕時,會改執行 index.js 內的函式 runNQueens(),它負責呼叫其他函式並且計時、然後將結果寫入到兩個 label。

// index.jsconst go = new Go();WebAssembly.instantiateStreaming(
fetch("tiny.wasm"),
go.importObject).then((result) => {
go.run(result.instance);
});
function runNQueens(qnum) {
if (Number.isNaN(qnum)) {
alert("必須輸入數值!");
return ""
}
if (qnum < 4 || qnum > 255) {
alert("必須輸入 4~255!");
return ""
}
const label1 = document.getElementById("result1");
const label2 = document.getElementById("result2");
let start,
result,
duration;
if (label1 && label2) { // 呼叫 TinyGo + WASM 版
start = Date.now();
result = nqueens(Number(qnum));
duration = (Date.now() - start) / 1000;
label1.innerHTML = `[TinyGo + WASM]: ${qnum} Queens 有 ${result} 個解 (計算時間: ${duration} 秒)`;
// 呼叫 JavaScript 版
start = Date.now();
result = nqueenJs(Number(qnum));
duration = (Date.now() - start) / 1000;
label2.innerHTML = `[JavaScript]: ${qnum} Queens 有 ${result} 個解 (計算時間: ${duration} 秒)`;
}
}

最後執行伺服器,打開 localhost:8080:

有注意到嗎?TinyGo + WASM 執行時間反而比 JavaScript 慢。N 值越大,差距就越明顯。很多人都說 WASM 效能能夠直逼原始語言,為什麼會這樣呢?

當 WASM 在發展時,JavaScript 引擎也在進步

一開始我也對這個結果一頭霧水。進一步搜尋資料後,才了解大概是怎麼回事。

一些 JavaScript 引擎,比如 Chrome 瀏覽器的 V8,其實一直在試著最佳化 JS 的執行速度,它會預處理和編譯 JS 為機器碼。只要你沒有用不同型別去呼叫函式,這種最佳化狀態就得以保留,讓 JS 執行起來飛快。Node.js 同樣使用了 V8 引擎,所以效能表現差不多。

我在 Edge 上得到的結果跟 Chrome 幾乎一樣,應該是因為 Edge 現在也是使用 Chromium 核心。

WebAssembly 身為較新的技術,最佳化的程度依然有限,且其實也取決於各語言工具的編譯效果。事實上 WebAssembly 真正的優勢還是在載入稍快一點,在執行速度上並不見得能占優勢。

我們來做個實驗,改用 Mozilla 自家的 Firefox 執行剛才的專案:

你能發現在 Firefox 的 JS 明顯變慢許多,而 TinyGo + WASM 的表現跟在 Chrome 相當。我在 Ubuntu 上試過也是如此。所以 WASM 能否「比較快」,其實還是取決於執行環境的 JS 最佳化程度,以及實際上要計算什麼。也有人說 JS 擅長處理小型陣列,所以若資料量變得很大,WASM 也許仍能拉開差距。

結語

Photo by Markus Spiske on Unsplash

本篇筆者簡單介紹了 WebAssembly,以及如何用 Go/TinyGo 編譯器來將 Go 程式移植到前端環境。當然就目前而言,WASM 的執行速度不見得會比 JavaScript 快;所以它最大的潛力,或許仍在於替其他語言打開了一道窗,讓非 JS 語言都得以移植到瀏覽器或 Node.js 等環境內運作。而 Rust、Go、TypeScript 這類靜態強型別語言,也能拿來解決 JavaScript 看似方便、但容易引發臭蟲的動態型別問題。

筆者我還沒有時間認真學 Rust,Rust 語法也比 Go 難得多,但它卻在 Stack Overflow 2020 調查中被最多使用者選為最愛的語言。除了 Rust 似乎自詡為 C++ 的正宗傳人,擁有比 Go 更強悍的執行效能外,它對 WASM 的支援程度或許也是它能如此受矚目的原因之一吧。

I just like to write weird stuff that have very little to do with my actual work. My normal blog is https://krantasblog.blogspot.com.

I just like to write weird stuff that have very little to do with my actual work. My normal blog is https://krantasblog.blogspot.com.