Wasm dari Go / C++ — WebAssembly

Wasm dari Go dan C++ Selain Rust, Go dan C/C++ juga bisa compile ke WebAssembly. Setiap bahasa punya trade-off berbeda. Go → WebAssembly // main.go package…

Selain Rust, Go dan C/C++ juga bisa di-compile ke WebAssembly. Fungsinya bisa sama persis, tapi bawaan yang ikut masuk ke file .wasm beda jauh. Bayangin Bu Sri memesan kue lapis untuk tamu Homestay Kabut dari tiga toko. Toko pertama mengirim kuenya bersama koki dan petugas kebersihan yang ikut menginap. Toko kedua mengirim kue yang sama dengan satu asisten kecil. Toko ketiga mengirim kuenya saja, plus secarik kertas cara penyajian. Kuenya adalah fungsi hargaInap dan grayscale. Koki dan petugas kebersihan itu runtime Go (scheduler goroutine dan garbage collector), asisten kecilnya runtime TinyGo, dan kertas cara penyajiannya glue code JavaScript dari Emscripten.

🧪 Tebakan cepat: hargaInap dan grayscale ditulis dalam Go lalu di-build dengan go build, dan ditulis dalam C lalu di-build dengan clang. File .wasm versi Go kira-kira berapa kali lebih besar: 5 kali, 100 kali, atau lebih dari 5.000 kali? Tebak dulu, jawabannya ada di bagian "Ukurannya, diukur".

Go: GOOS=js, GOARCH=wasm, dan wasm_exec.js

Compiler Go biasa bisa langsung menghasilkan Wasm. Fungsi Go dipasang ke JavaScript lewat package syscall/js:

// main.go
package main

import "syscall/js"

func hargaInap(malam, hargaPerMalam int32) int32 {
	return malam * hargaPerMalam
}

func grayscale(px []byte) {
	for i := 0; i+3 < len(px); i += 4 {
		abu := byte((uint32(px[i])*77 + uint32(px[i+1])*150 + uint32(px[i+2])*29) >> 8)
		px[i], px[i+1], px[i+2] = abu, abu, abu
	}
}

func main() {
	js.Global().Set("hargaInap", js.FuncOf(func(this js.Value, args []js.Value) any {
		return hargaInap(int32(args[0].Int()), int32(args[1].Int()))
	}))
	js.Global().Set("grayscale", js.FuncOf(func(this js.Value, args []js.Value) any {
		buf := make([]byte, args[0].Get("length").Int())
		js.CopyBytesToGo(buf, args[0]) // salin Uint8Array ke slice Go
		grayscale(buf)
		js.CopyBytesToJS(args[0], buf) // salin hasilnya balik
		return nil
	}))
	select {} // jangan biarkan main selesai
}
$ GOOS=js GOARCH=wasm go build -o harga.wasm .
$ cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" .

Di halaman, const go = new Go(), lalu WebAssembly.instantiateStreaming(fetch('harga.wasm'), go.importObject), lalu go.run(instance). Setelah itu hargaInap(2, 350000) mengembalikan 700000.

Hasilnya: harga.wasm 2.042.356 byte (593.144 byte setelah gzip). Hampir semuanya runtime Go: garbage collector, scheduler goroutine, dan dukungan syscall/js. Penyeberangannya juga mahal: tiap panggilan lewat js.FuncOf membangunkan scheduler Go. Di Node 22, 100.000 panggilan hargaInap makan sekitar 780 ms. 100.000 panggilan export Wasm biasa (dari TinyGo atau C di bawah) cuma sekitar 0,7 ms.

📌 Go tanpa syscall/js: GOOS=wasip1 dan //go:wasmexport

Go 1.21 menambah target GOOS=wasip1 (WASI Preview 1, antarmuka sistem untuk Wasm di luar browser). Go 1.24 menambah directive //go:wasmexport, yang meng-export fungsi Go langsung sebagai export Wasm, dan -buildmode=c-shared untuk modul library (reactor) yang gak menjalankan main:

//go:wasmexport hargaInap
func hargaInap(malam, hargaPerMalam int32) int32 {
	return malam * hargaPerMalam
}

GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o harga.wasm . menghasilkan 1.931.836 byte dengan 10 impor wasi_snapshot_preview1 (jam, angka acak, tulis ke stdout, dan lainnya). Di Node, impor itu disediakan node:wasi, dan wasi.initialize(instance) harus dipanggil sebelum hargaInap. Untuk grayscale, modulnya meng-export pesanBuffer(n) supaya JS menulis foto ke tempat yang dipesan Go sendiri. Ukurannya tetap sekitar 2 MB, karena runtime-nya sama.

TinyGo: Go yang sama, file jauh lebih kecil

TinyGo adalah compiler Go lain yang dibangun di atas LLVM. Kode yang sama di-build dengan TinyGo 0.43.0:

Perintah Ukuran .wasm
tinygo build -target=wasm -o harga.wasm . (versi syscall/js) 236.916 byte
sama, ditambah -no-debug 54.371 byte
tinygo build -target=wasip1 -buildmode=c-shared -no-debug ... 27.767 byte
tinygo build -target=wasm-unknown -no-debug ... 2.567 byte, 0 impor

C dengan Emscripten

Emscripten adalah toolchain C/C++ ke Wasm: compiler emcc (di atas clang/LLVM), libc, dan glue code JavaScript yang meniru lingkungan C (heap, file system virtual, OpenGL lewat WebGL). Pelajaran ini memakai Emscripten 6.0.12.

// harga.c
#include <emscripten.h>
#include <stdint.h>

EMSCRIPTEN_KEEPALIVE
int32_t hargaInap(int32_t malam, int32_t hargaPerMalam) {
  return malam * hargaPerMalam;
}

EMSCRIPTEN_KEEPALIVE
void grayscale(uint8_t *px, int32_t len) {
  for (int32_t i = 0; i + 3 < len; i += 4) {
    uint8_t abu = (px[i] * 77 + px[i + 1] * 150 + px[i + 2] * 29) >> 8;
    px[i] = abu;
    px[i + 1] = abu;
    px[i + 2] = abu;
  }
}
$ emcc harga.c -O2 -sMODULARIZE -sEXPORT_NAME=createHarga \
    -sEXPORTED_FUNCTIONS=_malloc,_free -sEXPORTED_RUNTIME_METHODS=ccall,cwrap,HEAPU8 \
    -o harga.js
createHarga().then((M) => {
  console.log(M._hargaInap(2, 350000)); // 700000
  console.log(M.ccall('hargaInap', 'number', ['number', 'number'], [2, 350000])); // 700000
  const ptr = M._malloc(4);               // pesan 4 byte di heap C
  M.HEAPU8.set([200, 120, 40, 255], ptr); // tulis satu piksel
  M._grayscale(ptr, 4);
  console.log(M.HEAPU8.slice(ptr, ptr + 4)); // Uint8Array(4) [135, 135, 135, 255]
  M._free(ptr);                           // kembalikan ke heap
});

C tanpa glue: clang --target=wasm32

clang bisa menghasilkan Wasm tanpa Emscripten, asal kodenya gak butuh libc. Apple clang bawaan macOS gak punya target Wasm; pakai clang dari LLVM, wasi-sdk, atau yang ikut emsdk. harga-polos.c sama dengan harga.c, tapi tanpa #include <emscripten.h>, dan tiap EMSCRIPTEN_KEEPALIVE diganti __attribute__((export_name("hargaInap"))) (dan "grayscale"):

$ clang --target=wasm32 -nostdlib -O2 -Wl,--no-entry -Wl,--export=__heap_base \
    -Wl,--strip-all harga-polos.c -o harga-polos.wasm

Ukurannya, diukur

Semua build berisi hargaInap dan grayscale dengan rumus yang sama:

Build .wasm Glue JS
Go 1.27.2, GOOS=js 2.042.356 byte wasm_exec.js 16.992 byte
Go 1.27.2, GOOS=wasip1, c-shared 1.931.836 byte host WASI
TinyGo 0.43.0, -target=wasm -no-debug 54.371 byte wasm_exec.js 17.729 byte
Rust + wasm-pack (pelajaran sebelumnya) 14.896 byte 5.311 byte
Emscripten 6.0.12, dengan _malloc dan HEAPU8 7.174 byte 10.511 byte
TinyGo 0.43.0, -target=wasm-unknown -no-debug 2.567 byte gak ada
clang 24, --target=wasm32 -nostdlib 363 byte gak ada

Jawaban tebakannya: lebih dari 5.000 kali. 2.042.356 dibagi 363 kira-kira 5.600. Hampir seluruh selisihnya runtime Go, bukan fungsi kita, dan setelah gzip pun versi Go masih 593.144 byte.

Kapan pakai yang mana:

⚠️ Jebakan yang sering ketemu

TL;DR: Go standar (GOOS=js GOARCH=wasm dengan syscall/js, js.FuncOf, select {}, dan wasm_exec.js dari lib/wasm di Go yang sama) menghasilkan 2.042.356 byte untuk hargaInap dan grayscale, karena runtime dan garbage collector Go ikut; panggilan lewat js.FuncOf juga lambat. GOOS=wasip1 (Go 1.21) dengan //go:wasmexport dan -buildmode=c-shared (Go 1.24) tetap sekitar 1,9 MB. TinyGo memakai runtime kecil dan LLVM: 54.371 byte (-target=wasm -no-debug) sampai 2.567 byte (-target=wasm-unknown, panggil _initialize() dulu), dengan dukungan package yang lebih terbatas. Emscripten (emcc) adalah toolchain C/C++ lengkap: EMSCRIPTEN_KEEPALIVE atau -sEXPORTED_FUNCTIONS=_nama, glue .js, _malloc/HEAPU8/_free untuk data. clang --target=wasm32 -nostdlib tanpa glue: 363 byte, memorinya kamu urus sendiri.

Yang akan kamu pelajari