Published on

Nodejs Event Loop hoạt động như thế nào

Authors
  • avatar
    Name
    Dinh Nguyen Truong
    Twitter

Có lẽ ae dev cũng không xa lạ gì về những câu nói như Node.js là single thread, xử lý concurrent task qua event loop. Thế nhưng chi tiết thực sự event loop hoạt động như thế nào, tại sao node lại chỉ cần một single thread để thực thi Javascript thì không nhiều người rõ. Hoặc có khi đã ôn luyện nhưng khi vào ngồi phỏng vấn vẫn lúng túng về chủ đề này.

Chính vì vậy nên mình đã cố gắng nghiên cứu chủ đề này thật sâu nhất, chi tiết nhất cho ae. Thông qua bài viết này, mình sẽ giúp các bạn:

Hiểu cơ chế hoạt động của Event Loop

Event Loop là trái tim của Node.js. Thực tế, Node.js không chỉ đơn thuần là "single-threaded" như nhiều người vẫn nghĩ. Mặc dù JavaScript code của bạn chạy trên một thread chính (main thread), nhưng đằng sau đó, Node.js sử dụng nhiều thread khác để xử lý các tác vụ I/O nặng nề.

Trong phần này, chúng ta sẽ đi sâu vào kiến trúc của Event Loop, các phase khác nhau của nó như timers, pending callbacks, idle & prepare, poll, checkclose callbacks. Bạn sẽ hiểu được tại sao Node.js có thể xử lý hàng nghìn kết nối đồng thời mà không cần tạo thread mới cho mỗi kết nối như các mô hình truyền thống.

Tiếp theo đó là vai trò của libuv, Thread Pool và epoll API

Đằng sau sự "kỳ diệu" của Node.js là thư viện libuv - một thư viện đa nền tảng tập trung vào I/O không đồng bộ. Libuv cung cấp cho Node.js một thread pool để xử lý các tác vụ tốn thời gian như đọc/ghi file, DNS lookups, …. Libuv cũng tận dụng epoll API (Linux)- một cơ chế I/O multiplexing hiệu quả cao cho phép một process theo dõi nhiều file descriptors cùng lúc.

Oke, hãy đi vào phase đầu tiên

Initial Phase

Đây là phase đầu tiên, trước khi khởi tạo event loop. Phase này chỉ chạy đúng một lần.

Node sẽ thực thi toàn bộ mã nguồn một cách tuần tự từ trên xuống dưới, toàn bộ các callback từ I/O hoặc timer sẽ được đăng ký vào một phase riêng chứ sẽ không chạy ngay.

Nếu như bạn load các module sử dụng require(), nó sẽ được load synchronously ở phase này luôn.

Tiếp theo, Event loop được khởi tạo, cuộc hành trình của main thread chính thức bắt đầu. Event loop sẽ bao gồm 6 phase chính và được lặp đi lặp lại liên tục cho đến khi có thể dừng.

1. Phase đầu tiên: Timer

Như cái tên của nó, timer phase sẽ thực thi các callback của timer (setTimeoutsetInterval).

Node sẽ quản lý các timer callback ở một thread khác. Đầu tiên, nó sẽ sắp xếp toàn bộ các timer dựa theo duration, sau đó dùng cơ chế ngủ và thức dậy khi hết duration, đánh dầu timer đã sẵn sàng rồi đi ngủ tiếp, và cứ như vậy cho đến khi hết timer.

Trong khi đó ở main thread, mỗi khi event loop chạy đến timer phase, nó sẽ kiểm tra xem có timer nào được đánh dấu sẵn sàng không, nếu có thì đem callback ra thực thi. Còn không sẽ đi đến phase tiếp theo.

2. Pending callback:

Phase này được dùng để thực thi một số callback có độ ưu tiên thấp từ loop trước đó, đặc biệt là handle các error như:

  • TCP error callback
  • UDP error callback
  • Và một số error callback khác từ system

Ví dụ về pending callback:

  • Network operation được thực thi ở poll phase
  • Nó fail ⇒ schedule callback cho pending callback phase
  • Khi chạy sang loop tiếp theo, pending callback phase sẽ lấy error ra và xử lý callback của nó ( retry lại chẳng hạn).

3. Idle - Prepare Phase

Phase này dàng cho node thực thi một số công việc ngầm của nó, và chuẩn bị sẵn sàng cho phase rất quan trọng tiếp theo - Poll phase.

Phase này có thể được ghi đè bằng C++ Extension. Chính xác, chúng ta có thể viết extension bằng C++ cho node.

4. Poll Phase

Poll phase sẽ xử lý các callback cho I/O operations, nó là phase rất quan trọng của event loop.

Hai nhiệm vụ chính của poll phase bao gồm:

1. Thăm dò I/O operations:

  • Lắng nghe các socket connection.
  • Nhận connection mới
  • Đọc/ghi file

2. Thực thi callback khi có dữ liệu sẵn sàng từ các I/O operations, ví dụ:

  • Đọc một file thành công
  • Connect đến một server thành công
  • Nhận data từ một connection thành công.
  • (Và nhiều nhiệm vụ khác nữa)

Một nhiệm vụ khác của poll phase là xử lý các dynamic import. Import thực chất cũng phải đọc file trong hệ thống, Node coi nó như một I/O operations

Cơ chế xử lý I/O operations của Node.js

  • Xử lý các file operations

Bên dưới node, nó sử dụng libuv để xử lý các operation này. Libuv sẽ có một thread riêng để xử lý các file, tránh cho main thread bị block.

Khi thread này xử lý xong file, callback sẽ được queue lại và main thread sẽ thực thi nó ở poll phase.

Trường hợp khác là mình tạo stream để đọc file thì khi libuv xử lý xong một chunk, nó sẽ queue callback cùng với chunk và cũng sẽ được thực thi ở poll phase.

  • Xử lý network operations

Việc đọc, ghi vào network connection thực chất là một blocking opertions. Nếu mình xử lý bằng cách đọc tuần tự từng connection một thì sẽ bị block rất lâu.

Đây chính là lúc epoll (linux), kqueue(macos), iocp (window) được node tận dụng để xử lý asynchronous operations.

Với epoll trên linux, node sẽ đăng ký các connection qua epoll_wait, nếu một connection nào đó có dữ liệu sẵn sàng thì linux kernel thông báo cho node biết. Node sẽ thực thi callback của connection đó luôn.

Điều thú vị là epoll_wait hỗ trợ timeout, giả sử nếu sau 1s chờ không có connection nào có dữ liệu, node sẽ đi sang phase tiếp theo.

Thực chất thì node sẽ bị block ở poll phase để đợi dữ liệu từ các connection. Tuy nhiên nó sẽ xử lý thế này.

  • Nếu có timer nào đó schedule thực thi ở timer phase thì node sẽ chờ đợi epoll_wait một khoảng thời gian nhất định. Rồi đi sang phase tiếp theo.
  • Nếu không có timer, node sẽ bị block ở phase này cho đến khi có connection nào đó có dữ liệu, hay nhận thêm connection mới, ….

Tóm tắt lại hoạt động của poll phase:

  • Là phase mà hầu hết các task được thực thi
  • Thực thi các I/O operation và các callback.
  • Nó sẽ tính toán để chờ đợi trước khi đi sang phase tiếp theo

5. Check phase

Phase này luôn thực thi sau poll phase và cung cấp lời gọi để dev có thể đăng ký callback ( setImmediate )

Nếu như bạn có một operation nào đó quá to ở poll phase muốn chia nhỏ nó ra, bạn hoàn toàn có thể schedule nó sang check phase.

6. Phase cuối: Close Callback

Phase này dùng để thực thi các callback khi có I/O nào đó bị đóng. Mục đích chủ yếu là để clean up resource.

Sở dĩ cần một phase riêng cho hoạt động này vì việc đóng các connection cũng mất time, ví dụ như TCP chẳng hạn thì cần gửi nhiều request close connection (FIN, FIN-ACK, FIN, ACK), chúng được thực thi ở poll phase. Tuy nhiên callback của nó khi close thành công thì được thực thi ở Close Callback.

Nguyên nhân thứ 2 là các close event có ưu tiên thấp, hoàn toàn phù hợp thực thi ở cuối event loop.

Sumary

Mong rằng thông qua bài viết này, ae đã có một cái nhìn sâu hơn về cách Node.js xử lý I/O operation, timer, chạy đồng thời nhiều task ra sao.

Hẹn gặp lại