Event Loop & Async
Event Loop คืออะไร?
หัวข้อที่มีชื่อว่า “Event Loop คืออะไร?”JavaScript ใน Node.js ทำงานบน เธรดเดียว (single thread) แต่กลับรองรับการเชื่อมต่อพร้อมกันได้นับพันโดยไม่บล็อก ความลับอยู่ที่ event loop — กลไกที่มอบงานช้า (อ่านไฟล์, เรียก network) ให้ระบบปฏิบัติการจัดการ แล้วค่อยประมวลผลผลลัพธ์เมื่อพร้อม
แก่นของ runtime มีโครงสร้างสำคัญสามอย่าง:
- Call stack — ที่ที่โค้ดแบบ synchronous ทำงาน ทีละเฟรม
- Microtask queue — เก็บ callback ของ Promise และ
queueMicrotaskระบายจนหมดหลังจากทุก task - Macrotask queue (task queue) — เก็บ callback ของ
setTimeout/setIntervalจัดกลุ่มเป็น phase Node รันทั้ง phase ต่อหนึ่งรอบ event loop (เช่น timer ที่หมดอายุทุกตัว) — ไม่ใช่แค่ macrotask เดียว
ลูปทำงานต่อเนื่อง: รัน callback ใน phase ทีละตัว แล้วหลังจากทุก callback จะระบาย process.nextTick แล้วตามด้วย microtask queue ก่อนไปยัง callback ถัดไปหรือ phase ถัดไป
สิ่งที่โมดูลนี้ครอบคลุม
หัวข้อที่มีชื่อว่า “สิ่งที่โมดูลนี้ครอบคลุม”| บทเรียน | หัวข้อ |
|---|---|
| Call Stack | การทำงานแบบ synchronous และทำไมการบล็อกจึงสำคัญ |
| Callbacks | รูปแบบ async ดั้งเดิมและขนบ error-first ของ Node |
| Promises | สถานะ, การ chain และ combinator |
| Async/Await | อ่านโค้ด async ได้เหมือนโค้ด synchronous |
| Timers | setTimeout, setInterval และ API timers/promises |
| Microtasks & Macrotasks | ลำดับความสำคัญของคิวและลำดับการระบาย |
| libuv & Threadpool | ตัวจริงว่าอะไรทำงานอยู่ที่ไหน ใต้ชั้น JavaScript |
เดโม async ตัวแรก
หัวข้อที่มีชื่อว่า “เดโม async ตัวแรก”สนิปเป็ตด้านล่างรันโค้ดสามชิ้น สังเกตว่าโค้ด ไม่ได้ ทำงานจากบนลงล่างตามลำดับที่เขียน — log แบบ synchronous ทำงานก่อน, callback ของ Promise ทำงานถัดมา (microtask), และ callback ของ setTimeout ทำงานเป็นอันสุดท้าย (macrotask)
// What runs first?console.log('A — sync');Promise.resolve().then(() => console.log('B — microtask (Promise)'));setTimeout(() => console.log('C — macrotask (setTimeout)'), 0);ลองรันเพื่อยืนยันลำดับ:
ผลลัพธ์จะเป็น A แล้วตามด้วย B แล้ว C เสมอ — event loop รับประกันลำดับนี้
อะไรทำงานอยู่ที่ไหน
หัวข้อที่มีชื่อว่า “อะไรทำงานอยู่ที่ไหน”“Async” ไม่ได้แปลว่า “รันบนอีกเธรด” การทำงานแบบ async แต่ละแบบถูกจัดการด้วยกลไกที่ต่างกันโดยสิ้นเชิง การรู้ว่ากำลังดูกลไกไหนอยู่ช่วยอธิบาย benchmark ที่น่าแปลกใจหลายอย่างในโมดูลนี้:
| การทำงาน | ทำงานอยู่ที่ไหนจริง ๆ |
|---|---|
Promise, async/await, queueMicrotask | microtask queue ของ JS — ยังเป็นเธรด JS เธรดเดียว |
setTimeout, setImmediate, network socket | event loop ของ libuv หนุนหลังด้วย async I/O ของ kernel (epoll/kqueue/IOCP) — ไม่ใช้เธรดเพิ่ม |
fs.readFile, crypto.pbkdf2/scrypt/randomBytes, dns.lookup, zlib | threadpool ขนาดคงที่ของ libuv (ค่าเริ่มต้น 4 เธรด) |
loop แบบ synchronous, JSON.parse บนสตริงยักษ์, fs.readFileSync | call stack — บล็อกทุกอย่าง รวมถึงตัว loop เองด้วย |
flowchart LR Code["Your JS code"] --> Stack["Call stack (one thread)"] Stack -->|Promise / await| Micro["Microtask queue"] Stack -->|fs, crypto, dns.lookup, zlib| Pool["libuv threadpool (4 threads)"] Stack -->|sockets, timers, signals| Kernel["OS kernel async I/O"] Pool --> Loop["Event loop"] Kernel --> Loop Micro --> Loop Loop --> Stack
บทเรียน libuv & Threadpool ท้ายโมดูลนี้จะวัด threadpool ด้วยตัวเลขจริงและไล่ดู 6 phase ของ libuv แบบละเอียด
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ให้นึกว่า event loop คือคนงานคนเดียว (เธรด JS) ที่มีกล่องจดหมายสองกล่องให้เช็กระหว่างทุก task: กล่อง microtask (เทออกจนหมดทุกครั้ง เป็นอันดับแรกเสมอ) และ กล่อง macrotask (รับของหนึ่ง phase ต่อรอบ) งานที่คนงานคนนี้ทำเองแบบไม่บล็อกไม่ได้ — อ่านไฟล์, hash รหัสผ่าน, resolve ชื่อโดเมน — จะถูกส่งต่อไปให้เธรดผู้ช่วยกลุ่มเล็ก ๆ (threadpool ของ libuv) หรือส่งตรงให้ kernel ของ OS (socket) แล้วกลับมาเป็น callback ที่ต่อคิวไว้เมื่อเสร็จ
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”เธรด JS เดียวแปลว่าไม่มี data race ในโค้ดของคุณเอง — ไม่ต้องใช้ lock ล้อมรอบ state ของ JavaScript ที่แชร์กัน ต้นทุนคืองาน CPU-bound แบบ synchronous ใดก็ตาม (loop ใหญ่, JSON.parse บนข้อความหลายเมกะไบต์, crypto แบบ synchronous) จะบล็อก request อื่นทุกตัวในโปรเซส Node ไม่เหมาะกับงานที่กิน CPU หนักเว้นแต่จะย้ายงานนั้นออกจาก main thread (worker_threads, คิวงาน, service แยก) — จุดแข็งของมันคืองานที่หนัก I/O และมี connection พร้อมกันจำนวนมากที่ส่วนใหญ่ว่างรอ
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “Node เป็น single-threaded เลยรองรับ I/O พร้อมกันไม่ได้” ผิด — เธรดเดียวมีไว้สำหรับ JavaScript ของคุณ เท่านั้น kernel และ threadpool ของ libuv เป็นคนรอจริง ๆ พร้อมกัน เธรด JS แค่ถูกแจ้งเตือนเมื่อแต่ละอันเสร็จ
- “Async แปลว่ารันบน CPU core อื่น” ไม่ใช่สำหรับ
Promise/await— สองอย่างนี้ไม่เคยออกจากเธรด JS มีแค่การทำงานที่ใช้ threadpool หรือ kernel เท่านั้นที่ใช้อะไรอื่นนอกจากเธรด JS - “หนึ่งรอบ event loop ประมวลผลหนึ่ง macrotask” Node ประมวลผล callback ที่ครบกำหนดทั้ง phase ต่อหนึ่งรอบ (ดูบท Microtasks & Macrotasks สำหรับการพิสูจน์ที่รันได้จริง)
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”โค้ด Node.js ที่ AI สร้างมักติดป้ายฟังก์ชันว่า async แล้วสมมติว่าแค่นั้นทำให้โค้ดข้างในไม่บล็อก ซึ่งไม่จริง — async เปลี่ยนแค่ค่าที่ฟังก์ชัน return (เป็น Promise) statement แบบ synchronous ข้างในยังรันบน call stack และยังบล็อกทุกอย่างจนกว่าจะเสร็จ
🐛 หา bug ในโค้ดนี้
async function computeReport(rows) {// "It's async, so this won't block other requests" — comment left by the AIlet total = 0;for (let i = 0; i < rows.length; i++) { total += heavySyncTransform(rows[i]); // synchronous, CPU-bound, no await anywhere}return total;}เฉลย
การติดป้าย async ให้ฟังก์ชันไม่ได้ย้าย body ของมันออกจาก call stack heavySyncTransform ยังรันแบบ synchronous ทุกแถวโดยไม่มี await เลย ทั้ง loop เลยบล็อก event loop — request, timer, และ I/O callback อื่นทุกตัวต้องรอจนกว่าจะเสร็จ async แปลว่า “ฟังก์ชันนี้ return Promise” เท่านั้น ไม่ได้บอกอะไรเลยว่าโค้ดข้างใน รันยังไง วิธีแก้คือให้มัน yield จริง ๆ (แบ่งงานเป็นก้อนแล้ว setImmediate/await คั่นระหว่างก้อน) หรือย้ายไปที่ worker_thread