Tuần này trên Hacker News có một post hơn 700 điểm: ai đó cho Need for Speed Underground 2 chạy online ngay trong browser. Một game PC từ 2004, viết bằng C++, giờ chỉ cần mở một tab là chơi được. Đằng sau những project kiểu này hầu như luôn là WebAssembly (Wasm) và Emscripten.

Nhiều bạn developer Việt Nam nghĩ Wasm là thứ gì đó rất "hardcore", chỉ dành cho game engine hay team lớn. Thực tế mình đã dùng Wasm cho những việc rất bình thường: chạy một thư viện xử lý ảnh viết bằng C trên frontend, port một parser cũ sang web thay vì viết lại bằng JS, hay chuyển một tác vụ nặng CPU từ server xuống client để giảm chi phí. Bài này chia sẻ quy trình thực tế mình dùng, kèm những cái bẫy mà docs không nói rõ.

Khi nào nên dùng WebAssembly (và khi nào không)

Trước khi cài gì, hãy tự hỏi: bài toán của mình có thực sự cần Wasm không?

Nên dùng khi:

  • Bạn đã có sẵn một codebase C/C++/Rust ổn định và muốn đưa lên web mà không viết lại
  • Tác vụ nặng CPU: xử lý ảnh/video, nén dữ liệu, crypto, physics, parse file binary lớn
  • Cần hiệu năng ổn định, không bị GC pause của JS làm giật

Không nên dùng khi:

  • Logic chủ yếu là thao tác DOM hoặc gọi API, vì Wasm không truy cập DOM trực tiếp, mọi thứ vẫn phải đi qua JS
  • Code nhỏ, chạy vài millisecond: chi phí copy dữ liệu qua lại giữa JS và Wasm có thể còn lớn hơn phần tiết kiệm được
  • Team không ai đọc được C/C++, vì debug sẽ rất đau

Kinh nghiệm của mình: Wasm thắng rõ rệt khi bạn đưa vào một khối dữ liệu lớn, xử lý nhiều vòng bên trong Wasm, rồi trả kết quả ra một lần. Nếu gọi qua lại hàng nghìn lần với dữ liệu nhỏ thì đừng kỳ vọng nhanh hơn JS (V8 hiện tại JIT rất tốt).

Pipeline build: từ file .c đến file .wasm

Luồng build cơ bản trông như thế này:

flowchart LR A[Source C/C++] --> B[emcc / em++] B --> C[Clang + LLVM] C --> D[Binaryen wasm-opt] D --> E[app.wasm] B --> F[app.js glue code] E --> G[Browser] F --> G

emcc là wrapper quanh Clang/LLVM. Output gồm hai phần: file .wasm chứa code đã compile, và file .js là "glue code" lo việc load module, cấp phát memory, giả lập filesystem và các syscall.

Cài emsdk và compile thử một hàm đơn giản:

# Cài emsdk (chỉ cần làm một lần) git clone https://github.com/emscripten-core/emsdk.git cd emsdk ./emsdk install latest ./emsdk activate latest source ./emsdk_env.sh emcc --version # kiểm tra, mình đang dùng nhánh 4.0.x # File grayscale.c: chuyển ảnh RGBA sang grayscale cat > grayscale.c << 'EOF' #include <emscripten.h> #include <stdint.h> EMSCRIPTEN_KEEPALIVE void grayscale(uint8_t *px, int len) { for (int i = 0; i < len; i += 4) { uint8_t g = (px[i] * 77 + px[i+1] * 150 + px[i+2] * 29) >> 8; px[i] = px[i+1] = px[i+2] = g; } } EOF # Build ra ES module, bật tối ưu và cho phép memory tự tăng emcc grayscale.c -O3 -o grayscale.mjs -sMODULARIZE=1 -sEXPORT_ES6=1 -sALLOW_MEMORY_GROWTH=1 -sEXPORTED_FUNCTIONS=_grayscale,_malloc,_free -sEXPORTED_RUNTIME_METHODS=HEAPU8 

Vài flag quan trọng:

  • -O3: tối ưu mạnh. Khi release có thể thử thêm -Oz để giảm kích thước file nếu bandwidth quan trọng hơn tốc độ.
  • -sMODULARIZE=1 -sEXPORT_ES6=1: output là ES module, import gọn gàng với Vite hay webpack, không làm bẩn global scope.
  • -sALLOW_MEMORY_GROWTH=1: cho phép heap tự mở rộng. Không bật thì xử lý ảnh lớn sẽ gặp lỗi Cannot enlarge memory arrays.
  • EMSCRIPTEN_KEEPALIVE: không có cái này, dead code elimination sẽ xóa hàm của bạn và bạn sẽ ngồi debug cả buổi vì sao export bị undefined. Mình đã từng mất nguyên một buổi chiều vì nó.

Gọi Wasm từ JavaScript: quản lý memory đúng cách

Đây là phần mọi người hay làm sai nhất. Wasm có một linear memory riêng, JS không truyền object trực tiếp vào được. Bạn phải malloc một vùng trong heap của Wasm, copy dữ liệu vào, gọi hàm, copy kết quả ra, rồi free.

sequenceDiagram participant JS as JavaScript participant W as Wasm Module participant H as Linear Memory JS->>W: _malloc(size) W-->>JS: pointer JS->>H: HEAPU8.set(pixels, ptr) JS->>W: _grayscale(ptr, len) W->>H: xử lý tại chỗ JS->>H: đọc kết quả bằng subarray JS->>W: _free(ptr)

Code thực tế:

import createModule from './grayscale.mjs'; const wasm = await createModule(); export function toGrayscale(canvas) { const ctx = canvas.getContext('2d'); const img = ctx.getImageData(0, 0, canvas.width, canvas.height); const len = img.data.length; const ptr = wasm._malloc(len); try { wasm.HEAPU8.set(img.data, ptr); wasm._grayscale(ptr, len); // Lấy HEAPU8 SAU khi gọi hàm: nếu memory đã grow, // view cũ bị detach và sẽ trả về mảng rỗng img.data.set(wasm.HEAPU8.subarray(ptr, ptr + len)); ctx.putImageData(img, 0, 0); } finally { wasm._free(ptr); } } 

Hai bài học xương máu:

  1. Luôn free trong finally. Wasm không có GC cho heap của C. Quên free thì mỗi lần user bấm nút, memory lại tăng lên, và tab sẽ crash sau vài chục lần thao tác.
  2. Đừng cache HEAPU8 vào một biến dùng lâu dài. Khi ALLOW_MEMORY_GROWTH bật và heap được mở rộng, ArrayBuffer cũ bị detach. Mỗi lần cần thì truy cập lại wasm.HEAPU8.

Một tip nữa: tác vụ nặng hơn vài chục millisecond thì đẩy vào Web Worker. Wasm chạy trên main thread vẫn block UI như JS bình thường.

Deploy: header, threads và kích thước file

Code chạy ngon ở local, deploy lên thì lỗi. Ba nguyên nhân hay gặp nhất:

1. Sai MIME type. File .wasm phải được serve với Content-Type: application/wasm, nếu không WebAssembly.instantiateStreaming sẽ fail và fallback sang cách load chậm hơn. Nginx bản mới đã có sẵn, nhưng một số CDN và server tự build thì chưa.

2. Cần threads (-pthread). Emscripten dùng SharedArrayBuffer, mà browser chỉ cho dùng khi trang ở trạng thái cross-origin isolated. Nghĩa là phải có 2 header COOP/COEP. Port game hay engine nặng gần như chắc chắn cần cái này.

Script Python nhỏ mình dùng để test local với đúng header:

# serve.py - chạy: python3 serve.py from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer class IsolatedHandler(SimpleHTTPRequestHandler): extensions_map = { **SimpleHTTPRequestHandler.extensions_map, '.wasm': 'application/wasm', '.mjs': 'text/javascript', } def end_headers(self): self.send_header('Cross-Origin-Opener-Policy', 'same-origin') self.send_header('Cross-Origin-Embedder-Policy', 'require-corp') self.send_header('Cache-Control', 'no-cache') super().end_headers() if __name__ == '__main__': print('Serving on http://localhost:8080') ThreadingHTTPServer(('0.0.0.0', 8080), IsolatedHandler).serve_forever() 

Mở DevTools console và gõ crossOriginIsolated. Nếu ra true là ổn. Lưu ý: bật COEP thì mọi resource bên thứ ba (ảnh, script từ CDN khác) đều phải có header CORP hoặc CORS phù hợp, không thì bị block. Hãy kiểm tra kỹ trước khi bật trên production.

3. File quá nặng. Một engine port sang có thể nặng tới vài chục MB. Những cách giảm:

  • Build với -Oz và chạy thêm wasm-opt -Oz (đi kèm Binaryen trong emsdk)
  • Bật Brotli trên server: file .wasm thường nén được 60-70%
  • Tách asset (texture, audio) ra khỏi --preload-file, load lazy bằng fetch khi cần
  • Set Cache-Control: immutable cho file .wasm đã có hash trong tên, để user chỉ phải tải lần đầu

Kết luận

WebAssembly không còn là công nghệ thử nghiệm. Nó đã đủ chín để chạy cả game AAA cũ trong browser, và tất nhiên đủ để giải những bài toán thường ngày của bạn. Vài việc cụ thể bạn có thể làm ngay:

  1. Bắt đầu nhỏ: chọn một hàm thuần tính toán trong project (resize ảnh, tính checksum, parse CSV lớn) và port thử bằng emcc. Đo bằng performance.now() để so sánh với bản JS trước khi quyết định.
  2. Luôn build với MODULARIZE + EXPORT_ES6 để tích hợp sạch với bundler hiện đại.
  3. Quản lý memory nghiêm túc: malloc/free theo cặp, free trong finally, không cache view của heap.
  4. Đưa tác vụ nặng vào Web Worker để UI không bị đơ.
  5. Check header trước khi deploy: MIME type application/wasm, COOP/COEP nếu dùng threads, Brotli và cache dài hạn cho file .wasm.

Nếu team bạn đang giữ một đống code C/C++ cũ mà ai cũng ngại đụng vào, có khi Wasm chính là cách để nó sống tiếp trên web mà không cần viết lại một dòng logic nào.