ข้ามไปยังเนื้อหา

Event Loop & Async

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
TimerssetTimeout, setInterval และ API timers/promises
Microtasks & Macrotasksลำดับความสำคัญของคิวและลำดับการระบาย
libuv & Threadpoolตัวจริงว่าอะไรทำงานอยู่ที่ไหน ใต้ชั้น JavaScript

สนิปเป็ตด้านล่างรันโค้ดสามชิ้น สังเกตว่าโค้ด ไม่ได้ ทำงานจากบนลงล่างตามลำดับที่เขียน — 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);

ลองรันเพื่อยืนยันลำดับ:

JavaScript

ผลลัพธ์จะเป็น A แล้วตามด้วย B แล้ว C เสมอ — event loop รับประกันลำดับนี้

“Async” ไม่ได้แปลว่า “รันบนอีกเธรด” การทำงานแบบ async แต่ละแบบถูกจัดการด้วยกลไกที่ต่างกันโดยสิ้นเชิง การรู้ว่ากำลังดูกลไกไหนอยู่ช่วยอธิบาย benchmark ที่น่าแปลกใจหลายอย่างในโมดูลนี้:

การทำงานทำงานอยู่ที่ไหนจริง ๆ
Promise, async/await, queueMicrotaskmicrotask queue ของ JS — ยังเป็นเธรด JS เธรดเดียว
setTimeout, setImmediate, network socketevent loop ของ libuv หนุนหลังด้วย async I/O ของ kernel (epoll/kqueue/IOCP) — ไม่ใช้เธรดเพิ่ม
fs.readFile, crypto.pbkdf2/scrypt/randomBytes, dns.lookup, zlibthreadpool ขนาดคงที่ของ libuv (ค่าเริ่มต้น 4 เธรด)
loop แบบ synchronous, JSON.parse บนสตริงยักษ์, fs.readFileSynccall 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
เมื่อเรียก async หนึ่งครั้ง มันไปที่ไหนจริง ๆ

บทเรียน libuv & Threadpool ท้ายโมดูลนี้จะวัด threadpool ด้วยตัวเลขจริงและไล่ดู 6 phase ของ libuv แบบละเอียด

ให้นึกว่า event loop คือคนงานคนเดียว (เธรด JS) ที่มีกล่องจดหมายสองกล่องให้เช็กระหว่างทุก task: กล่อง microtask (เทออกจนหมดทุกครั้ง เป็นอันดับแรกเสมอ) และ กล่อง macrotask (รับของหนึ่ง phase ต่อรอบ) งานที่คนงานคนนี้ทำเองแบบไม่บล็อกไม่ได้ — อ่านไฟล์, hash รหัสผ่าน, resolve ชื่อโดเมน — จะถูกส่งต่อไปให้เธรดผู้ช่วยกลุ่มเล็ก ๆ (threadpool ของ libuv) หรือส่งตรงให้ kernel ของ OS (socket) แล้วกลับมาเป็น callback ที่ต่อคิวไว้เมื่อเสร็จ

เธรด 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 สำหรับการพิสูจน์ที่รันได้จริง)

โค้ด 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 AI
let 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

หลังจากรัน task หนึ่ง event loop ประมวลผลคิวตามลำดับใด?
ข้อใดต่อไปนี้ถูกใส่ลงใน microtask queue?
ข้อใดต่อไปนี้ไม่เคยออกจากเธรด JS เดี่ยวเลย ทั้งที่ดูเหมือน async?
โค้ดนี้พิมพ์อะไร?
async function work() {
  for (let i = 0; i < 3e8; i++) {} // no await
  console.log('done');
}
console.log('before');
work();
console.log('after');
เหตุผลนี้ผิดตรงไหน: "ผมใส่การ hash ไฟล์ไว้ในฟังก์ชัน async แล้ว มันจะไม่บล็อก request อื่น"?