Đồng bộ, bất đồng bộ, Promise, async/await và Event Loop
- 24-07-2026
- Toanngo92
- 0 Comments
Khả năng xử lý bất đồng bộ là lý do Node.js phục vụ được nhiều kết nối mà không cần tạo một luồng JavaScript riêng cho từng yêu cầu. Bài này phân biệt code đồng bộ và bất đồng bộ, lần lượt thực hành callback, Promise, async/await, rồi nối các khái niệm đó với Event Loop.

Mục lục
1. Đồng bộ và bất đồng bộ là gì?
Trong mô hình đồng bộ, mỗi câu lệnh phải hoàn tất trước khi câu lệnh kế tiếp chạy. Nếu một thao tác đọc tệp mất hai giây, JavaScript sẽ chờ đủ hai giây. Cách này dễ theo dõi nhưng có thể chặn toàn bộ tiến trình.
Trong mô hình bất đồng bộ, chương trình khởi tạo thao tác I/O rồi tiếp tục công việc khác. Khi I/O hoàn tất, kết quả được đưa trở lại hàng đợi để callback hoặc phần tiếp theo của Promise được chạy. Điều này không có nghĩa mọi đoạn JavaScript chạy song song; một callback JavaScript dài vẫn có thể chặn Event Loop.

Khi nào dùng mỗi loại?
- Dùng API bất đồng bộ cho server, đọc tệp, truy vấn cơ sở dữ liệu, gọi API và phần lớn tác vụ I/O.
- API đồng bộ phù hợp với script ngắn, bước khởi tạo chạy một lần hoặc công cụ dòng lệnh khi việc chặn tiến trình có chủ đích.
- Công việc nặng CPU như nén ảnh lớn hay tính toán kéo dài không tự trở nên bất đồng bộ chỉ vì đặt trong Promise; nên cân nhắc Worker Threads hoặc tiến trình riêng.
2. Ví dụ đọc tệp đồng bộ
const { readFileSync } = require('node:fs');
console.log('Bắt đầu');
const content = readFileSync('./message.txt', 'utf8');
console.log(content);
console.log('Kết thúc');
Thứ tự luôn là “Bắt đầu”, nội dung tệp, rồi “Kết thúc”. Trong lúc readFileSync() làm việc, Event Loop không thể xử lý yêu cầu khác.
3. Callback trong Node.js
Callback là hàm được truyền cho một hàm khác và được gọi khi thao tác hoàn tất. Quy ước phổ biến của Node.js là (error, result): đối số đầu tiên chứa lỗi, đối số tiếp theo chứa kết quả.
const { readFile } = require('node:fs');
console.log('Bắt đầu');
readFile('./message.txt', 'utf8', (error, content) => {
if (error) {
console.error('Không đọc được tệp:', error.message);
return;
}
console.log(content);
});
console.log('Đã gửi yêu cầu đọc tệp');
Dòng cuối thường xuất hiện trước nội dung tệp, vì callback chỉ được xếp lịch sau khi thao tác đọc hoàn tất.

Tránh callback hell
Khi nhiều tác vụ phụ thuộc lồng vào nhau, callback có thể tạo thành cấu trúc khó đọc. Hãy tách callback thành hàm có tên, trả lỗi sớm, hoặc dùng Promise và async/await.
4. Promise và ba trạng thái
Promise đại diện cho kết quả sẽ có trong tương lai. Nó có ba trạng thái: pending, fulfilled và rejected. Promise chỉ chuyển khỏi trạng thái pending một lần.
const { readFile } = require('node:fs');
function readText(path) {
return new Promise((resolve, reject) => {
readFile(path, 'utf8', (error, content) => {
if (error) return reject(error);
resolve(content);
});
});
}
readText('./message.txt')
.then(content => console.log(content))
.catch(error => console.error(error.message))
.finally(() => console.log('Đã xử lý xong'));
Trong code hiện đại, Node.js đã cung cấp sẵn node:fs/promises, vì vậy không cần tự bọc readFile trong Promise cho trường hợp này.
5. async và await
Từ khóa async khiến hàm luôn trả về Promise. await tạm dừng riêng hàm async hiện tại cho đến khi Promise ổn định; nó không làm cả tiến trình Node.js dừng lại.
const { readFile } = require('node:fs/promises');
async function showMessage() {
try {
const content = await readFile('./message.txt', 'utf8');
console.log(content);
} catch (error) {
console.error('Đọc tệp thất bại:', error.message);
}
}
showMessage();
Tuần tự và song song có kiểm soát
Hai lệnh await liên tiếp chạy tuần tự. Nếu hai tác vụ độc lập, khởi tạo chúng cùng lúc rồi dùng Promise.all():
const { readFile } = require('node:fs/promises');
async function loadFiles() {
const [config, users] = await Promise.all([
readFile('./config.json', 'utf8'),
readFile('./users.json', 'utf8')
]);
return {
config: JSON.parse(config),
users: JSON.parse(users)
};
}
Promise.all() sẽ reject ngay khi một Promise reject. Nếu cần thu thập cả kết quả thành công lẫn thất bại, dùng Promise.allSettled().
6. Event Loop trong Node.js
Event Loop phối hợp việc chạy callback JavaScript. Các thao tác I/O có thể được hệ điều hành hoặc thread pool của libuv xử lý. Khi kết quả sẵn sàng, callback tương ứng được đưa vào giai đoạn phù hợp để JavaScript thực thi.

Các giai đoạn chính
- Timers: chạy callback của timer đã đến ngưỡng thời gian.
- Pending callbacks: chạy một số callback hệ thống được hoãn từ vòng trước.
- Poll: nhận sự kiện I/O mới và chạy callback I/O khi thích hợp.
- Check: chạy callback được lên lịch bởi
setImmediate(). - Close callbacks: xử lý các sự kiện đóng, chẳng hạn socket phát
close.

7. setTimeout, setImmediate và hàng đợi vi tác vụ
setTimeout(fn, 0) không cam kết chạy ngay lập tức; nó chỉ đặt ngưỡng sớm nhất. setImmediate() lên lịch callback cho giai đoạn check, thường hữu ích sau I/O. Promise handlers và queueMicrotask() nằm trong hàng đợi vi tác vụ, được xử lý trước khi chuyển sang giai đoạn Event Loop kế tiếp. process.nextTick() còn có mức ưu tiên riêng; lạm dụng nó có thể làm I/O bị “đói”.
console.log('1. đồng bộ');
setTimeout(() => console.log('4. timer'), 0);
setImmediate(() => console.log('5. immediate'));
Promise.resolve().then(() => console.log('3. Promise'));
process.nextTick(() => console.log('2. nextTick'));
Trong ví dụ đơn giản này, phần đồng bộ chạy trước, sau đó đến nextTick và Promise. Thứ tự tương đối giữa timer và immediate có thể phụ thuộc ngữ cảnh, đặc biệt là đang ở top-level hay bên trong callback I/O.
8. Timer không phải đồng hồ chính xác
console.log('Timer bắt đầu');
setTimeout(() => {
console.log('Chạy sau ít nhất 1 giây');
}, 1000);
console.log('Timer đã được lên lịch');
Nếu Event Loop đang bận ở thời điểm một giây, callback phải chờ. Tham số delay là thời gian tối thiểu, không phải lời hứa về thời điểm chính xác.
9. Lỗi thường gặp
- Quên
awaitnên nhận Promise thay vì dữ liệu thật. - Dùng
awaitngoài hàm async trong tệp CommonJS. - Không bắt Promise rejection, làm lỗi lan đến cấp tiến trình.
- Dùng hàm
*Synctrong route Express và chặn mọi request khác. - Dùng
forEach(async () => ...)rồi tưởng rằng vòng lặp sẽ đợi. Hãy dùngfor...ofcho tuần tự hoặcPromise.all(array.map(...))cho đồng thời.
10. Bài tập thực hành
- Đọc cùng một tệp bằng
readFileSync, callback vànode:fs/promises; so sánh thứ tự log. - Đọc ba tệp độc lập bằng
Promise.all()và xử lý trường hợp một tệp không tồn tại. - Dùng
setTimeoutđể in hai thông báo sau 2 và 4 giây. - Thêm một vòng lặp CPU nặng rồi quan sát timer bị trễ như thế nào.
Tổng kết
API đồng bộ đơn giản nhưng có thể chặn Event Loop. Callback là nền tảng của nhiều API Node.js; Promise và async/await giúp tổ chức luồng bất đồng bộ dễ đọc hơn. Event Loop không biến JavaScript thành đa luồng, mà điều phối callback sẵn sàng để một luồng JavaScript có thể phục vụ hiệu quả nhiều tác vụ I/O.
