Kiến trúc Node.js - process.nextTick, setImmediate và setTimeout

Kiến trúc Node.js - process.nextTick, setImmediate và setTimeout

Tin ngắn hàng ngày dành cho bạn
  • Void - cái tên mình đã nhắc đến từ cách đây khá lâu. Từ đợt mà continue.dev mới nổi lên á. Nó tương tự như Cursor và Windsurf, mới hôm nay họ đã phát hành phiên bản Beta và cho phép mọi người tải xuống.

    Điểm mạnh thì đây là nguồn mở, miễn phí, dùng các mô hình miễn phí cục bộ trên máy qua Ollama hoặc LM Studio... Không thích thì cắm API của bên khác vào cũng được. Mình vừa dùng thử thì thấy khả năng gợi ý và khung chat khá tương đồng với Cursor, có cả tính năng Agent luôn nhé 👏. Hoạt động ổn định hơn continue.dev (lần cuối dùng), việc còn lại là chọn mô hình xịn xịn tí 🤤

    » Xem thêm
  • Zed mới đây đã giới thiệu thêm tính năng Agent - tương tự như Agent trong Cursor hay Write trong Windsurf và họ gọi nó là The Fastest AI Code Editor.

    Cũng nhanh thật đấy vì Zed viết bằng Rust. Cơ mà chiến lược của họ có vẻ thay đổi, tập trung vào AI thay vì phát triển kho tiện ích mở rộng vốn đang có rất ít, không thể cạnh tranh được với VSCode 🥶

    Zed: The Fastest AI Code Editor

    » Xem thêm
  • Ngay sau thông tin OpenAI đạt được thoả thuận mua lại Windsurf với giá 3 tỉ đô thì ngày hôm nay Cursor đã miễn phí 1 năm dùng bản Pro cho sinh viên. Chaaaaà 🤔

    OpenAI Reaches Agreement to Buy Startup Windsurf for $3 Billion

    Cursor for Students | Cursor - The AI Code Editor

    » Xem thêm

Vấn đề

Ở bài viết trước, chúng ta đã biết 6 pha xử lý trong một vòng lặp sự kiện, về chức năng và các kiểu hàm callback nào được xử lý ở đâu. Ngoài ra còn có một pha đặc biệt gọi là process.nextTick tuy không thuộc Event loop nhưng lại có mức độ ưu tiên cao nhất.

Khái niệm tưởng chừng dễ nhưng lại là gây khó dễ cho nhiều người, kể cả lập trình viên lâu năm. Vậy khi đặt process.nextTick, setImmediatesetTimeout lại cùng với nhau. Theo bạn callback trong hàm nào được thực hiện trước?

Nhắc lại Event loop

Trước tiên hãy quay lại mô hình 6 pha của vòng lặp sự kiện.

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           poll            │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

Mỗi pha có nhiệm vụ xử lý các sự kiện riêng biệt. Chúng ta không thể đẩy ồ ạt sự kiện vào 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 pha giúp đảm bảo đ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 setTimeout được xử lý trong "timers" nằm ở trên cùng của Event loop. Trong khi setImmediate được xử lý trong "check". Nếu 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?

setTimeout & setImmediate

Trong một chương trình đồng bộ, tức là không gọi hàm bất đồng bộ nào cả. 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 lên lịch trong pha "check". Vậy theo lý thuyết, callback của setTimeout luôn được thực hiện trước setImmediate.

setTimeout(() => {
  console.log('setTimeout');
}, 0);

setImmediate(() => {
  console.log('setImmediate');
});

Khi chạy mã trên bạn sẽ thấy 2 chuỗi "setTimeout" và "setImmediate" xuất hiện không theo thứ tự nào cả, tức là có lúc thì "setTimeout" in ra trước, lúc thì lại in ra sau. Lý giải cho điều này là khi mới bắt đầu chương trình, Node.js 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 chương trình có gọi hàm bất đồng bộ 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 lặp nữa để đến pha "timers".

fs.readFile("README.md", () => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);

  setImmediate(() => {
    console.log('setImmediate');
  });
});

Vì vậy, chúng ta nên sử dụng setImmediate để chạy 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 nhật ký truy vấn (request logs). Ví dụ như trong 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.

Điều đó chưa chắc đã đú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 giúp đẩy phần công việc ghi nhật ký vào pha "check", nhường thời gian cho pha "poll" - vốn được dùng để xử lý callback của các chức năng chính, tăng hiệu suất máy chủ. Luôn luôn nhớ rằng đẩy các công việc bớt quan trọng hơn xuống pha "check", nhường thời gian xử lý cho "poll".

Ngoài ra setImmediate còn được sử dụng để giảm tải luồng chính. Để biết thêm thông tin chi tiết, bạn đọc xem 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). Trong bài viết đề cập đến phương pháp "Partitioning" dựa trên setImmediate để ngăn chặn vòng lặp sự kiện khi muốn xử lý một lượng lớn dữ liệu.

process.nextTick

Nhìn lại 6 pha của Event loop bạn không thấy bất kỳ pha nào xử lý callback của process.nextTick. Đó là do process.nextTick là một pha đặc biệt, callback của nó luôn luôn được xử lý trên đầu mỗi pha của vòng lặp sự kiện.

Ví dụ, Event loop xử lý hết callback của "timers", chuẩn bị chuyển sang "pending callbacks" thì hẵng khoan đã, nó xử lý callback trong process.nextTick, sau khi xong hết thì mới chuyển sang "pending callbacks"... và cứ như thế ở các pha tiếp theo. Như vậy tức là process.nextTick có độ ưu tiên rất cao trong Event loop.

Nên ứ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.

Kết luận

Qua bài viết này, chúng ta đã đi sâu vào cách hoạt động của process.nextTick, setImmediatesetTimeout trong Node.js, từ đó hiểu rõ vai trò của chúng trong Event loop. process.nextTick nổi bật với mức ưu tiên cao nhất, được thực thi ngay trước khi chuyển sang các pha khác của Event loop, là lựa chọn lý tưởng cho những công việc cần thực hiện ngay lập tức. Trong khi đó, setImmediate được ưu tiên sử dụng để xử lý các tác vụ ít quan trọng hơn sau khi hoàn thành I/O, giúp giảm tải cho vòng lặp sự kiện và tối ưu hiệu suất hệ thống. Còn setTimeout thường được dùng để lên lịch thực thi ở các vòng lặp sau, nhưng lại dễ bị ảnh hưởng bởi độ trễ ban đầu của Node.js.

Hiểu rõ sự khác biệt này không chỉ giúp lập trình viên sử dụng các hàm callback một cách hiệu quả hơn, mà còn tối ưu hóa hiệu suất ứng dụng, đặc biệt trong các hệ thống đa nhiệm và yêu cầu xử lý đồng thời cao. Tùy thuộc vào kịch bản, việc chọn đúng công cụ giữa process.nextTick, setImmediatesetTimeout có thể tạo ra sự khác biệt lớn trong cách ứng dụng của bạn hoạt động và phản hồi. Hãy sử dụng chúng một cách khôn ngoan để đảm bảo rằng các tác vụ được xử lý theo đúng thứ tự ưu tiên và phù hợp với yêu cầu của hệ thống.

Cao cấp
Hello

Tôi & khao khát "chơi chữ"

Bạn đã thử viết? Và rồi thất bại hoặc chưa ưng ý? Tại 2coffee.dev chúng tôi đã có quãng thời gian chật vật với công việc viết. Đừng nản chí, vì giờ đây chúng tôi đã có cách giúp bạn. Hãy bấm vào để trở thành hội viên ngay!

Bạn đã thử viết? Và rồi thất bại hoặc chưa ưng ý? Tại 2coffee.dev chúng tôi đã có quãng thời gian chật vật với công việc viết. Đừng nản chí, vì giờ đây chúng tôi đã có cách giúp bạn. Hãy bấm vào để trở thành hội viên 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ường6 tháng trước
quá hay bạn ơi
Trả lời
Avatar
Xuân Hoài Tống5 tháng trước
Cảm ơn bạn Cường nha 🙏