23 คะแนน โดย GN⁺ 2025-06-08 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • อธิบายอย่างละเอียดเกี่ยวกับกระบวนการนำ โค้ด C/C++ ไปพอร์ตเป็น WebAssembly ด้วย Emscripten เพื่อสร้างเว็บแอปที่ทำงานในเบราว์เซอร์ โดยอิงจากตัวอย่าง Rubik’s Cube solver จริง
  • ครอบคลุม ปัญหาเฉพาะทางและวิธีแก้แบบเป็นขั้นตอน ที่พบในสภาพแวดล้อมเบราว์เซอร์/WebAssembly ตั้งแต่ Hello World ไปจนถึงมัลติเธรด, callback, persistent storage และ modularization
  • เน้น การแก้ปัญหาในงานจริง เช่น การเริ่มต้นแบบ asynchronous ใน JavaScript, การ export ฟังก์ชัน, Web Worker และประเด็น Spectre, รวมถึงการบันทึกถาวรผ่าน IndexedDB ด้วย IDBFS
  • ย้ำหลายครั้งว่า abstraction ของ Emscripten มัก “รั่ว” (leaky abstractions) ในการใช้งานจริง และชี้ว่าจำเป็นต้องเข้าใจข้อจำกัดกับโครงสร้างภายในของเว็บแพลตฟอร์ม
  • เป็น คู่มือจากประสบการณ์จริง ที่ให้ความช่วยเหลือและเคล็ดลับเชิงปฏิบัติสำหรับ นักพัฒนาที่ต้องการย้ายไลบรารี C/C++ เดิมไปสู่เว็บ ผ่านประสบการณ์พอร์ตโค้ดเบส C ที่ซับซ้อนไปยังเว็บ โดยอาศัยเพียง ความรู้ JavaScript/HTML ฝั่งฟรอนต์เอนด์ในระดับพื้นฐาน

บทนำ

  • เมื่อไม่นานมานี้ ผู้เขียนได้ทำโปรเจกต์นำ อัลกอริทึมคำตอบที่เหมาะที่สุดของ Rubik’s Cube มาสร้างเป็นเว็บแอป
  • บันทึกกระบวนการคอมไพล์ Rubik’s Cube solver สำหรับการหาคำตอบแบบเหมาะที่สุดที่พัฒนาด้วย C ให้ทำงานในเว็บเบราว์เซอร์เป็น WebAssembly ผ่าน Emscripten
  • เหตุผลหลักที่ใช้ WebAssembly คือเพื่อให้ได้ ประสิทธิภาพบนเว็บที่ใกล้เคียง native มาก เมื่อเทียบกับ JavaScript
  • บทความนี้ไม่ใช่ทิวทอเรียลพัฒนาเว็บแบบดั้งเดิม แต่เป็น ‘เส้นทางแห่งความเจ็บปวด’ สำหรับ นักพัฒนาที่ต้องการย้ายโค้ด C/C++ เดิมไปสู่เว็บ
  • แม้จะไม่มีประสบการณ์พัฒนาเว็บมากนัก ก็ยังตามได้หากรู้โครงสร้างพื้นฐานของ HTML, JavaScript และวิธีใช้ developer tools ของเบราว์เซอร์

การตั้งค่าสภาพแวดล้อม

  • สามารถดูโค้ดตัวอย่างทั้งหมดได้ใน git repository, github
  • จำเป็นต้องติดตั้ง Emscripten (ดูวิธีติดตั้งจากเว็บไซต์ทางการ) และใช้เว็บเซิร์ฟเวอร์อย่าง darkhttpd หรือ Python http.server
  • ตัวอย่างโค้ดในทิวทอเรียลนี้ผ่านการทดสอบบน Linux และตระกูล UNIX ผู้ใช้ Windows แนะนำให้ใช้ WSL(Windows Subsystem for Linux)

Hello World

  • เมื่อคอมไพล์โค้ด Hello World ของ C ด้วยคำสั่ง emcc -o index.html hello.c จะได้ไฟล์ 3 ไฟล์คือ index.html(หน้าเว็บ), index.wasm(WebAssembly bytecode), index.js(JavaScript glue code)
  • สามารถทำงานได้ทั้งในเบราว์เซอร์และ Node.js โดยแต่ละสภาพแวดล้อมก็มีวิธีใช้งานที่ต่างกัน
  • หากต้องการสร้างเฉพาะ .wasm ให้ใช้ตัวเลือก -sSTANDALONE_WASM
  • แม้ Emscripten จะสร้างเฉพาะ .wasm ได้ แต่ในกรณีส่วนใหญ่ JavaScript glue code ก็ยังจำเป็น

Intermezzo I: WebAssembly คืออะไร?

  • WebAssembly(WASM) คือภาษาระดับต่ำที่ทำงานบน virtual machine ประสิทธิภาพสูง ภายในเว็บเบราว์เซอร์
  • WASM รองรับใน เบราว์เซอร์หลักทุกตัวมาตั้งแต่ปี 2017
  • เดิมที Emscripten แปลงโค้ด C/C++ ไปเป็นส่วนย่อยของ JavaScript ที่ชื่อ asm.js แต่ภายหลังได้เปลี่ยนมาใช้ WASM เมื่อเทคโนโลยีนี้เกิดขึ้น
  • มีรูปแบบการเขียนแบบข้อความด้วย และมีโครงสร้างแบบ stack-based เดิมทีรองรับเฉพาะสถาปัตยกรรม 32 บิต จึงใช้หน่วยความจำเกิน 4GB ไม่ได้ แต่ตอนนี้ WASM64 กำลังค่อย ๆ ถูกนำเข้าสู่เบราว์เซอร์

การบิลด์ไลบรารี

  • ยกตัวอย่างพื้นฐานในการบิลด์ ฟังก์ชัน C ชื่อ multiply() เป็น WASM แล้วเรียกใช้จาก JavaScript
  • ในการบิลด์แบบปกติ Emscripten จะเติม ขีดล่าง (_) ไว้หน้าชื่อฟังก์ชัน (เช่น _multiply)
  • หากต้องการเปิดให้เรียกใช้จากภายนอก ต้องระบุออปชัน -sEXPORTED_FUNCTIONS
  • การโหลดไลบรารีมีขั้นตอนเริ่มต้นแบบ asynchronous ดังนั้นจึงต้องจัดการแบบ async ผ่าน onRuntimeInitialized หรือ await
  • โค้ดตัวอย่างอยู่ในโฟลเดอร์ 01_library ของ repository

Intermezzo II: JavaScript และ DOM

  • หากต้องการเข้าถึงและแก้ไของค์ประกอบของ HTML จาก JavaScript จำเป็นต้องใช้ Document Object Model(DOM)
  • สามารถสร้าง UI แบบไดนามิกได้ด้วย event listener(addEventListener), operator/ฟังก์ชันในตัว และองค์ประกอบอื่น ๆ
  • อธิบายโครงสร้างการเชื่อม HTML/JavaScript ขั้นพื้นฐานสำหรับตัวอย่างที่มี input, ปุ่ม และการแสดงผลลัพธ์
  • ยังแนะนำวิธีใช้งานจริงและประเด็นที่ควรระวังในการแยก/รวม script (เช่น การใช้ defer, ลำดับการโหลดองค์ประกอบ DOM)

การทำไลบรารีให้เป็นโมดูลและการโหลด

  • เพื่อให้สามารถรวม WASM library หลายตัว หรือ นำกลับมาใช้ซ้ำได้ทั้งใน Node.js และบนเว็บ สามารถบิลด์เป็นโมดูลได้ด้วยออปชัน MODULARIZE, EXPORT_NAME
  • แนะนำให้ใช้ส่วนขยาย .mjs (ES6 module) เพื่อความเข้ากันได้กับ Node.js
  • สามารถใช้งานเป็นโมดูลได้ทั้งบนเว็บและใน Node ด้วยรูปแบบ import MyLibrary from ...

มัลติเธรด

  • ใน WebAssembly สามารถพอร์ตโค้ดแบบมัลติเธรดที่อิง pthreads มาใช้เพื่อ เพิ่มประสิทธิภาพ ได้
  • ภายในฟังก์ชันสามารถสร้างหลายเธรดเพื่อรัน งานคำนวณแบบขนาน (เช่น นับจำนวนจำนวนเฉพาะ)
  • ตอนบิลด์ต้องใช้ออปชัน -pthread, -sPTHREAD_POOL_SIZE=
  • ในเบราว์เซอร์จริง จำเป็นต้องเพิ่ม HTTP header เช่น Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: require-corp
  • ตัวอย่างทั้งหมดดูได้ในโฟลเดอร์ 03_threads ของ repository

Intermezzo III: Web Workers และ Spectre

  • มัลติเธรดของ Emscripten ถูกทำผ่าน Web Workers (Web Workers เป็นโปรเซสแยกและสื่อสารกันด้วยโครงสร้างแบบ message-based)
  • การใช้ shared memory(SharedArrayBuffer) มี ข้อจำกัดด้านความปลอดภัย
  • หลังการเกิดช่องโหว่ Spectre ในปี 2018 จึงมีการบังคับใช้ข้อกำหนด cross-origin isolated และ header ที่เกี่ยวข้อง

ระวังการบล็อก main thread

  • หากงานที่ใช้เวลานานไป บล็อก main UI thread ของเบราว์เซอร์ ประสบการณ์ผู้ใช้จะลดลงอย่างมาก
  • เพื่อหลีกเลี่ยงปัญหานี้ จึงนำ web worker มาใช้ โดยแยกการจัดการ UI/อินพุตออกจากงานประมวลผลอย่างชัดเจน
  • สื่อสารระหว่าง main thread กับ worker แบบ event-driven ด้วย postMessage, onmessage
  • ภายใน web worker จะโหลดโมดูล Emscripten-WASM เพื่อรับผิดชอบเฉพาะงานคำนวณแบบ asynchronous

ฟังก์ชัน callback

  • เมื่อต้องส่ง function pointer เป็นพารามิเตอร์ของฟังก์ชัน C จะไม่สามารถเชื่อมกับ function object ของ JavaScript ได้โดยอัตโนมัติ
  • ต้องใช้ addFunction(), UTF8ToString() ที่ Emscripten มีให้ และตอนบิลด์ต้องเพิ่มออปชัน -sEXPORTED_RUNTIME_METHODS, -sALLOW_TABLE_GROWTH
  • callback ต้องถูกเรียกบน main thread เท่านั้น จึงจะทำงานได้อย่างเสถียร (ไม่สามารถเข้าถึงจาก web worker ได้)

พื้นที่จัดเก็บแบบถาวร

  • เพื่อเก็บข้อมูลไว้ถาวรในเบราว์เซอร์ของผู้ใช้ บทความนี้ใช้ IDBFS(ระบบไฟล์ที่อิง IndexedDB) ของ Emscripten
  • ตอนบิลด์ต้องตั้งค่าเริ่มต้นด้วยแฟลก --lidbfs.js และ **--pre-js ** เป็นต้น
  • ในโค้ด C สามารถใช้ฟังก์ชันอ่านเขียนไฟล์ (fopen, fread, fwrite) ได้ตามเดิม แต่การสะท้อนข้อมูลจริง/ซิงก์ข้อมูลจะต้องทำ การแมปและ sync อย่างชัดเจนจากฝั่ง JavaScript
  • ด้วยข้อจำกัดด้าน sandbox/ความปลอดภัยของเบราว์เซอร์ จึงเข้าถึง local file system โดยตรงได้เฉพาะใน Node.js ส่วนบนเบราว์เซอร์ต้องใช้แบ็กเอนด์อย่าง IDBFS เพื่อเก็บข้อมูลถาวรอย่างปลอดภัย

บทสรุป

  • ตลอดทั้งทิวทอเรียลนี้ ผู้อ่านจะได้เรียนรู้วิธีเชิงปฏิบัติอย่างละเอียดในการนำ โค้ด native C/C++ ที่ซับซ้อน มารัน บนเบราว์เซอร์ ได้อย่างปลอดภัยและไม่สูญเสียประสิทธิภาพมากนัก โดยอาศัยเพียง JavaScript และ HTML ขั้นพื้นฐาน
  • ในสภาพแวดล้อมจริง จะได้พบทั้งอุปสรรคและวิธีแก้ในแกนสำคัญทั้งหมด ไม่ว่าจะเป็น มัลติเธรด, callback, การประมวลผลแบบ asynchronous, การเชื่อมต่อ storage รวมถึงได้เรียนรู้การตั้งค่าที่เกี่ยวข้องและข้อจำกัดล่าสุดของเบราว์เซอร์
  • สามารถนำ ตัวอย่างใน Git repository ไปประยุกต์ใช้และต่อยอดกับโปรเจกต์ของตนเองได้

1 ความคิดเห็น

 
GN⁺ 2025-06-08
ความคิดเห็นใน Hacker News
  • อยากให้คนสังเกตว่ามีการเปลี่ยนนามสกุลจาก .js เป็น .mjs ซึ่งให้ความรู้สึกแบบเห็นใจจากประสบการณ์จริงกับความจริงที่ว่าไม่ว่าจะใช้นามสกุลไหนก็เจอปัญหาอยู่ดี ในฐานะคนที่ผ่านการใช้ระบบโมดูลมาหลากหลายตั้งแต่ dojo, CommonJS, AMD, ESM, webpack, esbuild, rollup ฯลฯ ก็รู้สึกเห็นด้วยกับประโยคนี้ 100%
    • การเปลี่ยนจาก commonjs ไปเป็น esm เป็นการเปลี่ยนครั้งใหญ่มาก ราวกับตอนย้ายจาก python2 ไป python3 แต่เมื่อเทียบกับความคาดหวังแล้ว ประโยชน์ที่ได้กลับน้อยและมีแต่ความยุ่งยากเพิ่มขึ้น ทุกวันนี้หลายไลบรารีรองรับแค่ esm อย่างเดียว จนกลายเป็นว่าต้องไปดูแท็บ 'versions' ของ npm แล้วเลือกเวอร์ชันที่มียอดดาวน์โหลดสูงสุดในช่วง 1 เดือนที่ผ่านมา เพราะมีโอกาสสูงว่านั่นคือ commonjs เวอร์ชันสุดท้าย แน่นอนว่า esm ถือเป็นระบบโมดูลที่ก้าวหน้ากว่า แต่ก็มีความเห็นตรงไปตรงมาว่าไม่เข้าใจเลยว่าทำไม tc39 ถึงทำให้มันไม่เข้ากันกับ commonjs แบบแทบจะจงใจ (เช่น top-level await)
    • รู้สึกว่าประวัติศาสตร์ของโมดูลใน js นั้นแทบจะเป็นบาดแผลทางใจ ตอนนี้เบราว์เซอร์ยังมี import maps เพิ่มเข้ามาอีก ก็เลยสงสัยว่าจากนี้จะมีปัญหาน่าสนุก(?) อะไรตามมาอีก
    • เพิ่งรู้ไม่นานมานี้ว่าอ็อบเจ็กต์ Function สามารถคอมไพล์โค้ด JS ใด ๆ ก็ได้ตอนรันไทม์ เลยกลายเป็นเหมือนเส้นชีวิตที่มีประโยชน์มากในสภาพแวดล้อมที่ใช้ 'import' ไม่ได้ แม้อาจไม่จำเป็นนักใน ecosystem ของ JS แต่สำหรับผมช่วยได้มากจริง ๆ
    • เพราะงั้นทุกคนควรใช้ bun.sh
    • แล้ว .esm.js ก็ใช้ได้ไม่ใช่หรือ
  • ถ้าจะชี้เพิ่มถึงจุดที่อาจก่อปัญหาในระยะยาวจากบทความนี้ ก็อยากแนะนำให้ใช้ let หรือ const แทนคีย์เวิร์ด var แม้ var จะยังใช้ได้ แต่ปัจจุบันนักพัฒนา JS ส่วนใหญ่ก็มักตั้ง linter ให้ห้ามใช้ var เพราะ var รองรับแค่ function scope ซึ่งเป็นจุดที่นักพัฒนาจากภาษาอื่นส่วนใหญ่มักสับสนเข้าสักวัน ส่วนประเด็นการพอร์ตแอปเนทีฟ มีการยกตัวอย่างการฮาร์ดโค้ด Ctrl-C, Ctrl-V สำหรับคัดลอก/วางตั้งแต่ตอนคอมไพล์ ทำให้ใช้ได้บนลินุกซ์และวินโดวส์ แต่ใช้บนแม็กไม่ได้ บนเว็บควรจัดการด้วยการตรวจจับอีเวนต์ copy, paste และยังเคยเห็นเฟรมเวิร์กอย่าง Unity ก็มีปัญหาคัดลอก/วางบนแม็กไม่ได้เพราะคีย์ถูกฮาร์ดโค้ดไว้ แม้เกมส่วนใหญ่จะไม่ต้องใช้ แต่พอเอาฟีเจอร์ที่ต้องคัดลอก/วางออกสู่เว็บก็มักจะกลายเป็นปัญหาเสมอ
  • บ่นว่าเกลียดการทำ multithreading บนเว็บ/NodeJS มาก เพราะแทนที่จะมี synchronization primitives อย่าง mutex หรือ rwlock ที่ทำให้ส่งค่าตัวมันเองข้าม context ได้จริง ๆ (เช่น v8 isolates) กลับมีแค่ SharedArrayBuffer ที่แทบไม่ได้ช่วยอะไร การซิงก์ระหว่างเธรดจึงลงเอยด้วยการทำ thunking และคัดลอกข้อมูลผ่านชั้น RPC อยู่ดี แอป production ของบริษัทเราเป็นแอปขนาดใหญ่ที่ใช้ RAM 70~100GB (เป็นแบบนั้นมาตั้งแต่ก่อนผมเข้ามาทำ) เลยต้องหาทางแก้แปลก ๆ ด้วยการใช้เนทีฟโค้ดมาจัดการ memory page และโครงสร้างข้อมูลแบบกำหนดเองโดยตรง เพื่อลดการ serialize/deserialize ให้มากที่สุด และ v8 ก็ใช้การเข้ารหัสสตริงแบบ utf16 ทำให้การจัดการค่า JS จากชั้นเนทีฟมีต้นทุนสูง
    • สงสัยว่าแอปที่ใช้ RAM 100GB นี้จำเป็นต้องเป็นเว็บแอปจริง ๆ หรือ ฟังดูเหมือนเป็นเครื่องมือภายในที่น่าจะเขียนด้วยภาษาอย่าง C# มากกว่า
  • ecosystem นี้ใกล้เคียงกับความโกลาหลมาก จนคำว่า 'มาโซคิสต์' ยังฟังดูสมเหตุสมผลขึ้นมาเลย
    • จะบอกว่าความโกลาหลนั้นถูกฝังอยู่ในตัวมันอยู่แล้วก็ได้
  • ตัวบทความเขียนได้ดีมาก แถมยังน่าทึ่งที่เริ่มต้นด้วยเส้นทางที่ยากและซับซ้อนขนาดนั้น ทำให้รู้สึกชัดเลยว่าการตั้งค่าโปรเจกต์คือส่วนที่ยากที่สุด ชื่นชมที่เจอปัญหาเรื่องความปลอดภัย/เฮดเดอร์ตั้งแต่ต้น แต่ก็มีความเห็นว่าปัญหาที่มักเจอกันคือ CORS ที่บริษัทเราก็กำลังบิลด์ด้วย emscripten/C++ และยังจะเพิ่ม WebGPU/เชดเดอร์กับ WebAudio เข้าไปอีก ดังนั้นเส้นทางข้างหน้าคงยิ่งโหดขึ้น
  • เมื่อก่อนเคยคิดลอย ๆ ว่าการคอมไพล์โค้ดในเบราว์เซอร์คง 'ช้า' แต่ OP อธิบายได้ดีว่าไม่ใช่แบบนั้น โครงการ Emscripten เองก็เน้นว่า "ด้วยการผสานกันของ LLVM, Emscripten, Binaryen และ WebAssembly ทำให้ผลลัพธ์มีขนาดเล็กและทำงานได้เร็วเกือบเทียบเท่าเนทีฟ" (emscripten.org)
    • วันนี้สำหรับผมเป็นวันแบบ 'อาการรถโรงเรียนสีเหลือง' เพราะจนถึงสัปดาห์ที่แล้วยังไม่รู้จัก Emscripten เลย แต่ตอนกำลังเอา SDL เข้าโปรเจกต์ก็ไปเจอคอมเมนต์ใน CMake ที่พูดถึง target APPLE, MSVC, EMSCRIPTEN แล้ววันนี้ก็มาบังเอิญเจอ Emscripten อีกครั้งบน hn เลยตั้งใจว่าถึงเวลาแล้วที่จะต้องหาเวลามาศึกษาแบบจริงจัง
    • สงสัยว่าคำว่า "เร็วเกือบเทียบเท่าเนทีฟ" นั้นค่อนข้างเป็นนามธรรม เพราะหาเอกสารที่มีตัวเลขวัดจริง ๆ ไม่เจอว่าเร็วแค่ไหน
  • บทความนี้มีประโยชน์มาก และตัวเองก็มีแผนจะคอมไพล์คอมไพเลอร์ที่เขียนด้วย C ไปเป็น WebAssembly แล้วทำเป็น web playground ด้วย อีกอย่างคือทราบมาว่าเบราว์เซอร์สมัยใหม่สามารถใช้ SQLite ผ่านจาวาสคริปต์ได้ เลยสงสัยว่าสิ่งนี้ทำได้จาก wasm ด้วยไหม ถ้า emscripten สามารถเชื่อมการเรียก sqlite API จากโค้ด C ไปยัง browser sqlite db ได้ก็คงจะเหมาะอย่างยิ่งและน่าศึกษาต่อ
  • สงสัยว่าทำไมถึงใช้พอร์ต 48 สำหรับ SSL มีเหตุผลพิเศษอะไรหรือเปล่า
    • คำตอบคือเลือกพอร์ตนี้แบบสุ่มเพราะมาจากชื่อ H48 และเว็บแอปนี้ต้องใช้ HTTP header เพิ่มเติม เลยใช้คนละพอร์ตเพื่อจะได้ทำได้โดยไม่กระทบทั้งไซต์ นอกจากนี้ยังรีไดเรกต์จาก https://h48.tronto.net ได้ด้วย และในอนาคตก็กำลังคิดว่าจะปรับปรุงการตั้งค่า OpenBSD ของ httpd กับ relayd ให้ดีขึ้น หรือไม่ก็ย้ายไปใช้โดเมนแยกต่างหากเลย