WebAssembly (wasm) là một đích biên dịch nhị phân chạy được trong trình duyệt ở tốc độ gần native, và ngày càng phổ biến ở phía máy chủ như một sandbox nhẹ. Go hỗ trợ wasm từ 1.11, và Go 1.21 thêm đích WASI. Vậy là cùng một mã Go bạn viết cho server có thể chạy trong tab trình duyệt hoặc trong một runtime wasm độc lập. Bài này đo thật cách biên dịch cả hai đích, chạy wasm bằng runtime, cách gọi qua lại JavaScript, và — điều quan trọng nhất khi cân nhắc dùng thật — kích thước binary.

Hai đích wasm, hai mục đích

Go có hai đích WebAssembly khác nhau, chọn qua biến GOOS:

# Trình duyệt: dùng package syscall/js, cần wasm_exec.js làm keo
GOOS=js GOARCH=wasm go build -o app.wasm

# WASI (server/standalone): chạy bằng wasmtime, wazero, wasmer...
GOOS=wasip1 GOARCH=wasm go build -o app.wasm

GOOS=js sinh wasm dành cho trình duyệt — nó giả định môi trường có JavaScript và dùng package syscall/js để tương tác. GOOS=wasip1 sinh wasm theo chuẩn WASI (WebAssembly System Interface) — chạy trong runtime wasm độc lập ngoài trình duyệt, có quyền truy cập hệ thống (file, stdin/stdout) qua giao diện WASI chuẩn. Cùng một mã Go, chỉ đổi GOOS/GOARCH.

Ảnh chụp đoạn mã Go nền tối minh hoạ WebAssembly với Go một mã nguồn chạy trên trình duyệt và WASI, hai đích wasm khác nhau, trình duyệt dùng syscall js cần wasm_exec js làm keo GOOS js GOARCH wasm go build o app wasm, WASI server standalone chạy bằng wasmtime wazero GOOS wasip1 GOARCH wasm go build o app wasm, cùng mã Go chỉ đổi GOOS GOARCH ra wasm func fib n int func main fmt Println Xin chào từ WebAssembly, syscall js gọi qua lại JavaScript chỉ js wasm go build js và wasm import syscall js đăng ký hàm Go cho JS gọi js Global Set congGo js FuncOf func this js Value args slice js Value any return args 0 Int cộng args 1 Int JS congGo 3 4 bằng 7 đọc ghi DOM js Global Get document, nạp wasm trên trình duyệt js wasm script src wasm_exec js keo runtime Go script const go bằng new Go WebAssembly instantiateStreaming fetch app wasm go importObject then r go run r instance

Hình 1: Hai đích — GOOS=js cho trình duyệt (dùng syscall/js, cần wasm_exec.js), GOOS=wasip1 cho WASI standalone. syscall/js cho phép đăng ký hàm Go để JavaScript gọi và thao tác DOM.

Chạy thật: mã Go nguyên vẹn trong wasm

wasm không chỉ biên dịch được mà chạy thật. Dùng wazero — một runtime wasm viết bằng Go thuần — để chạy binary WASI:

$ wazero run app_wasip1.wasm
Xin chào từ WebAssembly!
fib(40) = 102334155

Đo thật trong go-lab (Go 1.23): fmt.Println in ra qua stdout của WASI, fib(40) tính đúng 102334155. Toàn bộ runtime Go — goroutine, GC, map, slice — chạy nguyên trong sandbox wasm. Đây không phải một tập con hạn chế của Go; đó là Go đầy đủ, chỉ biên dịch sang một đích khác.

syscall/js: cầu nối với JavaScript

Với đích trình duyệt, package syscall/js là cách Go nói chuyện với thế giới JavaScript. Bạn đăng ký hàm Go cho JS gọi, và thao tác được DOM:

//go:build js && wasm
import "syscall/js"

js.Global().Set("congGo", js.FuncOf(func(this js.Value, args []js.Value) any {
	return args[0].Int() + args[1].Int() // JS: congGo(3,4) -> 7
}))

Sau khi wasm nạp, JavaScript gọi congGo(3, 4) và nhận 7 từ Go. Ngược lại, Go đọc/ghi DOM qua js.Global().Get("document"). Trên trình duyệt, wasm cần một file keo wasm_exec.js (16.687 byte, do Go phát hành) để khởi tạo runtime Go trước khi go.run() chạy binary. Đây là cơ chế cho các ứng dụng Go chạy hoàn toàn phía client — từ công cụ xử lý ảnh tới game.

Đo thật cái giá: kích thước binary

Đây là điều phải cân nhắc nghiêm túc trước khi dùng Go wasm cho web. Biên dịch cùng một chương trình nhỏ ra ba đích:

Ảnh chụp bảng kết quả đo thật nền tối Go biên dịch wasm chạy được nhưng binary 2,3 MB vì gói cả runtime, go build cộng wazero run Go 1.23 arm64 10 core, chạy thật app wasip1 wasm bằng wazero wazero run app wasip1 wasm Xin chào từ WebAssembly fib 40 bằng 102334155 mã Go chạy nguyên trong wasm, kích thước cùng mã ba đích GOOS js GOARCH wasm 2.383.451 trình duyệt GOOS wasip1 GOARCH wasm 2.360.548 WASI standalone GOOS linux GOARCH arm64 2.155.611 native để so, wasm 2,3 đến 2,4 MB gói cả runtime Go scheduler GC vào binary lớn cho web người dùng phải tải 2,3 MB trước khi chạy, rút gọn strip giúp rất ít mặc định 2.360.548 ldflags s w bỏ symbol debug 2.317.067 strip chỉ bớt 43 KB phần lớn kích thước là runtime không cắt được muốn wasm nhỏ chục KB phải dùng TinyGo đổi lại TinyGo hỗ trợ thư viện chuẩn không đầy đủ, cốt lõi 2 đích js wasm trình duyệt và wasip1 wasm WASI server syscall js gọi qua lại JS đăng ký hàm Go đọc ghi DOM keo js wasm cần wasm exec js 16.687 byte khởi tạo runtime kích thước 2,3 MB vì gói cả runtime cộng GC strip bớt 43 KB nhỏ hơn TinyGo cho wasm chục KB nhưng thư viện chuẩn hạn chế

Hình 2: Cùng mã — js/wasm 2.383.451 byte, wasip1/wasm 2.360.548 byte, native linux/arm64 2.155.611 byte. wasm ~2,3 MB vì gói cả runtime Go. Strip (-ldflags="-s -w") chỉ giảm còn 2.317.067 — bớt ~43 KB.

  • js/wasm: 2.383.451 byte (~2,38 MB).
  • wasip1/wasm: 2.360.548 byte.
  • native linux/arm64: 2.155.611 byte (để so).

wasm ~2,3-2,4 MB vì nó gói cả runtime Go — scheduler, garbage collector, toàn bộ hệ thống chạy goroutine — vào binary. Không như C hay Rust (runtime tối thiểu), Go mang cả cỗ máy đi theo. Với web, đây là điểm đau: người dùng phải tải 2,3 MB trước khi trang chạy được. Và strip giúp rất ít: -ldflags="-s -w" chỉ giảm xuống 2.317.067 — bớt vỏn vẹn ~43 KB, vì phần lớn kích thước là runtime không cắt được.

Đánh đổi cần cân nhắc

Go wasm hợp cho ứng dụng, không hợp cho widget nhỏ. 2,3 MB là quá lớn cho một nút bấm hay một hiệu ứng nhỏ trên trang — dùng JavaScript thuần đúng hơn. Nhưng cho một ứng dụng client phức tạp (trình chỉnh sửa, công cụ tính toán, game) mà bạn muốn tái dùng logic Go từ server, 2,3 MB tải một lần rồi cache lại là chấp nhận được. Cân theo tỷ lệ giữa kích thước tải và giá trị ứng dụng.

Muốn wasm nhỏ thật thì dùng TinyGo, nhưng mất mát. TinyGo — một compiler Go thay thế — sinh wasm nhỏ tới hàng chục KB vì dùng runtime tối giản. Đổi lại, nó không hỗ trợ đầy đủ thư viện chuẩn (reflect hạn chế, một số package không dùng được) và một số hành vi khác Go chính thống. Với widget nhỏ hoặc thiết bị nhúng, TinyGo là lựa chọn; với ứng dụng cần thư viện chuẩn đầy đủ, phải chấp nhận kích thước của Go chính thống.

WASI mở ra dùng wasm ngoài trình duyệt. GOOS=wasip1 không chỉ là "wasm cho server" — nó cho phép chạy code Go trong sandbox wasm an toàn (plugin, hàm serverless, edge computing) với runtime như wasmtime hay wazero. Đây là hướng đang lớn nhanh: một binary wasm chạy được trên mọi runtime WASI, không phụ thuộc OS/kiến trúc — tính di động còn cao hơn cả binary native của Go.

Ba ý mang về

  1. Go biên dịch sang WebAssembly qua hai đích: GOOS=js GOARCH=wasm cho trình duyệt (dùng syscall/js gọi qua lại JavaScript, cần wasm_exec.js làm keo) và GOOS=wasip1 GOARCH=wasm cho WASI standalone — đo thật, mã Go chạy nguyên trong wasm (fib(40)=102334155 qua wazero).
  2. Binary wasm ~2,3 MB vì gói cả runtime Go (scheduler, GC): đo thật js/wasm 2.383.451 byte, wasip1 2.360.548 byte — lớn cho web vì người dùng phải tải hết trước khi chạy, và strip chỉ bớt ~43 KB.
  3. Muốn wasm nhỏ (chục KB) phải dùng TinyGo nhưng đổi lấy thư viện chuẩn hạn chế — chọn Go chính thống cho ứng dụng cần đầy đủ stdlib, TinyGo cho widget nhỏ; WASI mở ra dùng wasm như sandbox di động ngoài trình duyệt.

Phần sau ta xuống tầng thấp nhất của Go, nơi kiểu an toàn bị gạt sang bên: Phần sau mổ xẻ unsafe.Pointer — bốn mẫu chuyển đổi hợp lệ mà runtime bảo đảm, và vì sao đi chệch chúng là hành vi không xác định.