iT邦幫忙

2023 iThome 鐵人賽

DAY 19
0

WebAssembly 簡介

上上一篇匆匆忙忙不知不覺中就完成了WebAssembly,都還沒介紹這是什麼(?)。我們一起看一下WebAssembly的官網:

WebAssembly官網

裡面提到WebAssembly(縮寫為wasm)是基於堆疊式虛擬機的二進制指令稿。並且有著以下特性:

  • 效率與快速:目標為相容一般硬體的指令,所以可以跨不同平台執行。
  • 安全:因為採sandboxed沙盒式環境,隔離在執行的虛擬環境中,無法直接進行底層操作,需要透過runtime或瀏覽器的API呼叫OS的操作。
  • 開放且可除錯:Wasm設計為可用文字形式表達其對應的二進制形式,所以可以手寫(?)來進行驗證等。
  • 擁抱Open Web:wasm雖然設計來讓web環境使用,但也可以用在其他非Web嵌入式的環境,如IoT物聯網設備之類的。

怎麼好像這系列介紹的的東西,每樣都說自己是安全且快速(?)。

Wasm vs JavaScript

基本上JavaScript和wasm互有所長,如果是DOM元件的操作,還是建議用JavaScript就好;如果涉及比較複雜的演算法,可以考慮使用wasm,因為它快;如果需要比較精確的數字處理,比如金額(錢錢)的計算,可以考慮使用wasm,因為他不會亂進位。

所以wasm不是用來取代JavaScript的,是來加強我們前端的,畫面刻版依舊還是JS/CSS框架,需要加強效能或安全性,或是不想讓普羅大眾也看到我們在網站上裸奔的JavaScript長啥樣(?),就可以考慮選擇wasm。(那我不想加強JavaScript就不用學wasm了)

以下附上一些效能評測的資料供參考:

  • Benchmark-1:比較wasm / javascript跑費式數列,當n=3時 JavaScript比較快,但當 n=25 時 JavaScript明顯慢很多,所以推得wasm在進行複雜運算時有優勢,但因其不像JavaScript是立即執行,wasm需要進行載入的動作,所以相比JavaScript在進行簡單運算時,會有額外的載入成本耗損。
  • Benchmark-2:線上提供一些預寫好的算法,直接在瀏覽器環境中執行即可,在這可以測一下自己電腦上執行JavaScript與Wasm的差異比較。
  • Benchmark-3:提供很多常見程式語言,及一些算法案例的評測,還附帶各種程式原始碼供參考,這裡跑出來的評比,大家可以去看一下自己目前用的語言,和其他語言的相互比較。我當時就是在找什麼找到這個網站,看了綜合比較之後,才對rust產生好奇開始學rust的。

再次印證了寫程式採用的解法就是取捨。

浮點數

怕有人誤會以為用rust就無敵,畢竟還是有很多朋友沒有 去過中國廚藝訓練學院 受過計概的訓練。畢竟現在是一個看了網路YouTube教學就可以速成寫程式的年代。在JavaScript中的數字精確度非常非常的低,常常在四則運算中就錯了,不信的話把0.1加3次看看:

在JavaScript實現浮點數差異

怕有人看不懂第一行又再補了第二行 XDD

這個其實嚴格來說不算是bug(?),相對地,它是一個標準(IEEE 754)。原理版請自己去K文件,這裡講通俗版,大家知道第18篇講過溢位了,因為電腦的記憶體空間有限,當整數運算超出了空間就爆了;這裡也是類似,因為記憶體空間有限,不能像數學裡的小數可以無限切割(又不是在積分?),所以在電腦裡的實數是不連續的。它會有很多洞填不滿。只是在運算時,剛好有時候多一點點,有時候少一點點,平均下來就差不多了:

在JavaScript實現更多浮點數差異

所以如果真的要在前端寫到有精確度要求的運算,可以使用decimal.js處理,雖然它提供的API實在是有夠難用:

new Decimal(33).add(5).times(20); // (33 + 5) * 20

而rust沒有像C#一樣內建decimal的資料型別(其實deciaml也不保證不會有問題,只是我們日常情境夠用,所以更難遇到像上面JS算錯的例子而已。)在rust中已經有類似的套件rust_decimal可以使用。

這邊舉個栗子:

// examples/money/src/main.rs
use rust_decimal::prelude::*;
use rust_decimal_macros::dec;

fn main() {
    let a = 0.1;    // f64
    let b = 0.1;
    let c = 0.1;

    println!("a + b + c = {}", a + b + c);
    println!("a / 0.01 = {}", a / 0.03 );

    let d1 = dec!(0.1);
    let d2 = dec!(0.1);
    let d3 = dec!(0.1);

    println!("d1 + d2 + d3 = {}", d1 + d2 + d3);
    let d4 = dec!(0.03);
    println!("d1 / d4 = {}", d1 / d4 );
}

執行結果:

rust浮點數運算範例

其實有些系統對於精確度有明確要求的,會自己實作Currency/Money物件,比如MartinFowler有在PoEAA中提到Money物件中譯本為Martin Fowler談企業級軟體架構模式,快去買一本放在桌上假裝自己很厲害的樣子(X)。

曾經聽過有些系統在語言/資料庫/商業需求等綜合考量下,為了效能或其他的特殊需求,使用整數型態進行儲存,再另外存小數位數去處理,比如 100.00 就存成 int: 10000 的資料型別,最後在UI/報表上再折2位顯示。

這裡有針對浮點數與整數的benchmark和討論,有興趣的可以自己去看一下。

Wasm vs Docker

wasm 的另一個非web端的應用就是容器(?),基於其本身就是設計在沙盒中的虛擬環境,所以其抽象的程度更高,不能直接存取作業系統層的操作,需透過wasm的執行環境(runtime)。

可以參考:關於 WebAssembly 也能變成 Container 的這檔事

Docker聯合創始人Solomon Hykes曾在twitter(X)上說,如果在2008年當時有wasm+wasi,那麼也許就不會產生docker了。很多地方都會引用這句話來說明WASM的重要性(包含我在這也是在引用)。隨後Solomon Hykes又說以現今來說,docker仍有其地位,wasm將是windows容器或linux容器外的另一種容器選擇。

Solomon Hykes在tweet上指出wasm的重要性

所以有學docker的,可以繼續把wasm學起來(?),WebAssembly官網有列表各種程式語言編譯WebAssebmly的資源,大家有興趣可以看看,雖然我個人還是比較推薦使用rust。

有興趣的朋友可以實際去下載js及wasm的runtime相互比較看看。JavaScript的runtime,大多用nodejs,後來有Deno想改進nodejs的技術債而重寫,現在還有個很fancy的Bun。而wasm的runtime執行環境,可以看官網Roadmap,或是擲杯Wasmtimewasmer二選一。

rust編譯wasm的限制

其實rust編譯wasm還有一些限制。我們來幫 service 層加 tokio的參考:

@@ service/Cargo.toml @@
 [dependencies]
+tokio = { workspace = true }

然後建置wasm時就爆了:

tokio無法建置wasm

因為之前說wasm處在沙盒中,不能開socket連線,所以在編譯時參考到mio(tokio使用的low-level I/O library)時就會報錯,這時候cargo 的feature就派上用場了,終於知道為什麼要分feature了(以後不能總想著懶人包直接full全部feature加進來了):

@@ service/Cargo.toml @@
 [dependencies]
+tokio = { version = "1.32" , features = ["macros", "rt", "time"] }
-tokio = { workspace = true }

所以第一項限制是不能編譯比較接近OS底層的操作。不然連編譯都通過不了。接著再試試:

// service/src/demo.rs
pub async fn hello_async() -> String {
    // wait().await;               // 加這行在執行時會失效
    "Hello async!".to_string()
}

pub async fn wait() {
    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
// wasm/src/demo.rs
use wasm_bindgen::prelude::wasm_bindgen;
use service::demo;

#[wasm_bindgen]
pub async fn hello_async() -> String {
    demo::hello_async().await
}

前端加上

<!-- app/src/lib/Greet.svelte -->
<script lang="ts">
  import init, { hello_async } from '../../../wasm/pkg/wasm'
  import { onMount } from "svelte";
  let hello = null;

  async function greet() {
    let s = await hello_async();
    console.log(s);
  }

  onMount(async () => {
    await init();
    hello = hello_async;
  });
</script>

一開始執行是成功的:

wasm正確執行

拿掉註解後,裡面的 tokio::time::sleep 在wasm中不能正確使用:

wasm在執行時出錯

這是另一個無法正確執行的限制,所以如果寫的程式是要被wasm參考的,最好是一邊寫一邊開著watch看有沒有建置成功,不然等到都寫完了,再拆就比較麻煩。

Closure 閉包

上上一篇說要講的Closure在這裡也補一下。我們開個範例檔說明比較直觀,在examples底下新增一個closure專案:

examples/closure/
├── Cargo.toml
└── src
    └── main.rs
# Cargo.toml
[package]
name = "example-closure"
version = "0.1.0"
edition = "2021"

[dependencies]
// examples/closure/src/main.rs
fn add_2(x: &i32) -> i32 {  // 注意這個fn的簽章與下面map裡所需的簽章是一致的
    x + 2
}

fn main() {
    let a = vec![1, 2, 3];
    let b: Vec<i32> = a.iter().map(|x| x + 2).collect();
    let c: Vec<i32> = a.iter().map(add_2).collect();
    println!("a: {:?}", a);
    println!("b: {:?}", b);
    println!("c: {:?}", c);
}

這個不常跑所以就不特別寫到批次檔(run.sh/run.ps1)裡,但還是可以在命令列watch起來

~demo-app$ cargo watch -q -c -w ./examples/closure/ -x 'run -p example-closure'

執行example-closure

說明:

  • let b = ...呼叫map時使用閉包,丟一個參數,所以閉包相當於是一個迷你版的fn,在其他語言通常叫匿名函數(Anonymous function),也有人叫lambda函數,在js/ts裡又叫arrow function
  • let c = ...的map直接呼叫 add_2 這個 fn,這裡的fn是作為參數傳遞,有點像functional promgramming的用法,因為在這裡是當參數傳遞,所以不能加(),因為我們沒有要在這裡呼叫,一旦在函數後面加括號(),就表示我們要呼叫執行它,所以參數值拿到的就會變成執行fn的回傳值了。

然後我們可以改一下:

fn main() {
    let a = vec![1, 2, 3];
    let times_2 = |x| x * 2;    // closure直接assign給變數
    let b: Vec<i32> = a.iter().map(|x| x * 2).collect();
    let c: Vec<i32> = a.iter().map(times_2).collect();
    println!("a: {:?}", a);
    println!("b: {:?}", b);
    println!("c: {:?}", c);
    // let f = vec![1.2,3.4];
    // let d: Vec<f64> = f.iter().map(times_2).collect();
}

執行example-closure範例2

上面演示了閉包也可以指派給變數,這個變數雖然我們沒有明確給予型別指派,但rust可以透過上下文推斷出來。如果我們把註解給取消,編譯器就會報錯,說在let d = ...裡使用的time_2參數應該要是float的類別,但是卻拿到i32:

map呼叫的fn不同

最後一個例子:

fn main() {
    let a = vec![1, 2, 3];
    let b: Vec<i32> = a.iter().map(|x| {
        println!("正在處理 x: {:?}", x);
        x + 2    // 多行的closure,記得最後一行表達式是回傳值
    }).collect();
    let c: Vec<i32> = a.iter()
        .inspect(|x| println!(" x在map前: {:?}", x))
        .map(|x| x + 2)
        .inspect(|x| println!(" x在map後: {:?}", x))
        .collect();
    let d: Vec<i32> = a.iter().map(|_| 0).collect();
    // let e: Vec<i32> = a.iter().map(|| 0).collect();
    println!("a: {:?}", a);
    println!("b: {:?}", b);
    println!("c: {:?}", c);
    println!("d: {:?}", d);
}

rust closure 範例3

  • let c = ...用到的inspect,可以檢驗當前的值,方便debug使用,如果又要串.filter.map...的話就可以幫助我們查驗每個階段的值分別會是什麼。
  • let d = ... 就是乘以0,都是0,所以不需要x,這時候||裡面還是要放一個佔位符,因為我們變數接到後直接拋棄不用所以寫成_。如果不放的話,像let e = ...,就會報錯,因為rust是強型別,要求簽章要一致,我們沒寫出來的i32等,是rust在背後幫忙補上了,不代表不需要寫哦,把e前面的註解拿掉就會報錯如下:

rust提醒需要傳變數

參考資料

本系列專案源始碼放置於 https://github.com/kenstt/demo-app


上一篇
18 今天來調教一下,哦不是,是調校一下 rust 效能
下一篇
20 gRPC初探:Hello world from rust tonic
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言