23 điểm bởi GN⁺ 2025-06-08 | 1 bình luận | Chia sẻ qua WhatsApp
  • Giải thích chi tiết quy trình port mã C/C++ sang WebAssembly bằng Emscripten để tạo ứng dụng web chạy trong trình duyệt, dựa trên ví dụ thực tế về bộ giải Rubik’s Cube
  • Từng bước xử lý những khó khăn cụ thể và cách giải quyết vấn đề gặp phải trong môi trường trình duyệt/WebAssembly, từ Hello World đến đa luồng, callback, lưu trữ bền vững, mô-đun hóa
  • Tập trung vào xử lý sự cố thực chiến như khởi tạo bất đồng bộ trong JavaScript, export hàm, Web Worker và vấn đề Spectre, lưu trữ bền vững IndexedDB thông qua IDBFS
  • Liên tục nhấn mạnh rằng các tầng trừu tượng của Emscripten thực tế thường bị 'rò rỉ' (leaky abstractions), và cần hiểu giới hạn cũng như cấu trúc bên trong của nền tảng web
  • Đây là hướng dẫn dựa trên kinh nghiệm thực tế, mang lại trợ giúp và bí quyết thiết thực cho các lập trình viên muốn chuyển thư viện C/C++ sẵn có lên web, thông qua trải nghiệm port một codebase C phức tạp sang web chỉ với kiến thức JavaScript/HTML tối thiểu ở phía frontend

Giới thiệu

  • Gần đây tác giả đã thực hiện một dự án triển khai thuật toán nghiệm tối ưu cho Rubik’s Cube dưới dạng ứng dụng web
  • Ghi lại quá trình biên dịch bộ giải tối ưu Rubik’s Cube viết bằng C sang WebAssembly bằng Emscripten để chạy trong trình duyệt web
  • Lý do chính để dùng WebAssembly là vì có thể đạt được hiệu năng gần như native so với JavaScript trên web
  • Bài viết này không phải một tutorial phát triển web theo kiểu truyền thống, mà là một ‘hành trình đau khổ’ dành cho lập trình viên muốn port mã C/C++ hiện có lên web
  • Ngay cả khi không có nhiều kinh nghiệm phát triển web, bạn vẫn có thể theo được nếu nắm cấu trúc cơ bản của HTML, JavaScript và cách dùng công cụ dành cho nhà phát triển của trình duyệt

Thiết lập môi trường

  • Có thể xem toàn bộ mã ví dụ tại kho git, github
  • Cần cài Emscripten (xem trang chính thức để biết cách cài), và dùng web server như darkhttpd hoặc Python http.server
  • Mã ví dụ trong tutorial được kiểm thử trên Linux và các hệ UNIX. Với người dùng Windows, khuyến nghị dùng WSL (Windows Subsystem for Linux)

Hello World

  • Nếu biên dịch mã Hello World bằng C với lệnh emcc -o index.html hello.c thì sẽ tạo ra ba tệp: index.html (trang web), index.wasm (bytecode WebAssembly), index.js (JavaScript glue code)
  • Có thể chạy trong trình duyệt hoặc Node.js, và mỗi môi trường có cách sử dụng khác nhau
  • Nếu chỉ muốn tạo .wasm thì dùng tùy chọn -sSTANDALONE_WASM
  • Emscripten cũng có thể chỉ tạo .wasm, nhưng trong đa số trường hợp JavaScript glue code vẫn là thành phần bắt buộc

Intermezzo I: WebAssembly là gì?

  • WebAssembly (WASM) là một ngôn ngữ cấp thấp chạy trên máy ảo hiệu năng cao bên trong trình duyệt web
  • WASM được hỗ trợ trên mọi trình duyệt lớn từ năm 2017
  • Ban đầu Emscripten chuyển mã C/C++ sang tập con JavaScript có tên asm.js, nhưng sau khi WASM xuất hiện thì đã chuyển hướng sang nền tảng này
  • Nó cũng có dạng biểu diễn văn bản và có cấu trúc dựa trên stack. Cho đến gần đây chỉ hỗ trợ kiến trúc 32-bit nên không thể dùng quá 4GB bộ nhớ, nhưng WASM64 đang dần được đưa vào các trình duyệt

Build thư viện

  • Trình bày ví dụ cơ bản về việc build hàm C multiply() sang WASM rồi gọi từ JavaScript
  • Khi build mặc định, Emscripten sẽ thêm dấu gạch dưới (_) vào tên hàm (ví dụ: _multiply)
  • Để export hàm ra bên ngoài cần chỉ định tùy chọn -sEXPORTED_FUNCTIONS
  • Vì quá trình khởi tạo khi nạp thư viện là bất đồng bộ, nên cần xử lý async bằng onRuntimeInitialized hoặc await
  • Mã thực hành nằm trong thư mục 01_library của kho lưu trữ

Intermezzo II: JavaScript và DOM

  • Để truy cập và chỉnh sửa các thành phần của HTML từ JavaScript, cần dùng Document Object Model (DOM)
  • Có thể xây dựng UI động bằng event listener (addEventListener), toán tử/hàm dựng sẵn và nhiều công cụ khác
  • Bài viết giải thích cấu trúc liên kết HTML/JavaScript cơ bản với ô nhập, nút bấm và phần hiển thị kết quả
  • Đồng thời cũng hướng dẫn các cách thực tế để tách/gộp script và những vấn đề liên quan (ví dụ: cách dùng defer, thứ tự tải phần tử DOM)

Mô-đun hóa và nạp thư viện

  • Để nhúng nhiều thư viện WASM hoặc tái sử dụng cho cả Node.js lẫn web, có thể build ở dạng mô-đun bằng các tùy chọn MODULARIZE, EXPORT_NAME
  • Phần mở rộng .mjs (mô-đun ES6) được khuyến nghị để tương thích tốt hơn với Node.js
  • Có thể dùng mô-đun theo kiểu import MyLibrary from ... ở cả môi trường web và Node

Đa luồng

  • Trong WebAssembly có thể port mã đa luồng dựa trên pthreads để tăng hiệu năng
  • Có thể tạo nhiều luồng bên trong hàm để thực hiện các tác vụ tính toán song song (ví dụ: đếm số nguyên tố)
  • Khi build cần các tùy chọn -pthread, -sPTHREAD_POOL_SIZE=
  • Trong trình duyệt thực tế, cần thêm các HTTP header như Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: require-corp
  • Tất cả ví dụ đều có trong thư mục 03_threads của kho lưu trữ

Intermezzo III: Web Workers và Spectre

  • Đa luồng của Emscripten được triển khai bằng Web Workers (Web Workers là các tiến trình riêng biệt với mô hình giao tiếp dựa trên message)
  • Việc dùng bộ nhớ chia sẻ (SharedArrayBuffer) đi kèm các ràng buộc bảo mật
  • Sau khi lỗ hổng Spectre xuất hiện vào năm 2018, các yêu cầu về cross-origin isolation cùng những header liên quan đã trở thành bắt buộc

Lưu ý về việc chặn main thread

  • Nếu tác vụ dài BLOCK luồng UI chính của trình duyệt thì trải nghiệm người dùng sẽ giảm mạnh
  • Để tránh điều này, tác giả đưa vào web worker để tách rõ phần xử lý UI/đầu vào và phần tính toán
  • Triển khai giao tiếp dựa trên sự kiện giữa main thread và worker bằng postMessage, onmessage
  • Chỉ nạp mô-đun Emscripten-WASM trong web worker để chuyên xử lý các phép tính bất đồng bộ

Hàm callback

  • Khi truyền con trỏ hàm (callback) làm tham số cho hàm C, đối tượng hàm của JavaScript không thể tự động liên kết với nó
  • Cần dùng các API do Emscripten cung cấp như addFunction(), UTF8ToString(), đồng thời khi build phải thêm các tùy chọn -sEXPORTED_RUNTIME_METHODS, -sALLOW_TABLE_GROWTH
  • Callback chỉ hoạt động ổn định khi được gọi trên main thread (không thể truy cập từ web worker)

Lưu trữ bền vững

  • Để lưu dữ liệu lâu dài trong trình duyệt của người dùng, bài viết sử dụng IDBFS (hệ thống tệp dựa trên IndexedDB) của Emscripten
  • Khi build cần cờ --lidbfs.js và thiết lập ban đầu bằng --pre-js v.v.
  • Trong mã C vẫn có thể dùng nguyên các hàm nhập/xuất tệp (fopen, fread, fwrite), nhưng việc phản ánh/đồng bộ dữ liệu thực tế bắt buộc phải được ánh xạ và sync tường minh từ phía JavaScript
  • Do đặc tính sandbox/chính sách bảo mật của trình duyệt, chỉ Node.js mới có thể truy cập trực tiếp hệ thống tệp cục bộ; còn trên trình duyệt thì cần dùng backend như IDBFS để lưu dữ liệu bền vững một cách an toàn

Kết luận

  • Thông qua toàn bộ tutorial này, người đọc có thể học chi tiết một phương pháp thực tế để chạy mã C/C++ native phức tạp trên trình duyệt một cách an toàn và không suy giảm hiệu năng đáng kể, chỉ với một lượng JavaScript và HTML tối thiểu
  • Bạn sẽ trải nghiệm các trở ngại và cách giải quyết trên mọi trục cốt lõi trong môi trường thực tế như đa luồng, callback, xử lý bất đồng bộ, tích hợp lưu trữ, đồng thời nắm được các thiết lập liên quan và các ràng buộc mới nhất của trình duyệt
  • Có thể tham khảo các ví dụ trong kho Git được cung cấp để áp dụng và mở rộng vào dự án của riêng mình

1 bình luận

 
GN⁺ 2025-06-08
Ý kiến Hacker News
  • Mong mọi người để ý đến việc bài viết đã đổi phần mở rộng từ .js sang .mjs; cảm giác đồng cảm rất thực tế với chuyện dùng phần mở rộng nào rồi cuối cùng cũng sẽ đụng vấn đề. Với tư cách là người đã từng dùng đủ loại hệ thống module từ dojo, CommonJS, AMD, ESM, webpack, esbuild, rollup..., tôi thấy đồng cảm 100% với nhận xét này.
    • Việc chuyển từ commonjs sang esm quả thực là một thay đổi lớn, gần giống như lúc chuyển từ python2 sang python3, nhưng so với kỳ vọng thì lợi ích thu được lại ít mà sự phiền toái thì tăng lên. Nhiều thư viện giờ chỉ còn hỗ trợ esm, nên dạo này thực tế là phải vào tab versions của npm, chọn phiên bản được tải nhiều nhất trong một tháng gần đây, và rất có thể đó là phiên bản commonjs cuối cùng. Rõ ràng esm có thể xem là một hệ thống module tiến bộ hơn, nhưng việc tc39 gần như cố tình làm cho nó không tương thích với commonjs, như top-level await, thì thật sự khó hiểu.
    • Lịch sử module trong js thật sự gần như là một chấn thương. Giờ trình duyệt còn đưa thêm import maps vào, nên cũng tò mò không biết sắp tới sẽ phát sinh thêm những vấn đề “thú vị” nào nữa.
    • Gần đây tôi mới biết đối tượng Function có thể biên dịch bất kỳ mã JS nào ngay lúc chạy, nên trong môi trường của tôi, nơi thậm chí không dùng được cả import, nó đang đóng vai trò như một chiếc phao cứu sinh và cực kỳ hữu ích. Có thể trong hệ sinh thái JS chuyện này không quá cần thiết, nhưng với tôi thì nó giúp được rất nhiều.
    • Vì thế mọi người nên dùng bun.sh.
    • Có phải cũng có thể dùng .esm.js không?
  • Nếu muốn chỉ ra thêm những phần có thể gây vấn đề về lâu dài trong bài này, thì tôi khuyên nên dùng let hoặc const thay cho từ khóa var. var vẫn còn chạy được, nhưng hầu hết lập trình viên JS ngày nay đều dùng linter để cấm nó. var chỉ hỗ trợ function scope, nên đây là điểm mà phần lớn lập trình viên từ ngôn ngữ khác sớm muộn cũng sẽ bị nhầm. Về vấn đề porting ứng dụng native, có ví dụ về việc hardcode copy/paste lúc compile-time bằng Ctrl-C, Ctrl-V, nên chạy trên Linux và Windows nhưng trên Mac thì không hoạt động. Trên web thì phải xử lý theo kiểu phát hiện các sự kiện copy, paste. Tôi cũng từng thấy các framework như Unity vì hardcode phím nên trên Mac không thể copy/paste. Với đa số game thì không cần, nhưng khi đưa các tính năng cần copy/paste lên web thì đây gần như chắc chắn sẽ thành vấn đề.
  • Than phiền rằng quá ghét multithreading trên web/NodeJS. Điều đáng tiếc là thay vì có các primitive đồng bộ như mutex hay rwlock để có thể truyền chính giá trị giữa các context, ví dụ như v8 isolates, thì thứ được đưa vào thực tế lại gần như chỉ là SharedArrayBuffer, vốn hầu như vô dụng. Việc đồng bộ giữa các thread rốt cuộc vẫn thành cấu trúc phải làm thunking và copy dữ liệu qua một lớp RPC. Ứng dụng production của công ty tôi là một ứng dụng cực lớn, dùng 70~100GB RAM, vốn đã như vậy từ trước khi tôi tham gia, nên chúng tôi đang tìm một cách giải quyết rất kỳ quặc: quản lý trực tiếp các trang bộ nhớ và cấu trúc dữ liệu tùy biến bằng native code, đồng thời giảm tối đa serialize/deserialize. Vấn đề là v8 dùng utf16 cho mã hóa chuỗi, nên việc xử lý giá trị JS ở lớp native rất tốn kém.
    • Tò mò không biết ứng dụng dùng 100GB RAM này có thực sự nhất thiết phải là webapp không. Nghe có vẻ đây nên là một công cụ nội bộ viết bằng ngôn ngữ như C# thì hợp lý hơn.
  • Hệ sinh thái này hỗn loạn đến mức nghe gọi nó là “dành cho kẻ khổ dâm” lại thấy còn bình thường hơn.
    • Có khi bản thân chữ đó đã mặc nhiên bao hàm sự hỗn loạn rồi.
  • Bài viết được viết rất tốt, hơn nữa còn bắt đầu bằng một lựa chọn theo lộ trình khó và phức tạp khiến tôi bất ngờ. Càng thấy rõ việc thiết lập dự án mới là phần khó nhất. Đáng khen là tác giả đã đụng ngay vào vấn đề bảo mật/header, nhưng cũng có ý kiến rằng vấn đề thường được dự đoán trước ở đây là CORS. Công ty chúng tôi cũng đang build bằng emscripten/C++, và còn thêm cả WebGPU/shader lẫn WebAudio, nên phía trước có vẻ sẽ là một hành trình còn căng hơn nữa.
  • Trước đây tôi từng mơ hồ nghĩ rằng biên dịch mã trong trình duyệt chắc sẽ “chậm”, nhưng OP đã giải thích rất rõ rằng không hẳn như vậy. Dự án Emscripten cũng nhấn mạnh rằng “nhờ sự kết hợp của LLVM, Emscripten, Binaryen và WebAssembly, đầu ra có kích thước nhỏ và chạy với tốc độ gần như native” (emscripten.org).
    • Hôm nay đúng là một ngày kiểu “hội chứng xe buýt vàng” đối với tôi. Mới tuần trước thôi tôi còn chưa biết Emscripten là gì, vậy mà khi gắn SDL vào dự án tôi đã gặp chú thích về các target APPLE, MSVC, EMSCRIPTEN trong CMake, rồi ngay hôm nay lại thấy nhắc đến Emscripten trên hn. Giờ đúng là đến lúc phải dành thời gian tìm hiểu sâu nghiêm túc.
    • Cụm “tốc độ gần như native” nghe khá chủ quan; tôi không tìm thấy dữ liệu số liệu cụ thể trong tài liệu để biết thực tế nhanh đến mức nào.
  • Bài viết rất hữu ích, và bản thân tôi cũng đang có kế hoạch biên dịch một compiler viết bằng C sang WebAssembly để làm thành một web playground. Nhân tiện, các trình duyệt hiện đại giờ có thể dùng SQLite thông qua JavaScript; tôi tự hỏi liệu điều đó có thể làm được trong wasm không. Nếu emscripten có thể nối các lời gọi API sqlite trong mã C sang cơ sở dữ liệu sqlite của trình duyệt thì sẽ quá lý tưởng, rất đáng để tìm hiểu thêm.
  • Tò mò không hiểu tại sao lại dùng cổng 48 cho SSL, có lý do đặc biệt nào không?
    • Câu trả lời là cổng này được chọn ngẫu nhiên từ tên H48. Webapp này cần thêm các HTTP header bổ sung, nên để triển khai mà không ảnh hưởng đến toàn bộ trang web, tác giả đã đơn giản dùng một cổng khác. Nó cũng được chuyển hướng tới https://h48.tronto.net, và về sau tác giả cũng đang cân nhắc cải thiện thêm cấu hình httpd và relayd của OpenBSD, hoặc chuyển hẳn sang một domain riêng.