process.nextTick, setImmediate và setTimeout

process.nextTick, setImmediate và setTimeout

Tin ngắn hàng ngày dành cho bạn
  • Hôm qua đến nay, lượt truy cập tới từ Facebook tăng đột biến. Thường như thế là do ai đó chia sẻ bài viết của blog vào một nhóm nào đó.

    Cơ mà lần này là liên kết trực tiếp đến trang chủ luôn. Tò mò ghê, không biết ai chia sẻ, chia sẻ ở đâu nữa. Muốn biết để tìm hiểu "insight" ghê 🥹

    » Xem thêm
  • Mình mới phát hiện ra thư viện idb-keyval giúp triển khai cơ sở dữ liệu dạng key-value một cách đơn giản. Như đã chia sẻ trong chuỗi bài viết về quá trình làm OpenNotas, mình loay hoay đi tìm một loại cơ sở dữ liệu để lưu trữ mà xem chừng vất vả quá, cuối cùng chốt localForage.

    idb-keyval cũng tương tự như localForage nhưng có vẻ như nó đang làm tốt hơn một chút. Đơn cử là có hàm update để cập nhật dữ liệu, hình dung đơn giản là:

    update('counter', (val) => (val || 0) + 1);

    Chứ không như hàm set là thay thế dữ liệu mất tiêu luôn.

    » Xem thêm
  • Đầu xuân năm mới, xin phép khoe số tiền kiếm được sau 1 tháng đặt quảng cáo tại indieboosting.com 🥳🥳🥳

    » Xem thêm

Vấn đề

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, setImmediateprocess.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

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 setTimeoutsetInterval.

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.

setTimeout & setImmediate

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à setTimeoutsetImmediate 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.

process.nextTick

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();
Cao cấp
Hello

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!

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!

Xem tất cả

Đăng ký nhận thông báo bài viết mới

hoặc
* Bản tin tổng hợp được gửi mỗi 1-2 tuần, huỷ bất cứ lúc nào.

Bình luận (1)

Nội dung bình luận...
Avatar
Trịnh Cường3 tháng trước
quá hay bạn ơi
Trả lời
Avatar
Xuân Hoài Tống2 tháng trước
Cảm ơn bạn Cường nha 🙏
Bấm hoặc cuộn mạnh để sang bài mới