Skip to content

บทที่ 3 — Async + Events: หัวใจที่ทำให้ Node เป็น Node

← บทที่ 2: Core Modules + process | สารบัญ | บทที่ 4: Filesystem + Streams →

บทนี้คือบทที่สำคัญที่สุดของเล่ม ถ้าเข้าใจ async ใน Node ลึกจริง ที่เหลือทั้งเล่มจะไหลลื่น เราจะไล่จาก:

  1. 3 ยุคของ async — callback → Promise → async/await (และ error-first callback ที่เป็นเอกลักษณ์ของ Node)
  2. promisify — แปลง API เก่าแบบ callback ให้ทันสมัย
  3. EventEmitter — รูปแบบ async อีกแบบที่ Node ใช้ทั่ว (server, stream ก็เป็น EventEmitter)
  4. timers + event loop เชิงลึกsetTimeout vs setImmediate vs process.nextTick vs Promise ทำงานลำดับไหน
  5. AbortController — ยกเลิกงาน async (timeout, cancel)

📋 บทนี้ต่อยอดจากภาค JavaScript บทที่ 9 (Async Deep) ของหนังสือชุดเดียวกัน — ถ้าเข้าหนังสือเล่มนี้ตรง ๆ และยังไม่แม่น Promise/async-await แนะนำให้กลับไปอ่านภาค JavaScript ก่อน บทนี้จะพูดเฉพาะมุมที่เป็น Node


1. สามยุคของ async ใน Node

Node เกิดมาพร้อมปรัชญา "ไม่นั่งรอ I/O" (บทที่ 0) — แต่วิธีเขียนโค้ด async วิวัฒนาการมา 3 ยุค คุณจะเจอทั้งสามในโค้ดจริง:

ยุค 1: Callback + error-first convention

ยุคแรก Node ใช้ callback — ส่งฟังก์ชันเข้าไป "ให้เรียกตอนงานเสร็จ" และมีกติกาเฉพาะของ Node ที่เรียกว่า error-first callback: argument ตัวแรกของ callback คือ error เสมอ (ถ้าไม่มี error ก็เป็น null):

javascript
import { readFile } from "node:fs";

// error-first callback: (err, data) => ...
//   - ถ้าพัง   → err = Error object, data = undefined
//   - ถ้าสำเร็จ → err = null, data = ข้อมูล
readFile("config.json", "utf8", (err, data) => {
  if (err) {
    // ⭐ กติกา Node: ต้องเช็ค err ก่อนเสมอ
    console.error("อ่านไฟล์ไม่ได้:", err.message);
    return;   // อย่าลืม return ไม่งั้นโค้ดข้างล่างจะรันทั้งที่ error
  }
  console.log("ได้ข้อมูล:", data);
});

console.log("บรรทัดนี้รันก่อน callback ข้างบน!");   // เพราะ readFile ไม่นั่งรอ

ปัญหา "callback hell": พองานซ้อนกันหลายชั้น (อ่านไฟล์ → query DB → เขียนไฟล์) callback ซ้อน callback จนเป็นพีระมิดเอียง อ่านยาก จัดการ error ยาก:

javascript
readFile("a.txt", "utf8", (err, a) => {
  if (err) return handle(err);
  readFile("b.txt", "utf8", (err, b) => {        // ซ้อนชั้น 2
    if (err) return handle(err);
    readFile("c.txt", "utf8", (err, c) => {      // ซ้อนชั้น 3... 😱
      if (err) return handle(err);
      console.log(a + b + c);
    });
  });
});

ยุค 2: Promise

Promise คือ "object ที่แทนค่าที่จะมาในอนาคต" — แบนพีระมิดให้เป็นโซ่ .then():

javascript
import { readFile } from "node:fs/promises";   // ⭐ fs/promises = เวอร์ชัน Promise

readFile("config.json", "utf8")
  .then((data) => console.log("ได้ข้อมูล:", data))
  .catch((err) => console.error("พัง:", err.message));   // catch จับ error ของทั้งโซ่

ยุค 3: async/await (ใช้ตัวนี้เป็นหลัก)

async/await คือ syntax ที่ทำให้โค้ด async อ่านเหมือนโค้ดธรรมดา (เรียงบนลงล่าง) แต่เบื้องหลังยังเป็น Promise:

javascript
import { readFile } from "node:fs/promises";

async function loadConfig() {
  try {
    // await = "รอ Promise นี้เสร็จ แล้วเอาค่าออกมา" — แต่ไม่บล็อก event loop
    const data = await readFile("config.json", "utf8");
    console.log("ได้ข้อมูล:", data);
  } catch (err) {
    // try/catch จับ error ของ await ได้เหมือนโค้ด sync ปกติ
    console.error("พัง:", err.message);
  }
}
await loadConfig();

callback hell เมื่อกี้ กลายเป็นโค้ดเรียงสวย:

javascript
const a = await readFile("a.txt", "utf8");
const b = await readFile("b.txt", "utf8");
const c = await readFile("c.txt", "utf8");
console.log(a + b + c);

💡 กฎ: โปรเจกต์ใหม่ใช้ async/await + fs/promises เป็นหลัก · เจอ callback API เก่า ๆ ให้แปลงด้วย promisify (ข้อ 2)

ดักจุดพลาด: await ทีละตัว vs พร้อมกัน

javascript
// ❌ ช้า: 3 งานที่ไม่เกี่ยวกัน แต่ await ทีละตัว → รวม 300ms
const u = await fetchUser();      // 100ms
const p = await fetchPosts();     // 100ms (เริ่มหลัง user เสร็จ)
const c = await fetchComments();  // 100ms

// ✅ เร็ว: ยิงพร้อมกันด้วย Promise.all → รวม ~100ms (รอตัวที่ช้าสุด)
const [u, p, c] = await Promise.all([fetchUser(), fetchPosts(), fetchComments()]);

⚠️ Pitfall: await ใน for loop ทำให้งานเรียงต่อกัน (ช้า) — ถ้างานแต่ละรอบไม่ขึ้นต่อกัน ใช้ Promise.all(items.map(...)) แทน · แต่ระวัง: ถ้ายิงพร้อมกันเป็นพัน ๆ จะถล่ม DB/API — กรณีนั้นต้องจำกัด concurrency (จำนวนงานที่ยิงพร้อมกัน) เช่นใช้ library ชื่อ p-limit จาก npm:

javascript
import pLimit from "p-limit";
const limit = pLimit(5);   // ยอมให้ยิงพร้อมกันได้สูงสุด 5 ตัว

// map คืน Promise[] ที่ p-limit คุม concurrency ให้ — ตัวที่ 6 ต้องรอตัวก่อนหน้าเสร็จ
const results = await Promise.all(
  items.map((it) => limit(() => fetchOne(it))),
);

Promise combinator อื่น ๆ — เลือกตามสถานการณ์

นอกจาก Promise.all ยังมีอีก 3 ตัวที่ backend ใช้บ่อย:

javascript
// 1) Promise.race — ตัวแรกที่ "settle" (settle = จบ ไม่ว่าจะสำเร็จหรือพัง) ชนะ → ใช้ทำ timeout pattern
const result = await Promise.race([
  fetch("/api/data"),
  new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 3000)),
]);
// ถ้า fetch ช้าเกิน 3 วิ → throw timeout

// 2) Promise.any — ตัวแรกที่ "สำเร็จ" ชนะ (ignore reject) → fallback หลายแหล่ง
const data = await Promise.any([
  fetchFromPrimary(),
  fetchFromMirror1(),
  fetchFromMirror2(),
]);
// เอาที่สำเร็จก่อน; ถ้าทุกตัว reject → throw AggregateError
// (AggregateError = error ก้อนเดียวที่รวม error ของทุกตัวไว้ใน property `.errors` ซึ่งเป็น array)

// 3) Promise.allSettled — รอทุกตัวจบ (ไม่ throw แม้บางตัวพัง) → batch ที่ยอมให้บางตัว fail
const results = await Promise.allSettled([
  sendNotification(user1),
  sendNotification(user2),
  sendNotification(user3),
]);
for (const r of results) {
  if (r.status === "rejected") console.error("ส่งพัง:", r.reason);
}
Combinatorจบเมื่อทิ้ง error ที่เหลือ?ใช้กับ
Promise.allทุกตัวสำเร็จ หรือมีตัวแรก rejectใช่ (reject แรก)งานที่ "ทุกตัวต้องสำเร็จ"
Promise.raceตัวแรก settleใช่timeout, cancellation
Promise.anyตัวแรกสำเร็จสะสมใน AggregateErrorfallback/mirror
Promise.allSettledทุกตัว settleเก็บใน arraybatch job ที่ partial-fail OK

2. promisify — แปลง callback API เก่าให้เป็น Promise

library/core module เก่าบางตัวยังเป็น callback แบบ error-first — แปลงให้เป็น Promise ด้วย util.promisify:

javascript
import { promisify } from "node:util";
import { readFile } from "node:fs";   // เวอร์ชัน callback

// promisify ใช้ได้กับ function ที่ตามกติกา error-first callback
const readFileP = promisify(readFile);

const data = await readFileP("config.json", "utf8");   // ใช้ await ได้แล้ว

💡 หลาย core module มีเวอร์ชัน Promise ให้อยู่แล้ว (node:fs/promises, node:dns/promises, node:timers/promises) — ใช้ของที่มีก่อน ค่อย promisify เมื่อไม่มีให้

javascript
// ตัวอย่างมีประโยชน์: "sleep" แบบ async ด้วย timers/promises
import { setTimeout as sleep } from "node:timers/promises";

console.log("เริ่ม");
await sleep(1000);          // รอ 1 วินาทีแบบไม่บล็อก (คืน Promise)
console.log("ผ่านไป 1 วิ");

3. EventEmitter — รูปแบบ async อีกแบบที่อยู่ทั่ว Node

callback/Promise เหมาะกับงานที่ "เกิดครั้งเดียวแล้วจบ" (อ่านไฟล์ → ได้ผล) แต่บางอย่าง "เกิดซ้ำ ๆ หลายครั้ง" (request เข้ามาเรื่อย ๆ, ข้อมูลไหลมาเป็นชิ้น ๆ) — Node ใช้ EventEmitter: รูปแบบ "ประกาศข่าว/ฟังข่าว" (publish/subscribe)

แนวคิด: object หนึ่ง emit (ป่าวประกาศ) event เมื่อมีอะไรเกิดขึ้น — ใครสนใจก็ on (ลงทะเบียนฟัง) ไว้ เหมือน addEventListener ในเบราว์เซอร์

javascript
import { EventEmitter } from "node:events";

// สร้าง emitter (หรือ extends class ของเราจาก EventEmitter)
const orders = new EventEmitter();

// on() = ลงทะเบียนฟัง event ชื่อ "newOrder" — เรียกทุกครั้งที่มี emit
orders.on("newOrder", (order) => {
  console.log(`ออเดอร์ใหม่ #${order.id} ราคา ${order.total}`);
});
orders.on("newOrder", (order) => {
  sendEmailReceipt(order);    // (sendEmailReceipt = function ที่คุณนิยามเอง) — ฟังหลายคนได้ ทุกคนได้รับ event
});

// emit() = ป่าวประกาศ event พร้อมส่งข้อมูล → ทุก listener ถูกเรียก (ตามลำดับที่ลงทะเบียน)
orders.emit("newOrder", { id: 1, total: 250 });

// once() = ฟังแค่ครั้งเดียวแล้วเลิกฟังอัตโนมัติ
orders.once("ready", () => console.log("พร้อมแล้ว (เรียกครั้งเดียว)"));

ทำไมต้องรู้: ของหลายอย่างใน Node คือ EventEmitterHTTP server (server.on("request", ...)), stream (stream.on("data", ...) — บทที่ 4), process (process.on("SIGTERM", ...)) เมื่อเข้าใจ pattern นี้ คุณจะอ่านโค้ด Node ออกทันที

กติกาสำคัญ: event ชื่อ "error" พิเศษ

javascript
const emitter = new EventEmitter();

// ⭐ ต้องมี listener สำหรับ "error" เสมอ
emitter.on("error", (err) => console.error("จัดการ error:", err.message));

emitter.emit("error", new Error("อะไรพังสักอย่าง"));

⚠️ Pitfall ร้ายแรง: ถ้า EventEmitter emit("error", ...) แต่ไม่มีใคร on("error") Node จะ throw แล้ว crash ทั้งโปรแกรมทันที — นี่เป็น default ที่ตั้งใจให้ error ไม่เงียบหาย ต้องลงทะเบียน on("error") กับ emitter/stream ทุกตัวที่อาจ error

Pattern modern: events.once() กับ AbortSignal + ระวัง MaxListeners

นอกจาก .on() / .once() แบบ method มี helper events.once(emitter, eventName) ที่คืน Promise — เขียน flow async ได้สวยกว่า และยกเลิกได้ด้วย AbortSignal:

javascript
import { once, EventEmitter } from "node:events";

const ee = new EventEmitter();
setTimeout(() => ee.emit("ready", { ok: true }), 100);

// รอ event "ready" ครั้งเดียวแบบ await (คืน [args...] ที่ emit มา)
const [payload] = await once(ee, "ready");
console.log(payload.ok);   // true

// ยกเลิกการรอด้วย AbortSignal — ใช้ตอน timeout/cancel
const ac = new AbortController();
setTimeout(() => ac.abort(), 1000);
try {
  await once(ee, "neverComes", { signal: ac.signal });
} catch (err) {
  if (err.name === "AbortError") console.log("เลิกรอแล้ว");
}

⚠️ Pitfall — MaxListeners: ถ้าคุณ .on() event เดียวกันเกิน 10 ตัว Node จะเตือนใน console ว่า Possible EventEmitter memory leak detected (default = 10) — มักเกิดตอนลงทะเบียน listener ใน loop โดยลืม .off() · ถ้าตั้งใจฟังเยอะจริง ใช้ ee.setMaxListeners(50) หรือ events.setMaxListeners(50, ee) (helper จาก node:events)


4. Event loop เชิงลึก — ลำดับการทำงานที่ทำให้คนงง

🚀 โซนขั้นสูง — ข้ามได้ หัวข้อนี้ลงรายละเอียดลำดับการทำงานของ event loop (เป็นคำถามสัมภาษณ์ยอดฮิต) ถ้าเพิ่งเริ่ม รู้แค่ "async/await ใช้ยังไง" ก็เขียนงานได้แล้ว — กลับมาอ่านตรงนี้ตอนเจอบั๊กลำดับการทำงานแปลก ๆ ก็ทัน

บทที่ 0 อธิบายภาพรวมว่า event loop "หยิบงานที่พร้อมมาทำ" — บทนี้เจาะว่า "หยิบลำดับไหนก่อน" เพราะมันเป็นคำถามสัมภาษณ์ยอดฮิตและทำให้เกิดบั๊กแปลก ๆ

ภาพ: event loop มีหลาย "เฟส" วนเป็นรอบ

กฎทองที่ต้องจำ

ก่อนอื่นเข้าใจ 2 คำนี้ก่อน — งาน async ใน Node แบ่งเป็น 2 ชั้นความด่วน:

  • microtask = "งานด่วนแทรกคิว" (เช่น Promise, process.nextTick) — Node ทำให้หมดก่อนเสมอ เหมือนงานที่ต้องเคลียร์ก่อนทำอย่างอื่น
  • macrotask = "งานปกติรอรอบถัดไป" (เช่น setTimeout, setImmediate) — รอให้ microtask หมดก่อน แล้วค่อยถึงคิวรอบถัดไป

💡 (micro = เล็ก/ด่วน · macro = ใหญ่/ปกติ — micro มาก่อน macro เสมอ)

  1. microtask ทำก่อน macrotask เสมอ — ทุกครั้งที่ callback หนึ่งจบ Node จะเคลียร์ microtask ทั้งหมดก่อนไปต่อ
  2. ภายใน microtask: process.nextTick มาก่อน Promise

ดูตัวอย่างคลาสสิก — ทายผลก่อนดูเฉลย:

javascript
console.log("1: sync");

setTimeout(() => console.log("2: setTimeout"), 0);   // macrotask (timers)
setImmediate(() => console.log("3: setImmediate"));  // macrotask (check)

Promise.resolve().then(() => console.log("4: promise"));   // microtask
process.nextTick(() => console.log("5: nextTick"));        // microtask (สูงสุด)

console.log("6: sync");

ผลลัพธ์:

text
1: sync           ← โค้ด sync รันก่อนตามลำดับ
6: sync
5: nextTick       ← microtask: nextTick มาก่อน
4: promise        ← microtask: promise
2: setTimeout     ← macrotask (timers/check สลับกันได้ขึ้นกับจังหวะ)
3: setImmediate

อ่านผลนี้: โค้ด sync ทั้งหมดรันจนจบก่อน → แล้วเคลียร์ microtask (nextTick ก่อน promise) → แล้วค่อยถึง macrotask (timer/immediate)

⚠️ Pitfall: process.nextTick ที่เรียกตัวเองวน ๆ จะ ทำให้ event loop ไม่มีวันเดินไปเฟสอื่น (microtask ไม่หมดสักที) → I/O และ timer ค้างหมด — ใช้ nextTick อย่างระวัง ส่วนใหญ่ไม่จำเป็นต้องใช้

setTimeout vs setImmediate — ใช้ตัวไหน

  • setTimeout(fn, 0) — "ทำในรอบ event loop ถัดไป เร็วที่สุดเท่าที่ timer อนุญาต" (เฟส timers)
  • setImmediate(fn) — "ทำหลังเฟส poll (I/O) ของรอบนี้จบ" (เฟส check)

ลำดับขึ้นกับว่าใครเรียก — ต้องแยก 2 บริบท:

1. เรียกจาก main script (script entry) — ลำดับ ไม่แน่นอน — ขึ้นกับว่า event loop เข้ารอบใหม่ตอน timers phase พร้อมหรือยัง:

javascript
// อาจได้ "timeout" ก่อน หรือ "immediate" ก่อน — สลับได้
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

2. เรียกจาก I/O callbacksetImmediate มา ก่อน setTimeout(fn, 0) เสมอ — เพราะ I/O callback อยู่ที่เฟส poll → ถัดไปคือ check (setImmediate) → แล้วค่อยวนกลับ timers รอบใหม่:

javascript
import { readFile } from "node:fs";
// ⭐ ใช้ path เป็น string ตรง ๆ (ไม่ใช้ __filename เพราะเป็น global ของ CommonJS
//    ไม่มีใน ESM ที่หนังสือเล่มนี้ใช้ — จะได้ ReferenceError ถ้าใส่ __filename ในไฟล์ ESM)
readFile("package.json", () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});
// → "immediate" ก่อน "timeout" เสมอ (เพราะอยู่ใน I/O callback)

Rule of thumb:

  • ต้องการ "ทำหลัง I/O ปัจจุบันเสร็จในรอบเดียวกัน" → setImmediate
  • ต้องการหน่วงเวลาจริง (≥ 1ms) → setTimeout(fn, ms)
  • ที่ระดับ main script ทั้งคู่ใช้ผลใกล้กัน — เลือกตัวที่ intent ชัดกว่า

queueMicrotask — สั่งงานเข้า microtask queue ตรง ๆ

queueMicrotask(fn) (Node 11+, web standard) คือ primitive ที่ schedule callback เข้า microtask queue โดยไม่ต้องสร้าง Promise:

javascript
queueMicrotask(() => console.log("microtask"));
Promise.resolve().then(() => console.log("promise"));   // เทียบเท่า — แต่ allocate Promise ทิ้ง

ใช้เมื่อ:

  • เขียน library ที่ต้องการ "เลื่อนงานไปท้าย microtask phase" โดยไม่อยากสร้าง Promise object ทิ้ง (perf-sensitive)
  • ต้องการ semantic ชัดเจนว่า "นี่คือ microtask" ไม่ต้องเดาว่าใช้ Promise ทำไม

ลำดับ: process.nextTick > queueMicrotaskPromise.then > macrotask

💡 process.nextTick เป็น API เฉพาะของ Node (ไม่มีในเบราว์เซอร์) — โค้ดสมัยใหม่แทบไม่ต้องใช้ ส่วนใหญ่ queueMicrotask หรือ Promise.resolve().then(...) พอ · เก็บ nextTick ไว้สำหรับเขียน core library ที่ต้องการ "แทรกก่อน Promise" จริง ๆ


5. AbortController — ยกเลิกงาน async (timeout/cancel)

งาน async บางอย่างต้อง "ยกเลิกได้" — เช่น request ที่ช้าเกินไป (timeout) หรือ user กดยกเลิก Node ใช้ AbortController / AbortSignal (มาตรฐานเดียวกับเบราว์เซอร์) เป็นรีโมตปุ่มยกเลิก

ทางลัดสำหรับ timeout: AbortSignal.timeout

ถ้าแค่อยาก "ยกเลิกเมื่อเกิน N ms" ใช้ helper AbortSignal.timeout(ms) — สั้นที่สุดและไม่ต้องบริหาร timer เอง:

javascript
try {
  const res = await fetch("https://slow-api.example.com", {
    signal: AbortSignal.timeout(5000),   // 5 วินาที — abort อัตโนมัติ
  });
  const data = await res.json();
  console.log(data);
} catch (err) {
  if (err.name === "TimeoutError" || err.name === "AbortError") {
    console.error("⏱️ request ช้าเกิน 5 วิ — ยกเลิก");
  } else {
    throw err;
  }
}

ยกเลิกแบบ manual: AbortController เต็มรูป

ถ้าอยากกดยกเลิกเองจากที่อื่น (เช่นปุ่ม Cancel ของ user) ใช้ AbortController:

javascript
// สร้าง controller — มี signal (ตัวรับสัญญาณ) ที่ส่งให้งาน async
const controller = new AbortController();

// ยกเลิกอัตโนมัติใน 5 วินาที — .unref() บอก event loop ว่า timer นี้ไม่ต้องค้างโปรเซสไว้
// (ถ้า fetch สำเร็จก่อน เราเหลือแค่ clearTimeout — โดยไม่ต้องกลัวว่า timer จะถ่วงโปรเซสไม่ให้จบ)
const timer = setTimeout(() => controller.abort(), 5000).unref();

try {
  // ส่ง signal เข้าไปกับ fetch — ถ้า abort ถูกเรียก fetch จะ throw
  const res = await fetch("https://slow-api.example.com", {
    signal: controller.signal,
  });
  const data = await res.json();
  clearTimeout(timer);   // สำเร็จแล้ว ยกเลิก timer
  console.log(data);
} catch (err) {
  if (err.name === "AbortError") {
    console.error("⏱️ ยกเลิกแล้ว");
  } else {
    throw err;
  }
}

💡 core API หลายตัว (fs, timers/promises, http, EventEmitter once) รองรับ signal หมด — ส่งตัวเดียวคุมงานหลาย ๆ ตัวพร้อมกันได้

💡 รวมหลาย signal เป็นหนึ่ง (Node 20+): AbortSignal.any([userSignal, AbortSignal.timeout(5000)]) คืน signal ที่ abort เมื่อตัวใดตัวหนึ่ง abort — เหมาะกับเคส "ยกเลิกเมื่อ user กดยกเลิก หรือเกิน 5 วิ"


6. AsyncLocalStorage — request context ที่ flow ตาม async chain

🚀 โซนขั้นสูง — ข้ามได้รอบแรก หัวข้อนี้แก้ปัญหาที่จะเจอตอนเขียน backend ของจริง (request id ใน log, ดึง user ปัจจุบันลึก ๆ) — ใช้ศัพท์อย่าง middleware, JWT, OpenTelemetry ถ้ายังไม่เคยทำ backend มาก่อน รู้แค่ว่า "Node มีของแบบนี้" แล้วข้ามไปก่อน กลับมาตอนถึงบทที่ 5 (HTTP) ก็ทัน

ในงาน backend ของจริง คุณมักต้องมี "ค่าที่ติดอยู่กับ request หนึ่ง ๆ" ที่เข้าถึงได้จากทุก function ลึก ๆ ในชั้นล่าง — เช่น:

  • request id — รหัสประจำ request หนึ่ง ๆ ที่ใส่ใน log ทุกบรรทัดของ request นั้น ทำให้ตามอ่าน log ของ request เดียวกันได้ง่าย
  • current user / tenant — ใครเป็นคนยิง request นี้ ปกติได้มาจากการตรวจ JWT (token ที่ฝัง user id) ที่ middleware (ฟังก์ชันที่รันก่อน handler หลัก — บทที่ 5)
  • trace context ของ OpenTelemetry — มาตรฐานเก็บข้อมูล performance/tracing ที่ต้องโยง "span" (ช่วงเวลาของแต่ละชั้น) ของ request เดียวกันให้ติดกันเป็นกราฟ

ทาง naive = ส่ง parameter ลึกลงไปทุกชั้น (function foo(req, ...)) — code base โตขึ้น → ต้องไหลผ่าน 10+ function ลำบาก และ library ที่เขียนเองก็ไม่อยากผูก signature ทุกตัวกับ req

AsyncLocalStorage คือ primitive ของ Node (อยู่ใน module node:async_hooks) ที่ "เก็บค่าที่ flow ตาม async call chain อัตโนมัติ" — เปรียบเหมือน thread-local (ตัวแปรที่ผูกกับ thread ในภาษา multi-thread เช่น Java/.NET) แต่ Node เป็น single-thread + async จึงผูกกับ "async chain" แทน · ผู้อ่านที่มาจาก .NET เทียบได้กับ AsyncLocal<T>

javascript
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";

// 1 instance ต่อ "ค่าที่อยากส่งต่อ" (ปกติ singleton ระดับ module — สร้างครั้งเดียวต่อโปรเจกต์)
const requestContext = new AsyncLocalStorage();

// สมมุติเรามี Express app — รายละเอียดบทที่ 5 ตอนนี้รู้แค่ว่า app.use() = ลงทะเบียน middleware
// import express from "express";
// const app = express();

// ที่ middleware (HTTP entry) — ห่อ handler ทั้ง request ใน store
app.use((req, res, next) => {
  const ctx = {
    requestId: req.headers["x-request-id"] ?? randomUUID(),
    userId: req.user?.id,    // ถ้ามี auth middleware ก่อนหน้านี้ จะ set req.user ไว้แล้ว
  };
  requestContext.run(ctx, () => next());   // ⭐ ทุกอย่างที่ next() เรียกต่อ จะเห็น ctx นี้
});

// ที่ logger (ลึก 5 ชั้นจาก middleware)
function log(msg) {
  const ctx = requestContext.getStore();   // ดึงค่าของ request ปัจจุบัน
  console.log(JSON.stringify({ msg, requestId: ctx?.requestId, userId: ctx?.userId }));
}

// ที่ service (ลึก 10 ชั้น) — ไม่ต้องส่ง req/ctx ตามมา
async function getOrder(id) {
  log(`fetch order ${id}`);     // log จะมี requestId อัตโนมัติ
  return db.orders.find(id);
}

กลไก: ใต้ฝา Node ใช้ async_hooks track ว่า callback ไหน "สืบทอด" มาจากที่ไหน เมื่อ run(ctx, fn) เรียก fn ทุก promise/timer/callback ที่ schedule ภายในจะเห็น getStore() คืน ctx เดียวกัน — แม้ผ่าน await, setTimeout, EventEmitter

Pitfall ที่ต้องรู้

  1. ห้าม mutate store ตรง ๆ ระหว่างทาง — ใช้ run() ทุกครั้งเพื่อสร้าง scope ใหม่ (mutate store ระหว่างทางจะรั่วข้าม request)
  2. EventEmitter ที่ลงทะเบียน listener "นอก" run() — listener จะเห็น store ตอนลงทะเบียน ไม่ใช่ตอน emit — ถ้าลงทะเบียนตอน app start (ไม่มี ctx) แล้ว emit ภายใน request → getStore() คืน undefined
  3. Performance overhead — บน Node 18 ลงไป overhead เคยอยู่ที่ 5–15% ทำให้มีคนกลัวเปิดใช้ทั่วโปรเจกต์ · บน Node 22 LTS overhead ลดเหลือ <2% สำหรับ workload ปกติ (Node 19+ ปรับ Promise hooks ใหม่) — ใช้ได้สบายในระดับ middleware ของแอปทั่วไป แค่อย่าใส่ในลูปร้อน ๆ ที่รัน millions ครั้ง
  4. Pino logger (logger ยอดนิยมของ Node) มี integration ในตัว (pino-http + genReqId) — ไม่ต้องเขียน AsyncLocalStorage เอง

💡 ถ้าใช้ NestJS (framework ที่บทที่ 8–13 จะสอน) มี package ชื่อ nestjs-cls ให้ใช้ผ่าน DI สะดวกกว่าเรียก AsyncLocalStorage ตรง ๆ — ยังไม่ต้องสนตอนนี้ เก็บไว้อ่านตอนถึง NestJS


🛠️ Checkpoint 3 — ลงมือก่อนไปบทถัดไป

  1. เขียนฟังก์ชัน delay(ms) ที่คืน Promise แล้ว resolve หลัง ms (ใช้ setTimeout หรือ timers/promises) แล้วลอง await delay(500) 3 ครั้งติดกัน
  2. มี 3 ฟังก์ชัน async ที่ใช้เวลา 1 วิแต่ละตัว — เขียนแบบ await ทีละตัว (ได้ ~3 วิ) แล้วเขียนใหม่ด้วย Promise.all (ได้ ~1 วิ) จับเวลาเทียบด้วย console.time
  3. ทายผลลัพธ์ของโค้ด event loop ในข้อ 4 ก่อนรันจริง แล้วรันเช็กว่าตรงไหม
  4. สร้าง EventEmitter ชื่อ chat ที่มี event message — ลงทะเบียน listener 2 ตัว แล้ว emit("message", "สวัสดี") ดูว่าทั้งคู่ทำงาน · เพิ่ม once("join") แล้ว emit 2 ครั้ง ดูว่าทำงานครั้งเดียว
  5. ใช้ fetch + AbortSignal.timeout(2000) ยิงไปเว็บที่ช้า (หรือ https://httpbin.org/delay/5) แล้วจับ AbortError

เฉลยข้อ 1:

javascript
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// หรือ: import { setTimeout as delay } from "node:timers/promises";

สรุปบทที่ 3

  • async ใน Node มี 3 ยุค: callback (error-first)Promiseasync/await — ของใหม่ใช้ async/await + fs/promises
  • error-first callback = กติกา Node: (err, data) => ... เช็ค err ก่อนเสมอ
  • งานที่ไม่เกี่ยวกันใช้ Promise.all ยิงพร้อมกัน อย่า await ทีละตัวใน loop
  • promisify แปลง callback API เก่าเป็น Promise (แต่เช็ก node:*/promises ก่อน)
  • EventEmitter = pattern "ประกาศ/ฟัง" ที่อยู่ทั่ว Node (server, stream, process) — event "error" ที่ไม่มีคนฟัง = crash
  • event loop: sync → microtask (nextTick > queueMicrotask/Promise) → macrotask (setTimeout/setImmediate) — setImmediate มาก่อน setTimeout(fn,0) เสมอใน I/O callback
  • Promise combinator: all (ทุกตัว), race (ตัวแรก settle), any (ตัวแรกสำเร็จ), allSettled (ไม่ throw)
  • AbortController/AbortSignal.timeout = ยกเลิก/timeout งาน async
  • AsyncLocalStorage = "thread-local สำหรับโลก async" — flow request context (requestId, userId, trace) ทุกชั้นโดยไม่ต้องส่ง parameter

บทต่อไปเอา async มาใช้จริงกับ ไฟล์และ stream — รวมถึง "backpressure" ที่ทำให้ Node จัดการไฟล์ใหญ่ระดับ GB ได้โดยไม่ระเบิด memory

→ บทที่ 4: Filesystem + Streams