JavaScript thường được mô tả là đơn luồng, nhưng trình duyệt vẫn tải dữ liệu trong khi animation chuyển động, còn máy chủ Node.js có thể giữ hàng nghìn socket đang mở. Điều tưởng như mâu thuẫn sẽ trở nên rõ ràng khi ta tách ba thứ: việc thực thi JavaScript, hoạt động của host và cơ chế lập lịch.
Một agent JavaScript thường chỉ chạy một callback tại một thời điểm. Trình duyệt hoặc Node.js thực hiện timer, I/O mạng, I/O tệp và nhiều hoạt động khác bên ngoài callback đó. Khi một hoạt động có thể tiếp tục bằng JavaScript, phần tiếp nối của nó được đưa vào hàng đợi. Event loop quyết định khi nào công việc đang đợi được sử dụng luồng JavaScript.
Mô hình này không tự biến mọi việc thành song song. Tính đúng đắn phụ thuộc vào hàng đợi của phần tiếp nối, thời điểm hàng đợi được rút và thời gian callback giữ quyền thực thi.
Call stack, hàng đợi và run-to-completion
Call stack ghi lại các lời gọi đang hoạt động: gọi hàm đẩy một frame vào stack, còn trả về lấy frame ra. Khi stack chưa rỗng, task thông thường khác không thể chen vào đoạn JavaScript đang chạy.
Thuộc tính này được gọi là run-to-completion: chạy đến khi hoàn tất. Nó khiến một khối đồng bộ có tính nguyên tử cục bộ so với các task khác trên cùng event loop. Tuy nhiên, nó không biến một thao tác bất đồng bộ nhiều bước thành nguyên tử. Mỗi await có thể chia hàm thành những lượt chạy riêng, cho phép công việc khác xen vào.
let balance = 100;
async function spend(amount) {
const observed = balance;
await authorize(amount); // balance có thể đổi trong lúc chờ
balance = observed - amount;
}
Hai lời gọi có thể cùng đọc 100, chờ độc lập rồi ghi đè kết quả của nhau. Đơn luồng ngăn hai chỉ thị chạy đồng thời, chứ không ngăn race condition logic kéo dài qua các điểm tạm dừng.
Host điều phối nhiều nguồn công việc. Tên gọi khác nhau tùy môi trường và đặc tả, nhưng mô hình trình duyệt hữu ích gồm hàng đợi task, hàng đợi microtask, cơ hội render và các hoạt động do host quản lý. Sau khi một task kết thúc và stack rỗng, trình duyệt thực hiện microtask checkpoint: nó chạy microtask cho đến khi hàng đợi rỗng. Sau đó trình duyệt có thể render trước khi chọn task tiếp theo.
Sơ đồ là mô hình tư duy, không phải cam kết rằng mọi nguồn task dùng chung một hàng FIFO. Trình duyệt có thể ưu tiên nguồn task hoặc hạn chế tab nền; run-to-completion và checkpoint trước task kế tiếp mới là điểm ổn định.
Note
API bất đồng bộ không đồng nghĩa với một luồng JavaScript khác. Host theo dõi timer, còn I/O mạng thường do hệ điều hành xử lý. Callback vẫn chạy trên luồng event loop, trừ khi chương trình chủ động dùng Worker hoặc process khác.
Task, microtask, timer và những bẫy thứ tự
Task, đôi khi được gọi là macrotask, bao gồm lần chạy script ban đầu, callback của timer, nhiều DOM event và message event. Promise reaction, queueMicrotask và việc giao mutation observer sử dụng microtask trong trình duyệt. Microtask có mức ưu tiên tại checkpoint cao hơn: mọi microtask có thể lần tới đều chạy trước khi event loop chọn task mới.
console.log('script');
setTimeout(() => console.log('timer'), 0);
Promise.resolve()
.then(() => {
console.log('promise 1');
queueMicrotask(() => console.log('nested microtask'));
})
.then(() => console.log('promise 2'));
console.log('end');
Trong trình duyệt, kết quả là script, end, promise 1, nested microtask, promise 2, rồi timer. Script đầu là một task nên mã đồng bộ hoàn tất trước. Microtask lồng được xếp trước reaction của .then kế tiếp; cả hai chạy trong cùng checkpoint, rồi timer task mới được chọn.
setTimeout(callback, 0) nghĩa là “không trước độ trễ tối thiểu và khi host chọn task”, không phải “ngay lập tức”. Stack hoặc hàng microtask bận vẫn trì hoãn timer đã đủ điều kiện. Nếu công việc lâu hơn chu kỳ, setInterval có thể trôi hoặc dồn lượt; setTimeout đệ quy an toàn hơn vì chỉ đặt lượt kế sau khi việc hiện tại xong.
Promise còn có một bẫy phổ biến: executor của promise chạy đồng bộ, trong khi .then, .catch, .finally và phần tiếp tục sau await chạy dưới dạng microtask.
console.log('A');
new Promise((resolve) => {
console.log('B');
resolve();
}).then(() => console.log('C'));
console.log('D');
// A, B, D, C
await value luôn nhường phần tiếp tục sang microtask, kể cả khi value đã fulfilled. Vì vậy, mã sau await là một bước đã được lập lịch mới, không phải phần còn lại của một transaction đồng bộ.
Warning
Đừng dùng thứ tự timer quan sát được làm cơ chế đồng bộ. Hãy biểu diễn quan hệ phụ thuộc bằng promise, message, lock hoặc phép chuyển trạng thái. Máy nhanh hơn, trình duyệt khác hoặc bản Node.js mới có thể phơi bày một giả định chỉ tình cờ đúng trên máy phát triển.
Trình duyệt và Node.js cùng ý tưởng, không cùng một loop
ECMAScript định nghĩa các job như promise reaction, nhưng host định nghĩa phần lớn cách chúng tích hợp với event loop. Trình duyệt điều phối document, đầu vào người dùng, mạng và render. Node.js xây event loop quanh các phase của libuv và I/O hệ điều hành. Bảng sau tập trung vào bảo đảm thực tế thay vì mọi chi tiết nội bộ.
| Khía cạnh | Trình duyệt | Node.js |
|---|---|---|
| Trách nhiệm chính | DOM event, mạng, timer, render | Socket, tệp, subprocess, timer |
| Promise microtask | Rút tại các microtask checkpoint | Rút sau callback JavaScript và điểm chuyển phase theo quy tắc Node |
| Hàng ưu tiên đặc biệt | queueMicrotask và promise job |
Hàng process.nextTick chạy trước promise microtask thông thường |
| Task kiểu immediate | MessageChannel, scheduler.postTask nếu có |
setImmediate, đặc biệt hữu ích sau I/O |
| Bước render | Có thể tính style, layout và paint giữa các task | Không có trong runtime máy chủ chuẩn |
| JavaScript song song | Web Worker | node:worker_threads hoặc process |
Các phase libuv chính gồm timers, pending callbacks, poll cho I/O, check cho setImmediate và close callbacks. Chi tiết đã thay đổi theo phiên bản. Thứ tự giữa setTimeout(..., 0) và setImmediate ở top-level không portable; trong callback I/O, setImmediate thường đến check phase trước timer mới đặt.
// Node.js
import { readFile } from 'node:fs';
readFile(new URL(import.meta.url), () => {
setTimeout(() => console.log('timer'), 0);
setImmediate(() => console.log('immediate'));
});
Kết quả thường là immediate rồi timer, nhưng mã nên truyền đạt thứ tự bắt buộc một cách trực tiếp thay vì phụ thuộc vào tiểu tiết phase.
process.nextTick không phải một phase của libuv. Node rút hàng đợi này trước promise microtask thông thường sau thao tác hiện tại. API tồn tại vì tính tương thích và một số trường hợp xây dựng API cần kiểm soát chặt, nhưng gọi đệ quy có thể ngăn promise, timer và I/O tiến triển. Nên dùng promise cho việc kết hợp bất đồng bộ thông thường và setImmediate khi mục tiêu là nhường lượt cho I/O.
Hệ điều hành thường báo trạng thái sẵn sàng của mạng. Nhiều thao tác tệp và DNS có thể dùng worker pool của libuv để chạy việc native, nhưng callback JavaScript vẫn quay về event loop chính chứ không tự chạy song song.
Starvation, yielding, backpressure và việc nặng CPU
Một hàng đợi được ưu tiên có thể làm công việc ưu tiên thấp hơn bị bỏ đói. Hàm sau không bao giờ cho trình duyệt chọn timer hoặc paint vì mỗi microtask lại xếp một microtask khác trước khi checkpoint có thể kết thúc:
function starve() {
queueMicrotask(starve);
}
starve();
Vòng lặp đồng bộ vô hạn rất dễ nhận ra, nhưng một phép biến đổi JSON dài 200 ms đã đủ gây độ trễ thao tác rõ rệt. Chuỗi microtask dài cũng gây blocking như vậy. Promise chỉ trì hoãn công việc; nó không làm công việc CPU trở nên rẻ hơn.
Với khối lượng vừa phải, hãy chia xử lý thành các phần có giới hạn và yield bằng một task, không phải microtask khác. Ngân sách thời gian bền vững hơn số lượng phần tử cố định vì chi phí mỗi phần tử và phần cứng có thể khác nhau.
const yieldToHost = () => new Promise((resolve) => setTimeout(resolve, 0));
async function indexRecords(records) {
let cursor = 0;
while (cursor < records.length) {
const deadline = performance.now() + 8;
while (cursor < records.length && performance.now() < deadline) {
indexOne(records[cursor]);
cursor += 1;
}
await yieldToHost();
}
}
Cách này cải thiện độ phản hồi chứ không tăng throughput. Trình duyệt mới có scheduler.yield(); requestAnimationFrame phù hợp cho cập nhật hình ảnh. Trong Node, setImmediate thường là ranh giới nhường lượt hợp lý hơn.
Chia nhỏ không thể cứu phép biến đổi ảnh, nén, mô phỏng hoặc parse nặng liên tục chiếm một core. Hãy chuyển chúng sang Web Worker trong trình duyệt hoặc Worker từ node:worker_threads trong Node. Worker có agent, stack và event loop riêng; các bên giao tiếp qua message, transferable object hoặc shared memory được đồng bộ cẩn thận.
// Luồng chính của trình duyệt
const worker = new Worker(new URL('./hash-worker.js', import.meta.url), {
type: 'module',
});
worker.postMessage(largeBuffer, [largeBuffer]);
worker.addEventListener('message', ({ data }) => {
renderDigest(data);
});
Chuyển buffer giúp tránh sao chép và làm buffer bên gửi bị detach. Worker của Node đến từ node:worker_threads và có API vòng đời khác. Vì khởi động worker có overhead, pool phù hợp hơn cho công việc lặp lại.
Concurrency còn cần backpressure. Nếu producer xếp việc nhanh hơn consumer hoàn tất, event loop vẫn đang hoạt động nhưng bộ nhớ và độ trễ cứ tăng. Hãy giới hạn số request đang bay, tạm dừng readable stream khi write() trả về false, chờ sự kiện drain của writer trong Node và tránh Promise.all không giới hạn trên input lớn. Giới hạn hàng đợi biến quá tải thành chính sách rõ ràng: chờ, từ chối, loại bớt hoặc giảm chất lượng.
Note
Worker chỉ giải quyết tranh chấp CPU khi công việc thật sự được chuyển sang đó. Tạo worker rồi vẫn parse payload lớn trên luồng chính sẽ giữ nguyên nút thắt. Cần đo riêng thời gian serialize, transfer, chờ hàng đợi và tính toán.
Quan sát và kiểm thử có tính quyết định
Lỗi event loop thường xuất hiện dưới dạng độ trễ chứ không phải exception. Cần đo cả thời gian làm việc lẫn khoảng chờ trước khi nó bắt đầu. Trong trình duyệt, Long Tasks API có thể báo task trên luồng chính dài hơn 50 ms, PerformanceObserver thu thập entry, còn performance.mark/performance.measure đánh dấu từng giai đoạn ứng dụng. Performance panel của Chrome DevTools hiển thị task, microtask, render và hoạt động worker trên timeline.
performance.mark('parse:start');
const model = parsePayload(payload);
performance.mark('parse:end');
performance.measure('parse payload', 'parse:start', 'parse:end');
Với Node.js, node:perf_hooks cung cấp monitorEventLoopDelay() và performance.eventLoopUtilization(). Hãy đối chiếu chúng với độ trễ request, độ sâu hàng đợi, mức sử dụng worker, garbage collection và CPU profile. Event-loop delay cao trong khi dịch vụ ngoài phản hồi nhanh thường chỉ ra blocking hoặc starvation cục bộ; hàng đợi sâu nhưng CPU vừa phải thường gợi ý backpressure từ downstream.
Kiểm thử nên xác nhận quan hệ nhân quả thay vì sleep rồi hy vọng. Hãy await promise đại diện cho việc hoàn tất. await Promise.resolve() đi qua một ranh giới microtask, nhưng chuỗi lồng cần tín hiệu thật của API. Fake timer không nhất thiết mô phỏng promise, render, I/O phase hay process.nextTick; hãy flush đúng hàng đợi mà test runner ảo hóa.
it('chỉ phát sự kiện sau khi lưu hoàn tất', async () => {
const persisted = deferred<void>();
const publishing = service.publish(article);
expect(bus.events).toEqual([]);
persisted.resolve();
await publishing;
expect(bus.events).toEqual([{ type: 'published', id: article.id }]);
});
Với thư viện nhạy về thứ tự, hãy chạy test trong mọi runtime được hỗ trợ thay vì biến chi tiết nội bộ của một host thành assertion chung. Thêm load test ghi nhận p95/p99 event-loop delay và độ sâu hàng đợi; unit test hiếm khi phát hiện starvation hoặc thiếu backpressure.
Điểm chính cần nhớ
- Callback JavaScript chạy đến khi hoàn tất trên một agent, nhưng khoảng ngắt bất đồng bộ vẫn có thể tạo race condition logic.
- Task, microtask, render và hoạt động host là các cơ chế lập lịch khác nhau; phần tiếp tục của promise chạy trước task thông thường kế tiếp.
- Timer cung cấp độ trễ tối thiểu, không cung cấp deadline hay bảo đảm đồng bộ.
- Trình duyệt và Node.js có chung nguyên lý event loop nhưng khác nhau về render, phase libuv,
setImmediatevàprocess.nextTick. - Microtask đệ quy và công việc CPU dài có thể làm I/O hoặc render bị bỏ đói. Hãy yield qua task với việc vừa phải và dùng worker cho tính toán kéo dài.
- Backpressure là một phần của tính đúng đắn trong concurrency: giới hạn hàng đợi và thao tác đang bay trước khi quá tải biến thành tăng bộ nhớ.
- Hãy quan sát event-loop delay, utilization, long task và độ sâu hàng đợi; khi test, chờ tín hiệu hoàn tất thay vì dựa vào sleep hoặc thứ tự tình cờ.