Giao diện
Chặn event loop & xử lý CPU
Trang Event loop đã đo một trường hợp: mỗi giây chỉ một request tốn 200 ms CPU cũng làm p99 của mọi request khác tăng 50 lần. Trang này đi tiếp từ đó và trả lời bốn câu hỏi. Những đoạn code nào hay chặn event loop, và mỗi đoạn tốn bao lâu? Làm sao tìm ra đoạn đang chặn? Có những cách xử lý nào, mỗi cách tốn gì? Khi nào thì nên đưa việc ra khỏi Node? Số liệu trong trang đo bằng script lab/chan-event-loop/ trên MacBook M2 với Node 24.
Bao lâu thì gọi là chặn
Callback nào cũng chạy trên main thread, và trong lúc nó chạy thì mọi request khác phải chờ. Thời gian chặn vì vậy cộng thẳng vào latency của những request đến đúng lúc đó. Có hai con số cần để ý:
- Độ dài của mỗi lần chặn quyết định p99 và max. Một callback 50 ms làm request đến cùng lúc chậm thêm tới 50 ms. Nếu mục tiêu p99 của service là 100 ms, thì riêng callback đó đã ăn một nửa.
- Tổng thời gian chặn mỗi giây là độ bận của main thread, tức event loop utilization (ELU). Mười request mỗi giây, mỗi request 50 ms CPU, làm main thread bận 50%. Độ bận càng gần 100% thì hàng đợi càng dài và latency càng vọt lên, theo đúng đường cong độ bận và latency.
Tài liệu Node không đưa ra ngưỡng cố định, chỉ nói mỗi callback phải xong nhanh. Ngưỡng hợp lý tuỳ mục tiêu latency của từng service. Với một API thông thường, phần CPU của mỗi request thường chỉ vài ms. Vì vậy một đoạn code chạy vài chục ms đã đáng xem lại, còn từ 100 ms trở lên thì gần như chắc chắn gây chuyện khi có tải.
Những đoạn code hay chặn, đo thật
JSON.parse và JSON.stringify
Mỗi dòng giống kết quả một câu SELECT đơn hàng, khoảng 180 byte JSON. Mỗi ô là trung vị của 3 lần chạy, ghi khoảng giá trị qua hai lượt đo:
| Kích thước | Số dòng | JSON.stringify | JSON.parse |
|---|---|---|---|
| 0,9 MB | 5.000 | 2,5–2,9 ms | 1,9 ms |
| 9 MB | 50.000 | 25–27 ms | 19 ms |
| 45 MB | 250.000 | 140–151 ms | 109–147 ms |
Tính ra mỗi MB tốn khoảng 2–3 ms cho mỗi chiều. Tài liệu Node có một ví dụ với chuỗi 50 MB, stringify mất 0,7 giây và parse mất 1,3 giây. Số đo ở đây nhanh hơn nhiều vì dữ liệu có cấu trúc khác và máy khác. Điểm chung là thời gian tăng tuyến tính theo kích thước, và JSON.parse là một lời gọi duy nhất nên không chia nhỏ được.
Hai chỗ hay gặp ngoài đời:
- Response quá lớn. API trả về 50.000 dòng làm main thread bận 25 ms chỉ để
stringify, chưa tính thời gian tạo 50.000 object từ kết quả database. Phân trang (Phân trang) chữa được cả hai. - Body request không giới hạn.
express.json()mặc định chỉ nhận body tới 100 KB. Nâng giới hạn này lên vài chục MB thì một client gửi body 50 MB là đủ làm server đứng hơn 100 ms mỗi lần. Chỉ nâng giới hạn ở route thật sự cần, ví dụ route import.
Vòng lặp, thuật toán và hàm …Sync
| Việc | Thời gian |
|---|---|
Sắp xếp 200.000 dòng theo số (amount) | 20–23 ms |
Sắp xếp 200.000 dòng theo chuỗi bằng localeCompare | 37–39 ms |
Gom nhóm 200.000 dòng theo user_id bằng Map | 5,7–6,0 ms |
gzipSync 9 MB JSON | 58–62 ms |
Lọc trùng 10.000 id bằng includes / bằng Set | 7,5 ms / 0,23 ms |
Lọc trùng 20.000 id bằng includes / bằng Set | 30 ms / 0,49–0,52 ms |
Lọc trùng 40.000 id bằng includes / bằng Set | 117 ms / 1,1 ms |
Ba dòng cuối là lỗi hay gặp nhất. Mỗi lần gọi includes duyệt lại cả mảng kết quả, nên vòng lặp tốn O(n²): dữ liệu gấp 4 lần thì thời gian gấp 16 lần. Lúc dev với vài trăm dòng thì không ai thấy chậm. Lên production với 40.000 dòng thì một request chặn main thread hơn 100 ms. Set tra trong thời gian gần như cố định nên cả vòng lặp chỉ tốn O(n). Các lời gọi find, filter, indexOf đặt trong vòng lặp có cùng vấn đề.
Hàm …Sync cũng chặn theo cách này. crypto.pbkdf2Sync mất 104–108 ms mỗi lần (số đo ở trang Event loop), còn bản không có Sync thì chạy trên thread pool. Đọc file cấu hình bằng readFileSync lúc khởi động thì không sao, vì lúc đó server chưa nhận request nào.
GC cũng dừng main thread. Một lần GC lớn trên heap có nhiều object sống lâu làm event loop delay vọt lên dù code không làm gì nặng (Bộ nhớ & GC).
Regex và ReDoS
Regex có lượng từ lồng nhau như /^(a+)+$/ có thể chạy rất lâu với một chuỗi được tạo ra có chủ đích. Lỗi này gọi là ReDoS (regular expression denial of service). Đo với chuỗi gồm n chữ a và một dấu ! ở cuối:
| n | /^(a+)+$/ | /^a+$/ |
|---|---|---|
| 20 | 6,4–7,3 ms | dưới 0,01 ms |
| 22 | 25–26 ms | dưới 0,01 ms |
| 24 | 98–102 ms | dưới 0,01 ms |
| 26 | 394–440 ms | dưới 0,01 ms |
| 28 | 1.623–1.709 ms | dưới 0,01 ms |
Mỗi chữ a thêm vào làm thời gian gấp đôi. Engine regex của V8 chạy theo kiểu backtracking: gặp chỗ không khớp thì quay lại thử cách khác. Nhóm (a+)+ chia được n chữ a thành 2n−1 cách, và vì dấu ! làm regex không bao giờ khớp, engine thử hết mọi cách chia rồi mới chịu thua. Chuỗi 29 ký tự đủ làm server đứng gần 2 giây. Regex /^a+$/ kiểm cùng một điều kiện mà chỉ có một cách khớp, nên chuỗi 10.000 ký tự cũng chỉ mất 0,01 ms.
Thử ngay trong trình duyệt. Trình duyệt cũng chạy JavaScript trên một thread, nên trong lúc regex chạy, cả trang đứng yên giống hệt server Node:
RegexChuỗi
"aaaaaaaaaaaaaaaaaaaaaa!"
2.097.152số cách chia 22 chữ a thành các nhóm (a+)
≈ 25,6 msdự đoán theo số đo trên Node
0,0 sđồng hồ chạy bằng setInterval 100 ms
Với /^(a+)+$/, mỗi chữ a thêm vào làm số cách chia gấp đôi. Khi chuỗi có dấu ! ở cuối, regex không khớp, nên engine thử lần lượt mọi cách chia rồi mới chịu thua, và thời gian cũng gấp đôi theo. Khi chuỗi khớp, cách chia đầu tiên đã đúng nên regex xong ngay. Vì vậy lỗi này không hiện ra khi test bằng dữ liệu hợp lệ.
Thanh kéo dừng ở 27 để trang không đơ quá vài giây. Thời gian trên trình duyệt khác Node tuỳ engine regex và máy.
Regex kiểu này ngoài đời trông vô hại hơn nhiều, ví dụ /^([a-z0-9]+-?)+$/ để kiểm slug. Dấu - không bắt buộc, nên [a-z0-9]+ nằm trong (…)+ cũng là lượng từ lồng nhau. Với chuỗi kết thúc bằng hai dấu gạch (không khớp), slug 27 ký tự mất 0,3–0,5 ms, 33 ký tự mất 1,5–2,2 ms, còn 39 ký tự mất 17–19 ms. Cứ thêm 6 ký tự thì thời gian tăng 5–10 lần. Cách phòng:
- Tránh các mẫu nguy hiểm mà tài liệu Node liệt kê: lượng từ lồng nhau như
(a+)*, phép OR có nhánh chồng lên nhau như(a|a)*, và backreference như(a.*) \1. Tài liệu Node cũng nhắc các công cụ kiểm như safe-regex, nhưng lưu ý chúng không bắt được mọi trường hợp. - Giới hạn độ dài input trước khi chạy regex. Slug dài 200 ký tự đã là bất thường, nên kiểm độ dài trước sẽ chặn được phần lớn đòn tấn công.
- Không tạo regex từ input của người dùng (
new RegExp(req.query.q)). Nếu cần tìm theo chuỗi người dùng nhập thì dùngincludes, hoặc escape các ký tự đặc biệt trước. - Dùng engine không backtracking. Package
re2(node-re2) dùng engine RE2 của Google, chạy trong thời gian tuyến tính nhưng không tương thích 100% với regex của V8. V8 cũng có engine tuyến tính riêng, bật bằng cờ thử nghiệm. Với cờ--enable-experimental-regexp-enginevà regex có cờl, đo được/^(a+)+$/lvới n = 28 chỉ mất 0,03–0,32 ms. Cờ--enable-experimental-regexp-engine-on-excessive-backtrackstự chuyển sang engine tuyến tính khi một regex thường backtrack quá nhiều (mặc định 50.000 lần), và đo được 1,0–1,3 ms. Engine này không hỗ trợ backreference, lookahead, lookbehind. Vì vẫn là cờ thử nghiệm, cần thử kỹ trước khi bật trên production.
Tìm đoạn code đang chặn
Bước 1: biết là đang bị chặn. Event loop delay và ELU trên dashboard cho biết main thread có bị chặn không và bị chặn bao lâu (cách đo ở trang Event loop). Nếu p99 của event loop delay cao trong khi CPU của cả máy thấp, thì gần như chắc chắn có một đoạn code đồng bộ chạy lâu.
Bước 2: tìm hàm. Chạy với --cpu-prof. Node bật CPU profiler của V8 lúc khởi động, lấy mẫu mặc định mỗi 1.000 µs, và ghi file .cpuprofile khi process thoát. Mở file này trong tab Performance của Chrome DevTools sẽ thấy flame chart. Muốn tính nhanh thì đọc thẳng file, vì nó chỉ là JSON gồm cây lời gọi, danh sách mẫu và khoảng thời gian giữa các mẫu. Kết quả với một ứng dụng giả có ba hàm chậm (lab/chan-event-loop/slow-app.mjs), xếp theo self time, tức thời gian chạy trong chính hàm đó, không tính các hàm nó gọi:
81,4% 840 ms uniqueUserIds slow-app.mjs:7 ← lọc trùng bằng includes
7,8% 80 ms buildReport slow-app.mjs:18 ← JSON.stringify
6,3% 65 ms RegExp: ^([a-z0-9]+-?)+$ ← regex kiểm slug
1,0% 10 ms (idle)Profiler chỉ thẳng ra dòng code. Regex hiện thành một mục riêng mang nguyên văn mẫu regex, nên dễ nhận ra ReDoS. Cách dùng profiler trên production và đọc flame chart nằm ở trang Hiệu năng & profiling.
Bước 3: bắt các hàm …Sync. Cờ --trace-sync-io in stack trace mỗi khi code gọi API I/O đồng bộ sau vòng đầu tiên của event loop. Những lời gọi lúc khởi động thì Node bỏ qua:
(node:72792) WARNING: Detected use of sync API
at openSync (node:fs:560:18)
at readFileSync (node:fs:444:35)
at file:///…/slow-app.mjs:23:3Cờ này chỉ bắt I/O đồng bộ như readFileSync. Vòng lặp nặng, JSON.parse hay regex thì phải tìm bằng profiler.
Các cách xử lý
Xếp theo thứ tự nên thử trước:
- Làm cho việc rẻ đi.
- Chia nhỏ việc để nhường event loop.
- Đẩy việc sang pool worker trong cùng process.
- Đẩy việc ra khỏi process: process con, hàng đợi, service riêng.
1. Làm cho việc rẻ đi
Cách này thường cho kết quả tốt nhất, vì nó bớt hẳn phần CPU chứ không chỉ dời nó đi chỗ khác:
- Đổi thuật toán.
Setthayincludesbiến 117 ms thành 1,1 ms. Gom nhóm bằngMapmột lượt thay vìfiltercho từng nhóm. - Để database làm.
GROUP BY,ORDER BYcó index,LIMIT. Database làm việc này trên process khác và thường nhanh hơn, còn Node chỉ nhận kết quả đã gọn. - Giới hạn kích thước. Phân trang response, giới hạn body request, giới hạn độ dài chuỗi trước khi chạy regex.
- Cache kết quả của phép tính tốn kém mà nhiều request dùng chung (Cache).
2. Chia nhỏ việc
Tài liệu Node gọi cách này là partitioning: chạy một phần việc, nhường event loop để callback khác chạy xen vào, rồi chạy phần tiếp theo. Trang Event loop đã giải thích vì sao phải nhường bằng setImmediate chứ không dùng process.nextTick hay await Promise.resolve(). Hai chi tiết sau làm kết quả khác nhau rõ rệt.
Chia theo thời gian, không chia theo số phần tử. Chia mỗi phần 1.000 phần tử thì mỗi phần tốn bao lâu tuỳ dữ liệu. Chạy tới khi hết một ngân sách thời gian rồi mới nhường thì thời gian chặn luôn nằm trong giới hạn:
js
const yieldLoop = () => new Promise((resolve) => setImmediate(resolve))
async function processWithBudget(items, fn, budgetMs = 5) {
await yieldLoop() // nhường cả trước phần đầu, lý do ở dưới
let start = performance.now()
for (let i = 0; i < items.length; i++) {
fn(items[i])
// Xem đồng hồ sau mỗi 1.000 phần tử cho đỡ tốn
if (i % 1000 === 999 && performance.now() - start > budgetMs) {
await yieldLoop()
start = performance.now()
}
}
}Đo với 2.000.000 phần tử, chạy một lần mất 447–581 ms và chặn main thread suốt khoảng đó. Chia theo ngân sách 5 ms thì tổng thời gian gần như không đổi (454–492 ms), còn main thread chỉ bị chặn lâu nhất 4,3–7,3 ms (6 lượt).
Nhường trước phần đầu tiên. Đoạn code ở trang Event loop nhường sau mỗi phần, nên phần đầu chạy ngay trong handler của request, tức ở pha poll. Ngay sau pha poll là pha check, nơi setImmediate vừa hẹn chạy phần thứ hai, nên hai phần chạy liền nhau. Request nào đến trong lúc phần đầu đang chạy phải chờ hết cả hai phần. Đo với mỗi phần 20 ms: nhường sau mỗi phần cho p99 của request khác 57–77 ms, còn nhường trước mỗi phần thì còn 33–35 ms.
Chia nhỏ có ba giới hạn:
- Chỉ dùng một core. Main thread vẫn phải làm toàn bộ phần CPU, chỉ là làm xen kẽ. Tài liệu Node nói rõ partitioning không tận dụng được nhiều core. Với 4 việc 200 ms mỗi giây, main thread vẫn bận khoảng 80%, nên p50 của request khác tăng từ 1,3 ms lên 2,4–3,3 ms.
- Việc nặng xong muộn hơn, vì nó nhường chỗ cho các callback khác.
- Chỉ chia được việc tự viết bằng vòng lặp.
JSON.parse,zlib.gzipSync, một lần chạy regex đều là một lời gọi duy nhất, không chen vào giữa được.
3. Đẩy sang pool worker
Mỗi worker của worker_threads là một thread có V8 và event loop riêng (Process, thread & concurrency). Tài liệu Node viết rằng worker có ích cho việc JavaScript tốn CPU, còn với I/O thì không giúp được mấy. Tài liệu cũng khuyên dùng một pool worker, vì nếu tạo worker mới cho từng việc thì chi phí tạo có thể lớn hơn lợi ích. Đo ở trang Process, thread, mỗi worker mất 15–16 ms để khởi động và tốn khoảng 8,5 MB RSS.
Thư viện piscina quản lý sẵn pool, hàng đợi và việc huỷ task:
js
// report.worker.js: chạy trong worker
export default function buildReport({ json }) {
const rows = JSON.parse(json)
// … gom nhóm, tính tổng …
return { total: rows.length, sum: rows.reduce((s, r) => s + r.amount, 0) } // trả về kết quả nhỏ
}
// pool.js: dùng chung cho cả process
import { Piscina } from 'piscina'
export const pool = new Piscina({
filename: new URL('./report.worker.js', import.meta.url).href,
maxThreads: 2, // đủ cho việc nặng, chừa core cho main thread
maxQueue: 'auto' // hàng đợi tối đa maxThreads², đầy thì run() báo lỗi ngay
})
// Trong handler
const result = await pool.run({ json: body }, { signal: AbortSignal.timeout(2000) })Đã chạy thử với piscina 5.1.4. Trên máy 8 core, pool mặc định có minThreads 4 và maxThreads 12, hàng đợi không giới hạn. Đặt maxQueue: 'auto' thì khi hàng đợi đầy, run() reject ngay với lỗi Task queue is at limit, nên server có thể trả 503 thay vì để việc dồn lại mãi. Khi signal bị huỷ, task reject với AbortError sau khoảng 100 ms (đo với signal huỷ lúc 100 ms), và pool vẫn chạy tiếp các task sau bình thường.
Chép dữ liệu qua lại không miễn phí
Main thread và worker không dùng chung biến. Dữ liệu gửi qua postMessage được chép theo thuật toán structured clone, và phần chép diễn ra trên cả hai thread. Đo với việc parse 44 MB JSON (250.000 dòng) rồi cộng tổng, 3 lượt mỗi cách, ghi khoảng qua hai lần chạy:
| Cách | Tổng thời gian | Main thread bị chặn lâu nhất |
|---|---|---|
| Parse ngay trên main thread | 118–190 ms | 118–190 ms |
| Gửi chuỗi JSON sang worker, nhận về tổng | 132–189 ms | 5,5–12 ms |
Gửi ArrayBuffer theo kiểu transfer, nhận về tổng | 150–168 ms | 0,25–3,1 ms |
| Worker parse xong, gửi nguyên mảng object về main | 397–497 ms | 146–268 ms |
Dòng cuối là cái bẫy. Đẩy JSON.parse sang worker rồi gửi mảng đã parse về main thread thì main thread bị chặn còn lâu hơn tự parse, vì dựng lại 250.000 object từ dữ liệu clone còn tốn hơn parse chuỗi JSON. Chi phí riêng của postMessage khi gửi đi và nhận lại cùng dữ liệu:
| Dữ liệu | Mảng object | Chuỗi JSON cùng nội dung |
|---|---|---|
| 1.000 dòng | 1,6–1,8 ms | 0,18 ms |
| 50.000 dòng | 84–100 ms | 7–26 ms |
| 250.000 dòng | 555–717 ms | 60–107 ms |
Từ đó có ba quy tắc khi dùng worker:
- Gửi đi dữ liệu thô, nhận về kết quả nhỏ. Chép chuỗi hay
Buffernhanh hơn chép mảng object nhiều lần. Kết quả nên là con số, bản tóm tắt, hoặc mộtBufferđã nén hay đã render. - Dùng transfer cho dữ liệu nhị phân lớn. Đưa
ArrayBuffervàotransferList(trong piscina làPiscina.move()) thì vùng nhớ được chuyển hẳn sang worker chứ không chép, và bên gửi không dùng được nó nữa. - Đặt phần việc nặng ở phía worker. Worker nên tự đọc file, tự query, tự parse, thay vì để main thread chuẩn bị dữ liệu lớn rồi gửi sang.
Dừng một việc chạy quá lâu
Code đồng bộ đang chạy trên main thread thì không gì ngắt được, kể cả AbortController. Chỉ có hai cách:
- Chạy trong worker rồi
worker.terminate(). Đo với regex ReDoS n = 34 (chạy bình thường mất vài phút): gọiterminate()sau 100 ms thì worker dừng sau 1,1 ms, và trong 100 ms đó timer 10 ms trên main thread vẫn chạy đủ 10 lần. vm.runInNewContext(code, ctx, { timeout })ném lỗiERR_SCRIPT_EXECUTION_TIMEOUTsau đúng thời gian đặt (đo 100,5–102,1 ms với timeout 100 ms, kể cả khi đang kẹt trong regex). Nhưng nó chạy trên main thread, nên main thread vẫn bị chặn tới hết timeout. Tài liệu Node cũng nhấn mạnhvmkhông phải cơ chế bảo mật, không dùng để chạy code không tin cậy.
So sánh, đo thật
Server nhận 100 request mỗi giây vào /fast (trả lời ngay) trong 8 giây, cộng thêm việc nặng 200 ms CPU vào /job theo từng cách. Client mở sẵn 50 connection keep-alive. Ghi khoảng giá trị qua 3 lượt:
| Cách | p50 /fast | p99 /fast | max /fast | p50 việc nặng |
|---|---|---|---|---|
| Không có việc nặng | 1,2–1,3 ms | 2,7–17,8 ms | 13–57 ms | |
| 1 việc/giây, chạy thẳng trên main thread | 1,3–1,4 ms | 223–239 ms | 236–255 ms | 232–237 ms |
| Chia nhỏ, mỗi phần 1 ms | 1,2–1,3 ms | 3,5–4,7 ms | 5,1–28 ms | 231–234 ms |
| Chia nhỏ, mỗi phần 5 ms | 1,3–1,4 ms | 8,0–10,5 ms | 13–37 ms | 233–238 ms |
| Chia nhỏ, mỗi phần 20 ms | 1,3–1,4 ms | 33–35 ms | 37–43 ms | 228–238 ms |
| Chia nhỏ 20 ms, nhường sau mỗi phần | 1,3–1,4 ms | 57–77 ms | 66–143 ms | 231–252 ms |
| Pool 2 worker | 1,0–1,1 ms | 3,9–18 ms | 19–34 ms | 234–238 ms |
| 4 việc/giây, chạy thẳng trên main thread | 85–98 ms | 206–749 ms | 230–1.949 ms | 202–222 ms |
| Chia nhỏ, mỗi phần 5 ms | 2,4–3,3 ms | 6,7–8,1 ms | 9,2–17,5 ms | 202–218 ms |
| Pool 2 worker | 0,75–0,94 ms | 1,4–9,0 ms | 1,7–53 ms | 203–218 ms |
| Pool 4 worker | 0,73–0,94 ms | 1,8–38 ms | 6,9–73 ms | 203–214 ms |
Bảng cho thấy năm điều:
- Chạy thẳng trên main thread thì p99 bằng độ dài việc nặng. Lên 4 việc mỗi giây, main thread bận 80%, và cả p50 cũng lên gần 100 ms.
- Chia nhỏ thì p99 xấp xỉ độ dài mỗi phần. Phần càng nhỏ thì request khác càng ít phải chờ. Tổng thời gian của việc nặng gần như không đổi, vì mỗi lần nhường bằng
setImmediaterất rẻ. - Worker giữ request khác nhanh như lúc không có việc nặng, kể cả khi có 4 việc mỗi giây, vì main thread chỉ tốn thời gian gửi việc đi và nhận kết quả về.
- Pool lớn hơn không chắc tốt hơn. Pool 4 worker có lượt cho p99 38 ms, tệ hơn pool 2. Khi 4 worker cùng chạy, chúng tranh 4 core hiệu năng cao của M2 với main thread và với chính client đo. Trang Event loop cũng gặp chuyện tương tự khi tăng thread pool lên 8. Nên đặt số worker nhỏ hơn số core, chừa chỗ cho main thread.
- Có một chi tiết của phần cứng. Việc nặng 200 ms đo được 230 ms trên server, và phần 20 ms đầu tiên của mỗi việc mất tới 40 ms. Đo riêng (
cpu-ramp.mjs): sau khi nghỉ 1 giây, 20 ms việc đầu tiên mất 28–42 ms, phần thứ hai 23–29 ms, phần thứ ba mới về khoảng 20 ms. Nghỉ 50 ms thì gần như không chậm đi. Với cùng đoạn code, CPU cần khoảng 100 ms để lên đủ tốc độ, nên nhiều khả năng đây là cơ chế tiết kiệm điện của máy chứ không phải của Node (chưa kiểm). Máy chủ thường cấu hình khác laptop, nên đừng lấy con số này làm chuẩn.
Mô phỏng dưới dùng thông số lấy từ các số đo trên. Thử tăng số việc mỗi giây lên 5–6 với main thread để thấy hàng đợi dài dần, thu nhỏ hay phóng to phần chia nhỏ, hoặc giảm pool xuống 1 worker khi có nhiều việc:
Cách xử lý
1,4 msp50 /fast
216 msp99 /fast
230 msmax /fast
231 msp50 việc nặng
22%main thread bận
việc nặng đang chạyrequest /fast (mỗi vạch dưới là một request, chiều cao là latency)request /fast chậm hơn 20 ms
Mỗi việc nặng chiếm main thread 200 ms liền. Request /fast đến trong khoảng đó phải chờ việc xong, nên p99 gần bằng thời gian của việc nặng dù p50 không đổi.
Đo thật với cấu hình này (2 lượt): p50 /fast 1,3–1,4 ms, p99 223–239 ms, max 236–255 ms, việc nặng p50 232–237 ms.
Mô phỏng 8 giây, 100 request/giây đến ngẫu nhiên (cùng một chuỗi ngẫu nhiên cho mọi cấu hình), việc nặng đến đều đặn. Thông số lấy từ số đo: mỗi request /fast tốn 0,1 ms trên main thread cộng 1,2 ms mạng, gửi hay nhận một việc từ worker tốn 0,3 ms. Khi bật tăng tốc chậm, thread rảnh lâu thì chạy ở 40% tốc độ rồi tăng dần lên đủ sau 100 ms. Mô phỏng giả định đủ core cho mọi worker, bỏ qua GC và việc connection mới cần thêm vòng lặp.
4. Đẩy ra khỏi process
Có ba lựa chọn, tuỳ việc nặng kéo dài bao lâu và cần cô lập tới đâu:
- Process con (
child_process) khi cần chạy chương trình khác nhưffmpeg, ImageMagick hay một script Python. Process con có bộ nhớ riêng, nên nó crash hay rò bộ nhớ thì server không chết theo. Đổi lại, mỗi process tốn 22–24 ms để khởi động và khoảng 38 MB RSS (số đo). Tài liệu Node khuyên không tạo một process con cho mỗi client, mà giữ một pool process. - Hàng đợi khi việc kéo dài từ vài giây trở lên, như xuất báo cáo, resize video hay gửi email hàng loạt. API nhận yêu cầu, ghi việc vào hàng đợi rồi trả về
202 Acceptedkèm id. Một service worker riêng lấy việc ra xử lý, và client hỏi lại kết quả theo id hoặc nhận webhook. Service worker scale độc lập với API, retry được khi lỗi, và việc không mất khi deploy (Hàng đợi, Hàng đợi bằng database). - Service riêng viết bằng ngôn ngữ khác (Go, Rust, Java), khi phần tính toán là trọng tâm của service đó.
cluster không chữa được việc bị chặn
Chạy nhiều process bằng cluster, PM2 hay nhiều container làm tăng tổng năng lực xử lý, nhưng mỗi process vẫn chỉ có một main thread. Request được chia vào process đang chạy việc nặng thì vẫn phải chờ. Load balancer thường chia đều theo vòng, không biết process nào đang bận. Nhiều process chỉ làm giảm tỉ lệ request bị ảnh hưởng, còn mỗi request rơi trúng process đang bận vẫn chậm y như trước (Scale Node.js).
Vì sao Node không hợp với việc nặng CPU
Tài liệu Node viết rằng nếu server phụ thuộc nhiều vào tính toán phức tạp thì nên cân nhắc Node có thật sự hợp không. Node rất mạnh với việc I/O, nhưng với việc tính toán tốn kém thì có thể không phải lựa chọn tốt nhất. Có ba lý do:
- Event loop là cooperative. Một callback chạy lâu thì không ai ngắt được nó. Với mô hình mỗi request một thread của Java hay goroutine của Go, scheduler preemptive tự chia CPU, nên một request nặng chỉ chiếm thread của nó và không làm request khác đứng hẳn (Preemptive và cooperative).
- Worker pool là dựng lại thread pool bằng tay. Mỗi worker là một V8 riêng, tốn 8,5 MB và 15 ms để tạo. Dữ liệu qua lại phải chép, và mảng object lớn có khi chép còn lâu hơn tự tính.
- Ranh giới giữa code chạy nhanh và code chặn rất mỏng. Một
includestrong vòng lặp hay một regex viết vội cũng đủ chặn cả server, và lỗi chỉ lộ ra khi dữ liệu thật đủ lớn.
Node vẫn hợp khi phần CPU của mỗi request chỉ vài ms, việc nặng hiếm và chia nhỏ được, hoặc việc nặng đã nằm ở thư viện native chạy trên thread pool (như crypto.pbkdf2, zlib bản không đồng bộ). Khi việc nặng là phần chính của service, như xử lý ảnh, video hay tính toán số liệu lớn, thì nên tách thành worker service riêng sau hàng đợi, hoặc viết bằng ngôn ngữ khác.
Câu hỏi phỏng vấn hay gặp
Làm sao biết event loop bị chặn, và chặn ở đâu? Theo dõi event loop delay (monitorEventLoopDelay) và ELU. Delay cao mà CPU của máy thấp là dấu hiệu có code đồng bộ chạy lâu. Muốn biết hàm nào thì chạy --cpu-prof hoặc gắn profiler qua --inspect rồi xem self time. Thêm --trace-sync-io để bắt các hàm …Sync.
Đánh dấu hàm là async có làm nó chạy nền không? Không. async chỉ cho phép hàm await mà không chặn thread. Phần code đồng bộ bên trong hàm vẫn chạy trên main thread. Đo ở trang Process, thread cho thấy Promise.all với 8 hàm async tốn CPU chạy lâu đúng bằng chạy lần lượt.
Chia nhỏ hay dùng worker? Chia nhỏ khi việc ít, tự viết bằng vòng lặp, và không cần nhanh hơn. Dùng worker khi việc nhiều tới mức main thread bận đáng kể, khi việc là một lời gọi không chia được như JSON.parse, hoặc khi cần dùng nhiều core. Với worker, gửi đi dữ liệu thô và nhận về kết quả nhỏ.
Sao không tạo worker mới cho mỗi request? Vì mỗi worker tốn khoảng 15 ms để khởi động và 8,5 MB bộ nhớ, và số worker tăng theo số request thì máy cạn bộ nhớ. Dùng pool cố định kèm hàng đợi có giới hạn.
ReDoS là gì, phòng thế nào? Là regex có nhiều cách khớp lồng nhau, nên với chuỗi không khớp, engine backtracking thử một số cách tăng theo hàm mũ. /^(a+)+$/ với 28 chữ a và một dấu ! mất 1,7 giây. Phòng bằng cách tránh lượng từ lồng nhau, giới hạn độ dài input, không tạo regex từ input người dùng, hoặc dùng engine tuyến tính như RE2.
Có huỷ được một vòng lặp đang chạy không? Trên main thread thì không, vì không gì chen vào được giữa code đồng bộ. Chạy nó trong worker thì worker.terminate() dừng được ngay. vm với timeout cũng dừng được, nhưng main thread vẫn bị chặn tới hết timeout.
Chạy cluster có giải quyết được không? Không. Nó tăng năng lực xử lý, nhưng request rơi vào process đang chạy việc nặng vẫn phải chờ.
Ghi nhớ
- Độ dài mỗi lần chặn quyết định p99, còn tổng thời gian chặn mỗi giây quyết định độ bận của main thread. Theo dõi cả event loop delay và ELU.
- Thủ phạm hay gặp nhất là thuật toán O(n²) (
includes,findtrong vòng lặp), JSON lớn (2–3 ms mỗi MB mỗi chiều) và regex có lượng từ lồng nhau. Tìm chúng bằng--cpu-prof. - Thử theo thứ tự: làm cho việc rẻ đi, chia nhỏ bằng
setImmediate, pool worker, rồi hàng đợi hay service riêng.clusterkhông chữa được việc bị chặn. - Chia nhỏ theo ngân sách thời gian và nhường cả trước phần đầu. Request khác chỉ chờ khoảng một phần, nhưng main thread vẫn làm toàn bộ phần CPU.
- Với worker, gửi dữ liệu thô đi và nhận kết quả nhỏ về. Gửi lại mảng object lớn có thể chặn main thread lâu hơn tự tính.
Nguồn tham khảo
Đã đối chiếu (đọc nguồn ngày 2026-10-21):
| Nguồn | Dùng cho phần |
|---|---|
| Node.js docs, Don't Block the Event Loop | Partitioning và offloading cùng nhược điểm của từng cách (không dùng được nhiều core, chi phí trao đổi dữ liệu), các mẫu regex nguy hiểm, safe-regex/rxxr2 không bắt hết, node-re2 không tương thích 100%, ví dụ JSON 50 MB (stringify 0,7 s, parse 1,3 s), không tạo process con cho mỗi client, Node có thể không hợp với tính toán tốn kém |
| Node.js docs, Command-line options | --cpu-prof, --cpu-prof-dir, --cpu-prof-interval mặc định 1.000 µs |
| Node.js docs, Worker threads | Worker có ích cho việc CPU, không giúp được mấy với I/O; nên dùng pool; postMessage dùng structured clone; transferList chuyển hẳn vùng nhớ và bên gửi không dùng được nữa |
| Node.js docs, VM | Tuỳ chọn timeout; vm không phải cơ chế bảo mật |
| V8 blog, An additional non-backtracking RegExp engine (11/01/2021) | Cờ --enable-experimental-regexp-engine và cờ l, chế độ tự chuyển khi backtrack quá 50.000 lần, không hỗ trợ backreference và lookaround |
| piscina README | filename, run(), maxQueue: 'auto' bằng maxThreads², Piscina.move() và transferList |
| Express docs, express.json() | limit mặc định "100kb" |
Kết quả tự chạy, chưa có tài liệu chính thức xác nhận:
- Mô tả
--trace-sync-io(in stack trace khi có I/O đồng bộ sau vòng đầu của event loop) và việcworker.terminate()trả về exit code: không đọc được nguyên văn trong tài liệu lần này, nhưng chạy thử cho ra đúng hành vi đó (exit code 1). - piscina 5.1.4 mặc định
minThreads4 trên máy 8 core: README ghi mặc định bằngos.availableParallelism(), chạy thử ra 4 (maxThreads12 thì khớp vớiavailableParallelism() × 1,5). - Phần đầu của việc chia nhỏ chạy ngay trong pha poll, phần thứ hai chạy luôn ở pha check cùng vòng: suy ra từ thứ tự các pha và đã kiểm bằng timestamp từng request so với từng phần việc.
- CPU tăng tốc chậm sau lúc rảnh: đo bằng
cpu-ramp.mjs, nguyên nhân (xung nhịp hay chuyển core trên Apple Silicon) chưa kiểm. - Connection mới mở trong lúc server bận cần thêm vòng event loop (accept rồi mới đọc dữ liệu), làm p99 cao hơn; vì vậy lab mở sẵn 50 connection. Đã thấy khi so lượt có và không mở sẵn, chưa đo riêng.
re2(node-re2) là package thay thế: chưa chạy thử trong lab.
Code đã dùng để chạy thử
Chạy bằng npm run lab -- chan-event-loop (chỉ cần Node.js, khoảng 2 phút). Số trong trang lấy từ các lượt chạy ngày 2026-10-21 trên MacBook M2 (Node 24.14), xem Lab. Phần gửi request dùng lại lab/latency/util.mjs, thư viện piscina có trong lab/package.json.
lab/chan-event-loop/culprits.mjs: JSON, vòng lặp, gzipSync, ReDoS
js
// Các thủ phạm hay chặn event loop, đo thời gian mỗi lần chạy trên main thread.
// JSON.parse / JSON.stringify theo kích thước, sắp xếp, lọc trùng bằng includes và Set,
// gzipSync, regex bị ReDoS và engine regex tuyến tính của V8.
import { performance } from 'node:perf_hooks'
import { gzipSync } from 'node:zlib'
import { spawnSync } from 'node:child_process'
const ms = (v) => (v >= 100 ? Math.round(v).toLocaleString('vi-VN') : v.toFixed(v < 1 ? 2 : 1).replace('.', ',')) + ' ms'
// Trung vị của `runs` lần chạy, sau một lần chạy khởi động.
function time(fn, runs = 3) {
fn()
const t = []
for (let i = 0; i < runs; i++) {
const t0 = performance.now()
fn()
t.push(performance.now() - t0)
}
return t.sort((a, b) => a - b)[Math.floor(runs / 2)]
}
// Dòng giống kết quả một câu SELECT đơn hàng: khoảng 180 byte JSON mỗi dòng.
const row = (i) => ({
id: i, user_id: 1000 + (i % 5000), email: `user${i % 5000}@example.com`, amount: (i * 37) % 100000 / 100,
status: ['paid', 'pending', 'refunded'][i % 3], created_at: new Date(1700000000000 + i * 1000).toISOString(),
tags: ['a', 'b', 'c'].slice(0, i % 4), note: 'giao giờ hành chính'
})
const rows = (n) => Array.from({ length: n }, (_, i) => row(i))
console.log('\n=== JSON.parse và JSON.stringify ===')
console.log('kích thước số dòng stringify parse')
for (const n of [5_000, 50_000, 250_000]) {
const data = rows(n)
const s = JSON.stringify(data)
const mb = (Buffer.byteLength(s) / 1e6).toFixed(1).replace('.', ',') + ' MB'
console.log(`${mb.padEnd(12)} ${n.toLocaleString('vi-VN').padStart(9)} ${ms(time(() => JSON.stringify(data))).padStart(11)} ${ms(time(() => JSON.parse(s))).padStart(10)}`)
}
console.log('\n=== Xử lý mảng trong một request ===')
const big = rows(200_000)
console.log(`sort 200.000 dòng theo amount ${ms(time(() => [...big].sort((a, b) => a.amount - b.amount)))}`)
console.log(`sort 200.000 dòng theo email (localeCompare) ${ms(time(() => [...big].sort((a, b) => a.email.localeCompare(b.email)), 1))}`)
console.log(`gom nhóm 200.000 dòng theo user_id ${ms(time(() => { const m = new Map(); for (const r of big) m.set(r.user_id, (m.get(r.user_id) || 0) + r.amount) }))}`)
for (const n of [10_000, 20_000, 40_000]) {
const ids = Array.from({ length: n }, (_, i) => (i * 7919) % (n / 2))
const inc = time(() => { const out = []; for (const x of ids) if (!out.includes(x)) out.push(x) }, 1)
const set = time(() => [...new Set(ids)])
console.log(`lọc trùng ${n.toLocaleString('vi-VN').padStart(6)} id: includes ${ms(inc).padStart(9)}, Set ${ms(set).padStart(8)}`)
}
const s10 = JSON.stringify(rows(50_000))
console.log(`gzipSync ${(Buffer.byteLength(s10) / 1e6).toFixed(1).replace('.', ',')} MB JSON ${ms(time(() => gzipSync(s10)))}`)
console.log('\n=== Regex bị ReDoS: /^(a+)+$/ với n chữ a và một dấu ! ở cuối ===')
const evil = /^(a+)+$/
const safe = /^a+$/
for (const n of [20, 22, 24, 26, 28]) {
const input = 'a'.repeat(n) + '!'
const t = time(() => evil.test(input), 1)
console.log(`n = ${n}: ${ms(t).padStart(10)} (regex an toàn /^a+$/: ${ms(time(() => safe.test(input)))})`)
}
console.log('Chuỗi 10.000 ký tự với regex an toàn:', ms(time(() => safe.test('a'.repeat(10_000) + '!'))))
// Regex kiểm slug trông vô hại, nhưng [a-z0-9]+ lặp trong (…)+ cũng là lượng từ lồng nhau.
const slug = /^([a-z0-9]+-?)+$/
for (const s of ['bai-viet-moi-nhat-ve-node--', 'bai-viet-moi-nhat-ve-node-js-va--', 'bai-viet-moi-nhat-ve-node-js-va-event--']) {
console.log(`slug ${String(s.length).padStart(2)} ký tự: ${ms(time(() => slug.test(s), 1)).padStart(9)} "${s}"`)
}
console.log('\n=== Engine regex tuyến tính của V8 (cờ thử nghiệm) ===')
const code = `const t0 = performance.now(); const r = /^(a+)+$/l.test('a'.repeat(28) + '!'); console.log(r, (performance.now() - t0).toFixed(2) + ' ms')`
const lin = spawnSync(process.execPath, ['--enable-experimental-regexp-engine', '-e', code], { encoding: 'utf8' })
console.log('--enable-experimental-regexp-engine, cờ /l, n = 28:', (lin.stdout || lin.stderr).trim())
const code2 = `const t0 = performance.now(); const r = /^(a+)+$/.test('a'.repeat(28) + '!'); console.log(r, (performance.now() - t0).toFixed(2) + ' ms')`
const fb = spawnSync(process.execPath, ['--enable-experimental-regexp-engine-on-excessive-backtracks', '-e', code2], { encoding: 'utf8' })
console.log('--enable-experimental-regexp-engine-on-excessive-backtracks, regex thường, n = 28:', (fb.stdout || fb.stderr).trim())lab/chan-event-loop/transfer.mjs: chi phí gửi dữ liệu sang worker
js
// Đẩy việc sang worker không miễn phí: dữ liệu gửi qua postMessage phải chép (structured clone),
// và phần chép đó chạy trên main thread. Đo bốn cách xử lý cùng một việc:
// parse một chuỗi JSON khoảng 45 MB rồi cộng tổng amount.
// Cột "main thread bị chặn lâu nhất" đo bằng một setInterval 1 ms: khoảng cách lớn nhất giữa hai lần chạy, trừ 1 ms.
import { Worker } from 'node:worker_threads'
import { performance } from 'node:perf_hooks'
const ms = (v) => (v >= 100 ? Math.round(v).toLocaleString('vi-VN') : v.toFixed(v < 1 ? 2 : 1).replace('.', ',')) + ' ms'
const WORKER = `
const { parentPort } = require('node:worker_threads')
const sum = (rows) => rows.reduce((s, r) => s + r.amount, 0)
parentPort.on('message', ({ kind, data }) => {
if (kind === 'string') parentPort.postMessage(sum(JSON.parse(data)))
else if (kind === 'buffer') parentPort.postMessage(sum(JSON.parse(Buffer.from(data).toString())))
else if (kind === 'whole') parentPort.postMessage(JSON.parse(data)) // trả nguyên mảng đã parse về main thread
else if (kind === 'echo') parentPort.postMessage(data)
})`
const rows = Array.from({ length: 250_000 }, (_, i) => ({
id: i, user_id: 1000 + (i % 5000), email: `user${i % 5000}@example.com`, amount: (i * 37) % 100000 / 100,
status: ['paid', 'pending', 'refunded'][i % 3], created_at: new Date(1700000000000 + i * 1000).toISOString(),
tags: ['a', 'b', 'c'].slice(0, i % 4), note: 'giao giờ hành chính'
}))
const json = JSON.stringify(rows)
const sum = (r) => r.reduce((s, x) => s + x.amount, 0)
const w = new Worker(WORKER, { eval: true })
const call = (msg, transfer) =>
new Promise((resolve) => {
w.once('message', resolve)
w.postMessage(msg, transfer)
})
// Chạy fn trong lúc đo khoảng chặn lớn nhất của main thread.
async function measure(fn) {
let last = performance.now(), worst = 0
const iv = setInterval(() => {
const t = performance.now()
worst = Math.max(worst, t - last - 1)
last = t
}, 1)
await new Promise((r) => setTimeout(r, 20))
worst = 0
const t0 = performance.now()
await fn()
const total = performance.now() - t0
await new Promise((r) => setTimeout(r, 5))
clearInterval(iv)
return { total, worst }
}
// Bản Buffer giả định dữ liệu đã nằm sẵn trong một ArrayBuffer (ví dụ body đọc từ socket),
// nên bước tạo ArrayBuffer chạy trước khi đo. Transfer làm ArrayBuffer bên gửi bị vô hiệu, nên mỗi lượt tạo một cái mới.
const bytes = new TextEncoder().encode(json)
const freshBuffer = () => bytes.slice().buffer
const cases = [
['Parse trên main thread', () => null, async () => sum(JSON.parse(json))],
['Gửi chuỗi sang worker, nhận về tổng', () => null, () => call({ kind: 'string', data: json })],
['Gửi ArrayBuffer kiểu transfer, nhận về tổng', freshBuffer, (ab) => call({ kind: 'buffer', data: ab }, [ab])],
['Worker parse, gửi nguyên mảng về main', () => null, () => call({ kind: 'whole', data: json })]
]
console.log(`\n=== Parse JSON ${(json.length / 1e6).toFixed(1).replace('.', ',')} MB (${rows.length.toLocaleString('vi-VN')} dòng) rồi cộng tổng, 3 lượt mỗi cách ===`)
console.log('cách tổng thời gian main thread bị chặn lâu nhất')
for (const [name, prepare, run] of cases) {
const r = []
for (let i = 0; i < 4; i++) {
const arg = prepare()
const m = await measure(() => run(arg))
if (i > 0) r.push(m) // lượt đầu để khởi động
}
const span = (k) => `${ms(Math.min(...r.map((x) => x[k])))}–${ms(Math.max(...r.map((x) => x[k])))}`
console.log(`${name.padEnd(46)}${span('total').padEnd(22)}${span('worst')}`)
}
console.log('\n=== Chi phí riêng của postMessage (gửi đi và nhận lại cùng dữ liệu) ===')
for (const n of [1_000, 50_000, 250_000]) {
const part = rows.slice(0, n)
const str = JSON.stringify(part)
await call({ kind: 'echo', data: part })
const t0 = performance.now(); await call({ kind: 'echo', data: part }); const tObj = performance.now() - t0
const t1 = performance.now(); await call({ kind: 'echo', data: str }); const tStr = performance.now() - t1
console.log(`${n.toLocaleString('vi-VN').padStart(8)} dòng: mảng object ${ms(tObj).padStart(9)}, chuỗi JSON ${(str.length / 1e6).toFixed(1).replace('.', ',')} MB ${ms(tStr).padStart(8)}`)
}
await w.terminate()lab/chan-event-loop/budget.mjs: chia nhỏ theo ngân sách thời gian
js
// Chia nhỏ theo thời gian thay vì theo số phần tử: chạy tới khi hết 5 ms thì nhường event loop.
// So với chạy một lần: tổng thời gian và khoảng main thread bị chặn lâu nhất (đo bằng setInterval 1 ms).
const yieldLoop = () => new Promise((resolve) => setImmediate(resolve))
async function processWithBudget(items, fn, budgetMs = 5) {
await yieldLoop() // nhường cả trước phần đầu
let start = performance.now()
for (let i = 0; i < items.length; i++) {
fn(items[i])
// Xem đồng hồ sau mỗi 1.000 phần tử, đủ thưa để không tốn, đủ dày để không vượt ngân sách nhiều
if (i % 1000 === 999 && performance.now() - start > budgetMs) {
await yieldLoop()
start = performance.now()
}
}
}
const items = Array.from({ length: 2_000_000 }, (_, i) => i)
let sum = 0
const fn = (x) => { for (let k = 0; k < 50; k++) sum += Math.sqrt(x + k) }
const ms = (v) => v.toFixed(v < 100 ? 1 : 0).replace('.', ',') + ' ms'
console.log('\n=== 2.000.000 phần tử, chạy một lần so với chia theo ngân sách 5 ms (3 lượt) ===')
for (let r = 0; r < 3; r++) {
let t = performance.now()
items.forEach(fn)
const once = performance.now() - t
let last = performance.now(), worst = 0
const iv = setInterval(() => { const n = performance.now(); worst = Math.max(worst, n - last - 1); last = n }, 1)
await new Promise((res) => setTimeout(res, 20))
worst = 0
t = performance.now()
await processWithBudget(items, fn)
clearInterval(iv)
console.log(`một lần: ${ms(once)} (chặn suốt) chia nhỏ: tổng ${ms(performance.now() - t)}, chặn lâu nhất ${ms(worst)}`)
}lab/chan-event-loop/cpu-ramp.mjs: CPU tăng tốc chậm sau lúc rảnh
js
// CPU tăng tốc chậm sau lúc rảnh: nghỉ một khoảng rồi chạy 4 phần việc, mỗi phần 20 ms CPU (đo lúc đã chạy nóng).
// Nếu phần đầu sau lúc nghỉ lâu chậm hơn, thì thời gian chặn của phần đầu một việc nặng sẽ dài hơn con số tính trước.
import { work } from './work.mjs'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
work(2000) // khởi động JIT
console.log('\n=== Thời gian thật của 4 phần việc 20 ms liền nhau, sau khi nghỉ (4 lượt mỗi mức) ===')
for (const idle of [0, 50, 200, 1000]) {
const res = []
for (let k = 0; k < 4; k++) {
await sleep(idle)
const a = []
for (let c = 0; c < 4; c++) {
const t = performance.now()
work(200)
a.push(Math.round(performance.now() - t))
}
res.push(a.join('/'))
}
console.log(`nghỉ ${String(idle).padStart(4)} ms: ${res.join(' ')}`)
}lab/chan-event-loop/server.mjs: server với /fast và /job theo từng cách
js
// Server HTTP cho trang nodejs/chan-event-loop, chạy trong process con. Đối số: số thread của pool worker.
// /fast trả lời ngay
// /job?mode=main việc CPU 200 ms (2.000 phần) chạy thẳng trên main thread
// /job?mode=chunk&size=N cùng việc đó, chia thành từng phần N đơn vị, nhường event loop bằng setImmediate trước mỗi phần
// /job?mode=chunk-after&size=N như trên nhưng nhường sau mỗi phần, nên phần đầu chạy ngay trong handler
// /job?mode=worker cùng việc đó, chạy trong pool worker của piscina
// Nhận message 'stats' qua IPC thì gửi lại event loop delay rồi reset.
import http from 'node:http'
import { monitorEventLoopDelay } from 'node:perf_hooks'
import { Piscina } from 'piscina'
import { work } from './work.mjs'
const UNITS = 2000
const threads = Number(process.argv[2] || 2)
const pool = new Piscina({ filename: new URL('./work.mjs', import.meta.url).href, minThreads: threads, maxThreads: threads })
const delay = monitorEventLoopDelay({ resolution: 10 })
delay.enable()
const yieldLoop = () => new Promise((resolve) => setImmediate(resolve))
// Nhường trước mỗi phần: phần đầu chạy ở pha check, sau khi pha poll đã nhận các request vừa đến.
// Nhường sau mỗi phần: phần đầu chạy ngay trong handler (pha poll), rồi phần thứ hai chạy luôn ở pha check
// của cùng vòng lặp, nên request đến trong lúc phần đầu chạy phải chờ hết hai phần.
async function chunked(size, yieldFirst) {
for (let done = 0; done < UNITS; done += size) {
if (yieldFirst) await yieldLoop()
work(Math.min(size, UNITS - done))
if (!yieldFirst) await yieldLoop()
}
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://x')
if (url.pathname !== '/job') return res.end('fast')
const mode = url.searchParams.get('mode')
if (mode === 'main') work(UNITS)
else if (mode === 'chunk' || mode === 'chunk-after') await chunked(Number(url.searchParams.get('size')), mode === 'chunk')
else if (mode === 'worker') await pool.run({ units: UNITS })
res.end('done')
})
server.keepAliveTimeout = 60_000
server.listen({ port: 0, host: '127.0.0.1' }, () => process.send({ port: server.address().port }))
process.on('message', (m) => {
if (m !== 'stats') return
const ms = (ns) => Math.max(0, ns / 1e6 - 10) // mỗi mẫu đã gồm cả resolution 10 ms
process.send({ delay: { p99: ms(delay.percentile(99)), max: ms(delay.max) } })
delay.reset()
})lab/chan-event-loop/work.mjs: việc CPU chia được thành từng phần
js
// Một việc tốn CPU chia được thành nhiều phần nhỏ: mỗi phần (unit) là một vòng lặp tính toán khoảng 0,1 ms trên M2.
// Việc 200 ms = 2.000 phần. Dùng chung cho server (chạy trên main thread) và cho piscina (chạy trong worker).
export const UNIT_LOOPS = 28_000
export function work(units) {
let x = 0
for (let u = 0; u < units; u++) for (let i = 0; i < UNIT_LOOPS; i++) x = (x + i * 7) % 1_000_003
return x
}
// piscina gọi hàm export default trong worker
export default ({ units }) => work(units)lab/chan-event-loop/strategies.mjs: so sánh các cách khi có tải
js
// So sánh các cách xử lý một việc CPU 200 ms trong khi server vẫn nhận request nhanh.
// Gửi đều 100 request/giây (Poisson) vào /fast trong 8 giây, kèm `jobs` việc nặng mỗi giây theo từng cách.
// Đo latency của /fast, latency của việc nặng, và event loop delay của server.
import http from 'node:http'
import { fork } from 'node:child_process'
import { openLoop, get, summary, ms, sleep, now } from '../latency/util.mjs'
const SECONDS = 8
async function startServer(threads) {
const child = fork(new URL('./server.mjs', import.meta.url), [String(threads)], { stdio: 'inherit' })
const { port } = await new Promise((r) => child.once('message', r))
const agent = new http.Agent({ keepAlive: true, maxSockets: Infinity })
const stats = () => new Promise((r) => { child.once('message', r); child.send('stats') })
return { port, agent, stats, stop: () => (agent.destroy(), child.kill()) }
}
const scenarios = [
{ name: 'chỉ có /fast', jobs: 0 },
{ name: 'chạy thẳng trên main thread', jobs: 1, path: '/job?mode=main' },
{ name: 'chia nhỏ, mỗi phần 1 ms', jobs: 1, path: '/job?mode=chunk&size=10' },
{ name: 'chia nhỏ, mỗi phần 5 ms', jobs: 1, path: '/job?mode=chunk&size=50' },
{ name: 'chia nhỏ, mỗi phần 20 ms', jobs: 1, path: '/job?mode=chunk&size=200' },
{ name: 'phần 20 ms, nhường sau mỗi phần', jobs: 1, path: '/job?mode=chunk-after&size=200' },
{ name: 'pool 2 worker', jobs: 1, path: '/job?mode=worker', threads: 2 },
{ name: '4 việc/giây, main thread', jobs: 4, path: '/job?mode=main' },
{ name: '4 việc/giây, chia nhỏ 5 ms', jobs: 4, path: '/job?mode=chunk&size=50' },
{ name: '4 việc/giây, pool 2 worker', jobs: 4, path: '/job?mode=worker', threads: 2 },
{ name: '4 việc/giây, pool 4 worker', jobs: 4, path: '/job?mode=worker', threads: 4 }
]
console.log(`\n=== Việc CPU 200 ms, 100 request/giây vào /fast trong ${SECONDS} s ===`)
console.log('cách p50 /fast p99 /fast max /fast việc nặng p50 max event loop delay p99 max')
for (const s of scenarios) {
const srv = await startServer(s.threads || 2)
// Mở sẵn 50 connection keep-alive. Không làm vậy thì lúc server bận, client phải mở connection mới,
// và connection mới cần thêm vòng lặp event loop (accept rồi mới đọc), làm lẫn chi phí đó vào kết quả.
await Promise.all(Array.from({ length: 50 }, () => get(srv, undefined, '/fast')))
await openLoop(srv, { rate: 100, seconds: 1, path: '/fast' }) // khởi động
if (s.path) await get(srv, undefined, s.path) // khởi động JIT và pool worker
await srv.stats()
const jobLat = [], pending = []
let iv
if (s.jobs) {
iv = setInterval(() => {
const t0 = now()
pending.push(get(srv, undefined, s.path).then(() => jobLat.push(now() - t0)))
}, 1000 / s.jobs)
}
const r = summary(await openLoop(srv, { rate: 100, seconds: SECONDS, path: '/fast' }))
clearInterval(iv)
await Promise.all(pending)
await sleep(200)
const d = (await srv.stats()).delay
const j = jobLat.length ? summary(jobLat) : null
console.log([s.name.padEnd(32), ms(r.p50).padStart(9), ms(r.p99).padStart(10), ms(r.max).padStart(10),
(j ? ms(j.p50) : '').padStart(15), (j ? ms(j.max) : '').padStart(7),
(ms(d.p99) + ' ms').padStart(21), (ms(d.max) + ' ms').padStart(8)].join(' '))
srv.stop()
}lab/chan-event-loop/detect.mjs và slow-app.mjs: --cpu-prof và --trace-sync-io
js
// Tìm hàm nào chặn event loop: chạy slow-app.mjs với --cpu-prof rồi tính self time của từng hàm từ file .cpuprofile.
// Sau đó chạy lại với --trace-sync-io để xem Node cảnh báo API đồng bộ.
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const app = new URL('./slow-app.mjs', import.meta.url).pathname
const dir = mkdtempSync(join(tmpdir(), 'cpu-prof-'))
spawnSync(process.execPath, ['--cpu-prof', '--cpu-prof-dir', dir, app], { stdio: 'inherit' })
const file = readdirSync(dir).find((f) => f.endsWith('.cpuprofile'))
const prof = JSON.parse(readFileSync(join(dir, file), 'utf8'))
rmSync(dir, { recursive: true })
// File .cpuprofile: nodes (cây lời gọi), samples (node đang chạy ở mỗi mẫu), timeDeltas (µs giữa hai mẫu).
// Self time của một hàm là tổng thời gian các mẫu rơi đúng vào hàm đó, không tính hàm nó gọi.
const byId = new Map(prof.nodes.map((n) => [n.id, n]))
const self = new Map()
prof.samples.forEach((id, i) => {
const { functionName, url, lineNumber } = byId.get(id).callFrame
const key = `${functionName || '(anonymous)'} ${url ? url.split('/').pop() + ':' + (lineNumber + 1) : ''}`.trim()
self.set(key, (self.get(key) || 0) + (prof.timeDeltas[i] || 0))
})
const total = [...self.values()].reduce((a, b) => a + b, 0)
console.log(`\n=== --cpu-prof: ${prof.samples.length} mẫu, ${(total / 1000).toFixed(0)} ms, 6 hàm có self time lớn nhất ===`)
for (const [k, v] of [...self].sort((a, b) => b[1] - a[1]).slice(0, 6)) {
console.log(`${((v / total) * 100).toFixed(1).replace('.', ',').padStart(5)}% ${(v / 1000).toFixed(0).padStart(5)} ms ${k}`)
}
console.log('\n=== --trace-sync-io ===')
const r = spawnSync(process.execPath, ['--trace-sync-io', app], { encoding: 'utf8' })
console.log(r.stderr.split('\n').slice(0, 4).join('\n'))js
// Ứng dụng giả có ba hàm chậm, dùng cho detect.mjs. Chạy khoảng 1 giây rồi thoát.
import { readFileSync } from 'node:fs'
const orders = Array.from({ length: 20_000 }, (_, i) => ({ id: i, userId: (i * 7919) % 8000, amount: i % 997 }))
// Lọc trùng bằng includes: O(n²)
function uniqueUserIds(list) {
const out = []
for (const o of list) if (!out.includes(o.userId)) out.push(o.userId)
return out
}
// Regex có lượng từ lồng nhau
function isValidSlug(s) {
return /^([a-z0-9]+-?)+$/.test(s)
}
function buildReport(list) {
return JSON.stringify(list.map((o) => ({ ...o, vnd: o.amount * 25_000 })))
}
setTimeout(() => {
readFileSync(new URL(import.meta.url)) // API đồng bộ sau lượt chạy đầu tiên: --trace-sync-io sẽ cảnh báo
const t0 = performance.now()
while (performance.now() - t0 < 1000) {
uniqueUserIds(orders)
isValidSlug('bai-viet-moi-nhat-ve-node-js-va--') // 33 ký tự, khoảng 2 ms mỗi lần
buildReport(orders)
}
}, 10)lab/chan-event-loop/timeout.mjs: dừng regex chạy quá lâu
js
// Code đồng bộ không huỷ được từ bên ngoài, trừ khi nó chạy ở chỗ có thể dừng hẳn.
// Thử hai cách dừng một regex bị ReDoS (n = 34, chạy bình thường mất vài phút):
// chạy trong worker rồi worker.terminate() sau 100 ms; chạy bằng vm với timeout 100 ms.
import { Worker } from 'node:worker_threads'
import vm from 'node:vm'
const ms = (v) => v.toFixed(v < 1 ? 2 : 1).replace('.', ',') + ' ms'
const INPUT = 'a'.repeat(34) + '!'
// 1. Worker: main thread vẫn chạy timer trong lúc worker kẹt trong regex.
{
let ticks = 0
const iv = setInterval(() => ticks++, 10)
const w = new Worker(`require('node:worker_threads').parentPort.postMessage(/^(a+)+$/.test(${JSON.stringify(INPUT)}))`, { eval: true })
w.on('message', (m) => console.log('worker trả kết quả (không mong đợi):', m))
await new Promise((r) => w.once('online', r))
const t0 = performance.now()
await new Promise((r) => setTimeout(r, 100))
const t1 = performance.now()
const code = await w.terminate()
clearInterval(iv)
console.log(`\n=== Worker + terminate sau 100 ms ===`)
console.log(`terminate mất ${ms(performance.now() - t1)}, exit code ${code}; timer 10 ms trên main thread chạy ${ticks} lần trong ${ms(performance.now() - t0)}`)
}
// 2. vm với timeout: chạy trên chính main thread, nên main thread bị chặn tới khi timeout.
{
const t0 = performance.now()
try {
vm.runInNewContext(`/^(a+)+$/.test(${JSON.stringify(INPUT)})`, {}, { timeout: 100 })
console.log('vm: chạy xong (không mong đợi)')
} catch (e) {
console.log(`\n=== vm.runInNewContext với timeout 100 ms ===`)
console.log(`ném lỗi sau ${ms(performance.now() - t0)}: ${e.code || ''} ${e.message}`)
}
}