Bất kỳ ai khi tìm hiểu sâu vào Node.js có thể đã thấy các bài viết phân biệt sự khác nhau giữa setTimeout
, setImmediate
và process.nextTick
. Tôi cũng không phải là ngoại lệ! Thời gian đầu, tôi luôn cố gắng hiểu được cách sử dụng chúng bằng cách đọc các bài viết và cả tài liệu của Node. Nhưng hầu như chúng đều mang nặng tính lý thuyết. Nghĩ cũng phải vì để thực sự hiểu được sự khác nhau cũng như cách dùng, cần phải hiểu được cách hoạt động của vòng lặp sự kiện - event loop.
Trên thực tế, tôi rất ít khi quan tâm đến sự khác nhau giữa chúng. Tức là chương trình vẫn chạy được kể cả khi bạn biết hoặc không biết, vì chưa chắc bạn đã cần phải dùng đến. Còn nếu muốn chương trình chạy tốt hơn nữa thì chắc chắn cần phải học cách sử dụng.
Trước khi đi sâu vào bài viết, tôi khuyên bạn nên dành thời gian để đọc lại khái niệm về event loop, về cách nó hoạt động. Một số bài viết đã từ rất lâu trước đó của tôi ở đây: Kiến trúc Node.js - Event Loop, Tìm hiểu về vòng lặp sự kiện (Event Loop) trong Node.js. Còn tốt hơn nữa là bạn nên đọc tài liệu quý báu từ trang của của Node: The Node.js Event Loop. Thậm chí bạn cũng có thể tìm được bài viết về process.nextTick và setImmediate ở đây, nhưng nếu muốn khám phá chúng dưới cái nhìn khách quan của tôi, thì xin hãy đọc tiếp bài viết này!
Event loop là trái tim của Node.js, một vòng lặp vô hạn để đẩy các hàm callback bất đồng bộ ngược lại callstack và xử lý.
Hãy nhớ lại 6 pha (pharse) của vòng lặp sự kiện. Một chu trình của vòng lặp phải đi qua 6 pha trước khi bắt đầu một cái mới. 6 pha đó như sau.
Timers phase: Xử lý các callback được lên lịch bằng setTimeout
và setInterval
.
Pending callbacks phase: Xử lý các callback bị hoãn lại từ giai đoạn I/O trước đó.
Idle, prepare phase: Dành riêng cho các hoạt động nội bộ của Node.js.
Poll phase: Xử lý các tác vụ I/O mới, cũng như kiểm tra các sự kiện đã hoàn thành. Nếu không có sự kiện nào, nó sẽ chờ cho đến khi có một nhiệm vụ mới.
Check phase: Xử lý các callback từ setImmediate
.
Close callbacks phase: Xử lý các sự kiện đóng của các tài nguyên (như socket.on('close', ...)
).
Mỗi pha có nhiệm vụ xử lý các sự kiện riêng biệt. Sở dĩ Node.js phải chia ra các pha như vậy vì tính đơn luồng của nó. Chúng ta không thể đẩy vào ồ ạt sự kiện rồi mong chờ event loop xử lý chúng một cách lần lượt, mà có những sự kiện cần được xử lý trước các sự kiện khác. Việc chia ra các pha sẽ đảm bảo được điều đó!
Mỗi một chu trình lặp, Node.js đi qua từng pha và xử lý sự kiện trong đó trước khi sang pha khác. Ở đây chúng ta thấy setTimeout
được xử lý trong Timers phase, cũng là pha đầu tiên của event loop. Khoan đã! setImmediate
được xử lý trong Check phase, sau Timers phase, vậy thì chẳng phải setTimeout
với thời gian chờ bằng 0 luôn thực hiện trước setImmediate
? Vậy cái tên "immediate" có còn có ý nghĩa?
Chà, bạn đã bắt đầu bước vào sự phức tạp của Node. Còn câu trả lời cho câu hỏi trên là chưa hẳn. Tức là tuỳ vào trường hợp sử dụng mà cái nào được thực hiện trước cái nào.
Trong trường hợp không có tác vụ I/O nào được xử lý, tức là một chương trình hoàn toàn đồng bộ. Nếu sử dụng setTimeout(fn, 0)
, hàm callback sẽ được lên lịch trong pha "timers". Nếu sử dụng setImmediate()
, hàm callback sẽ được thực thi trong pha "check". Vậy theo lý thuyết, callback của setTimeout
được thực hiện trước setImmediate
.
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
Nhưng thực tế, kết quả là setTimeout
và setImmediate
sẽ "tranh nhau" thực hiện tuỳ vào Node đang ở pha nào. Tức là bạn sẽ thấy 2 chuỗi "setTimeout", "setImmediate" xuất hiện ngẫu nhiên trong các lần chạy khác nhau. Để lý giải là do khi mới bắt đầu chương trình, Node có độ trễ nhất định trước khi đi vào ổn định, tức là đôi khi nó ở pha "timers", đôi khi nó ở pha "check".
Trong trường hợp có I/O, thứ tự thực thi sẽ khác, luôn luôn là setImmediate
trước tiên vì setImmediate
được thực thi ngay sau khi tác vụ I/O hoàn thành, vì nó nằm trong pha "check" của event loop, vốn xảy ra ngay sau pha "poll" (giai đoạn xử lý các tác vụ I/O).
setTimeout(fn, 0)
chỉ được thực thi sau khi event loop đã quay lại pha "timers", nghĩa là nó sẽ phải đợi một vòng event loop nữa để đến pha "timers".
fs.readFile("README.md", () => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
});
Vậy chúng ta nên sử dụng setImmediate
để chạy ngay một hàm callback ngay sau khi tác vụ I/O được hoàn thành.
setImmediate
tỏ ra hữu ích khi được dùng để ghi logs request vào tệp. Hãy nhìn vào ví dụ về một máy chủ HTTP dưới đây:
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello World\n");
// ghi log vào file (công việc ít quan trọng hơn)
setImmediate(() => {
fs.appendFile("server.log", `Request: ${req.url}\n`, (err) => {
if (err) throw err;
console.log("Logged request to server.log");
});
});
Nhiều người nghĩ rằng không cần dùng đến setImmediate
ở đây vì fs.appendFile
là một hàm bất đồng bộ và nó không gây ảnh hưởng gì đến tốc độ phản hồi của phiên hiện tại. Do đó có thể loại bỏ setImmediate
để tránh mã thừa.
Quan điểm đó chính xác nếu chỉ có một mình bạn dùng. Thực tế máy chủ luôn luôn nhận được rất nhiều truy vấn đến cùng một lúc. Việc sử dụng setImmediate
sẽ đẩy công việc ghi logs vào pha Check, nhường thời gian cho pha Poll - vốn đang được dùng để xử lý callback của các chức năng chính. Làm như vậy sẽ tăng hiệu năng đáng kể cho trương trình, đẩy các công việc bớt quan trọng hơn xuống pha Check, sau pha Poll.
setImmediate
còn được xử dụng để giảm tải luồng chính. Trong bài viết Hai kỹ thuật nhằm ngăn vòng lặp sự kiện (Event Loop) bị chặn khi xử lý tác vụ nặng (CPU-intensive task) trước đó, tôi đã đề cập đến phương pháp "Partitioning" dựa trên setImmediate
để ngưng chặn vòng lặp sự kiện khi xử lý một lượng lớn dữ liệu.
Hãy nhìn lại 6 pha, bạn sẽ không thấy bất kỳ pha nào xử lý callback của process.nextTick
. Tại sao? Đó là do process.nextTick
là một hàm đặc biệt, callback của nó luôn luôn được xử lý vào đầu mỗi pha của vòng lặp sự kiện.
Lấy ví dụ, event loop xử lý hết callback của pha "timers" -> xử lý callback trong process.nextTick
-> xử lý callback của pha "pending callbacks" -> xử lý callback trong process.nextTick
... và cứ như thế. Như vậy tức là process.nextTick
có độ ưu tiên rất cao trong event loop.
Thế thì ứng dụng process.nextTick
như thế nào? Câu trả lời rất rõ ràng, bất cứ khi nào muốn thực hiện công việc có mức ưu tiên cao nhất, trước khi các pha được xử lý để đảm bảo công việc được thực hiện ngay lập tức. Ví dụ process.nextTick
cũng thường được áp dụng trong kỹ thuật "Partitioning" tương tự như setImmediate
, chỉ có điều mỗi vòng lặp chỉ có 1 lần xử lý setImmediate
, trong khi với process.nextTick
thì con số lên đến 6 lần.
Vì lẽ đó trong thư viện bcrypt được dùng để tính toán chuỗi mã hoá - một tác vụ rất nặng về tính toán và có nguy cơ chặn event loop đã áp dụng triệt để process.nextTick
để ngăn ngừa hành vi này.
Ngoài ra nó cũng thường được dùng để giảm tải cho luồng chính trong lần khởi động đầu tiên nhưng vẫn muốn các cấu hình trong constructor
được tải ngay sau đó:
class MyClass {
constructor() {
process.nextTick(() => {
this.loadConfiguration();
});
}
loadConfiguration() {
// load config here!
}
}
const myClass = new MyClass();
5 bài học sâu sắc
Mỗi sản phẩm đi kèm với những câu chuyện. Thành công của người khác là nguồn cảm hứng cho nhiều người theo sau. 5 bài học rút ra được đã thay đổi con người tôi mãi mãi. Còn bạn? Hãy bấm vào ngay!
Đăng ký nhận thông báo bài viết mới
Xin chào, tôi tên là Hoài - một anh Dev kể chuyện bằng cách viết ✍️ và làm sản phẩm 🚀. Với nhiều năm kinh nghiệm lập trình, tôi đã đóng góp một phần công sức cho nhiều sản phẩm mang lại giá trị cho người dùng tại nơi đang làm việc, cũng như cho chính bản thân. Sở thích của tôi là đọc, viết, nghiên cứu... Tôi tạo ra trang Blog này với sứ mệnh mang đến những bài viết chất lượng cho độc giả của 2coffee.dev.Hãy theo dõi tôi qua các kênh LinkedIn, Facebook, Instagram, Telegram.
Bình luận (1)