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

libuv & the Threadpool

ทุกอย่างที่โมดูลนี้ครอบคลุมมาจนถึงตอนนี้ — timer, microtask, setImmediate — เกิดขึ้นข้างใน library ภาษา C ที่ชื่อ libuv ซึ่ง Node process ทุกตัวรันอยู่หนึ่ง instance การวนแต่ละรอบของ loop ใน libuv จะผ่าน 6 phase เรียงตามลำดับเดิมเสมอ:

flowchart LR
Timers["timers
expired setTimeout/setInterval callbacks"] --> Pending["pending callbacks
some deferred system callbacks (e.g. certain TCP errors)"]
Pending --> Idle["idle, prepare
internal libuv bookkeeping"]
Idle --> Poll["poll
fetch new I/O events; run I/O callbacks (e.g. fs, sockets)"]
Poll --> Check["check
setImmediate callbacks"]
Check --> Close["close callbacks
socket.on('close'), etc."]
Close --> Timers
Phase ของ libuv event loop

phase poll คือตัวที่รอจริง ๆ: ถ้าไม่มีอะไรต้องทำ มันจะบล็อกจนกว่า I/O event จะมาถึงหรือ timer ถึงกำหนด นี่คือเหตุผลที่ setImmediate กับ setTimeout(fn, 0) ทำงานต่างกันขึ้นอยู่กับว่าถูกจัดคิวจากที่ไหน — ข้างใน I/O callback (ซึ่งอยู่ใน poll phase อยู่แล้ว) check คือ phase ถัดไปทันที ดังนั้น setImmediate จึงยิงก่อน timer เสมอ (ดูบท Microtasks & Macrotasks สำหรับการพิสูจน์ที่รันได้จริง)

libuv เก็บ pool ของ worker thread ขนาดคงที่และเล็ก (ค่าเริ่มต้น 4 ควบคุมด้วย environment variable UV_THREADPOOL_SIZE ซึ่งต้องตั้งก่อนที่ pool จะถูกใช้ครั้งแรก) สำหรับงานที่ทำด้วย async I/O ของ OS kernel ตรง ๆ ไม่ได้:

ใช้ threadpoolใช้ async I/O ของ kernel โดยตรง (ไม่ใช้ threadpool)
method แบบ async ของ fs (fs.readFile, fs.stat, …)TCP/UDP socket, server/client ของ net/http
crypto.pbkdf2, crypto.scrypt, crypto.randomBytes (แบบ async)setTimeout/setInterval/setImmediate
dns.lookup (ไม่ใช่ dns.resolve* ซึ่งใช้ c-ares โดยตรง)dns.resolve* ส่วนใหญ่
method แบบ async ของ zlib—

การแบ่งแบบนี้สำคัญเพราะ threadpool คือทรัพยากรที่จำกัดจริง ๆ และแชร์กัน — มีแค่ 4 อย่างจากคอลัมน์ซ้ายไหนก็ได้เท่านั้นที่รันแบบ parallel จริงบนฮาร์ดแวร์ได้พร้อมกัน ไม่ว่าจะ await พร้อมกันกี่ตัวก็ตาม ในขณะที่คอลัมน์ขวาสเกลไปตาม I/O multiplexing ของ OS เอง (epoll/kqueue/IOCP) ซึ่งจัดการ socket พร้อมกันได้เป็นพันตัวบนเธรดเดียวสบาย ๆ

crypto.pbkdf2 ผูกกับ threadpool ดังนั้นการรันมันพร้อมกันมากกว่าจำนวนเธรดใน pool ควรทำให้ตัวที่ช้าที่สุดใช้เวลานานขึ้นชัดเจน เดโมด้านล่างจับเวลา pbkdf2 หนึ่งครั้ง แล้วรัน 4 ครั้งพร้อมกัน แล้ว 8 ครั้ง (สองเท่าของขนาด pool เริ่มต้น) แล้วรายงานตัวที่ช้าที่สุดของแต่ละ batch เทียบกับ baseline ของครั้งเดียว:

Node.js
Output (verified on Node 24)
1 pbkdf2 call: baseline
4 concurrent pbkdf2 calls: ~1x a single hash (all fit in the pool at once)
8 concurrent pbkdf2 calls (default UV_THREADPOOL_SIZE=4): ~2x a single hash (some calls queued behind the rest)

การเรียก 4 ครั้งพร้อมกันเข้า pool ได้พอดีและเสร็จในเวลาประมาณครั้งเดียว ส่วน 8 ครั้งพร้อมกันต้องใช้ 2 รอบเต็มผ่าน 4 เธรด ตัวที่ช้าที่สุดจึงใช้เวลาประมาณสองเท่า — batch ของ 8 จริง ๆ แล้วคือ batch ของ 4 สองรอบ รอบหนึ่งต่อคิวอยู่หลังอีกรอบ UV_THREADPOOL_SIZE ถูกอ่านครั้งเดียวตอนที่ libuv สร้าง pool ครั้งแรก — ตั้งค่ามัน (เป็น environment variable จริง หรือผ่าน process.env.UV_THREADPOOL_SIZE ก่อนที่จะมีการเรียกที่ใช้ threadpool ตัวไหนเลย) แล้วการเรียก 8 ครั้งพร้อมกันแบบเดิมก็จะไม่ต้องต่อคิวอีก:

Node.js
Output (verified on Node 24)
UV_THREADPOOL_SIZE=8, 8 concurrent pbkdf2 calls: ~1x a single hash

ไม่มีขนาด pool ที่ “ถูกต้อง” ตายตัว — pool ที่ใหญ่กว่าแปลว่า parallelism จริงมากกว่าสำหรับงานที่ผูกกับ threadpool แลกมาด้วย OS thread ที่มากขึ้น (memory, context-switching) ที่แย่งงาน CPU-bound กับเธรด JS หลักของคุณเองบนเครื่องที่งานหนัก

ให้นึกภาพโต๊ะทำงานสี่โต๊ะในห้องหลังร้าน (threadpool) หลังเคาน์เตอร์หน้าร้านหนึ่งตัว (เธรด JS) คนที่เคาน์เตอร์ (โค้ด JS ของคุณ) ส่งงานบางประเภทได้ — hash รหัสผ่าน, อ่านไฟล์, DNS lookup — ไปที่โต๊ะไหนก็ได้ที่ว่าง แล้วได้รับแจ้งเตือน (ผ่าน callback) เมื่อเสร็จ แต่มีแค่สี่โต๊ะ: งานที่ห้าต้องรอโต๊ะว่าง ไม่ว่าจะมีลูกค้ารอที่เคาน์เตอร์กี่คนก็ตาม ในขณะเดียวกัน socket กับ timer ไม่ได้ถูกจัดการที่โต๊ะเลย — หน้าร้านมีสายตรงไปยังระบบสาธารณูปโภคของตึก (OS kernel) ซึ่งจัดการ connection ที่ยังเปิดอยู่เป็นพันตัวได้โดยไม่ต้องมีโต๊ะต่อ connection

Threadpool ขนาดคงที่ที่แชร์กันแปลว่าการใช้ resource คาดเดาได้ — Node จะไม่สร้าง OS thread ไม่จำกัดไม่ว่าคุณจะยัดงาน fs/crypto/dns.lookup/zlib เข้าไปมากแค่ไหน — แต่ก็แปลว่างานทั้งหมดนั้นแย่งช่องเดียวกันสี่ช่อง ไม่ว่าการเรียกเหล่านั้นจะเกี่ยวข้องกันในเชิงตรรกะหรือไม่ การเพิ่ม UV_THREADPOOL_SIZE แลก memory และ overhead การสลับ thread ของ OS เพื่อ parallelism จริงที่มากขึ้น มันเป็นปุ่มปรับระดับทั้งโปรเซสแบบหยาบ ๆ ไม่ใช่อะไรที่ scope ให้เฉพาะ “operation หนักตัวนี้ตัวเดียว” ได้ สำหรับงาน CPU-bound จริง ๆ ที่ไม่อยู่ในรายการ operation ที่ threadpool รองรับ (การคำนวณ synchronous ของคุณเอง ไม่ใช่ fs/crypto/dns/zlib) worker_threads คือเครื่องมือที่ถูกต้อง — มันไม่แชร์ libuv threadpool เลย

  • “threadpool ของ Node จัดการงาน async ทั้งหมด” มันจัดการแค่ operation เฉพาะในคอลัมน์ซ้ายด้านบนเท่านั้น Socket, HTTP และ timer ไม่แตะมันเลย — พวกมันเป็น async I/O ของ OS kernel ซึ่งเป็นกลไกแยกต่างหากที่ไม่มีจำนวนเธรดตายตัวให้หมด
  • “await Promise หลายตัวพร้อมกันแปลว่าทุกตัวรันเร็วเท่า parallel จริง” รันได้ถึงแค่ขนาดของ threadpool (ค่าเริ่มต้น 4) สำหรับ operation ที่ผูกกับ threadpool เท่านั้น Promise.all เริ่ม operation ข้างใต้พร้อมกันทั้งหมดก็จริง แต่ตัว pool เองยังคง serialize อะไรก็ตามที่เกิน 4 ตัวในเวลาเดียวกัน
  • “การเพิ่ม UV_THREADPOOL_SIZE ไม่มีข้อเสียเลย” เธรดที่มากขึ้นแปลว่า context-switching ระดับ OS และ memory ที่มากขึ้น และบนเครื่องที่มีงาน JS แบบ CPU-bound รันอยู่ด้วย ก็จะแย่ง CPU core จริงมากขึ้น — มันคือ trade-off ไม่ใช่ของฟรี

โค้ดที่ AI สร้างที่ hash รหัสผ่านหรืออ่านไฟล์หลายไฟล์ “พร้อมกัน” ด้วย Promise.all มักสมมติว่า concurrency ของ Node ไม่จำกัด — ซึ่งไม่จริงเลยสำหรับอะไรก็ตามที่ผ่าน threadpool และการผสมงานที่กิน threadpool หนัก ๆ กับ I/O อื่นที่ไม่เกี่ยวข้องอาจทำให้ I/O นั้นอดตายได้

🐛 หา bug ในโค้ดนี้

// AI-written password check middleware, called on every login request
async function checkPassword(candidate, storedHash) {
return new Promise(function (resolve, reject) {
crypto.pbkdf2(candidate, 'salt', 200000, 64, 'sha512', function (err, derivedKey) {
if (err) reject(err); else resolve(derivedKey.toString('hex') === storedHash);
});
});
}
// Comment left by the AI: "async, so this scales to as many concurrent logins as needed"
เฉลย

crypto.pbkdf2 รันบน threadpool ของ libuv ซึ่งมีแค่ 4 เธรดโดยค่าเริ่มต้น — มันไม่สเกลไปตาม “จำนวน login พร้อมกันเท่าที่ต้องการ” เลย เมื่อมี login พร้อมกันเป็น burst การเรียก checkPassword ตัวที่ 5 เป็นต้นไปจะต่อคิวอยู่หลัง 4 ตัวแรก เพิ่ม latency จริง ที่แย่กว่านั้นคือถ้าโปรเซสเดียวกันมีการอ่าน fs แบบ async, dns.lookup หรือการบีบอัด zlib ที่อื่นในแอปด้วย สิ่งเหล่านั้นแชร์เธรดสี่ตัวเดียวกันเป๊ะ ๆ — burst ของการเช็ก login สามารถทำให้ file operation หรือ DNS operation ที่ไม่เกี่ยวข้องกันเลยที่อื่นในแอปช้าลงอย่างวัดได้จริง วิธีแก้ไม่ใช่การแก้โค้ดฟังก์ชันนี้เท่าไหร่ แต่เป็นการวางแผนกำลัง (capacity): เพิ่ม UV_THREADPOOL_SIZE ถ้า workload คุ้ม, จำกัด rate ของการ hash พร้อมกัน หรือย้ายไป worker_threads ถ้าต้องแยกออกจาก threadpool consumer อื่น

Phase ใดของ libuv event loop รัน callback ของ setImmediate?
ขนาดเริ่มต้นของ threadpool ใน libuv คือเท่าไร?
ข้อใดต่อไปนี้ไม่ได้ใช้ threadpool ของ libuv?
โค้ดนี้วัดอะไร และควรคาดหวังผลอย่างไร? เมื่อ hashOnce() คือการเรียก crypto.pbkdf2 ที่ผูกกับ threadpool และ pool ถูกปล่อยไว้ที่ขนาดเริ่มต้น
const t0 = performance.now();
await Promise.all(Array.from({ length: 8 }, () => hashOnce()));
console.log(performance.now() - t0);
comment ของ AI อ้างว่าการเช็ก password ด้วย pbkdf2 นั้น "async ดังนั้นสเกลไปตามจำนวน login พร้อมกันเท่าที่ต้องการ" ทำไมถึงผิด?