Reading Time: 9 minutes

Hôm nay chúng ta sẽ tìm hiểu về kiến trúc Node.js và mô hình Event Loop đơn luồng của nền tảng này. Giải pháp này mang lại một cách tiếp cận độc đáo cho việc xử lý nhiều request đồng thời, nhất là so với mô hình đa luồng truyền thống thường gặp.

Kiến trúc Node.js

Trước khi bắt đầu với một số ví dụ lập trình với Node.js, bạn nên hiểu rõ về kiến trúc của runtime này. Chúng ta sẽ thảo luận về cách Node.js thực sự hoạt động bên trong, mô hình xử lý mà nó sử dụng, và cách Node.js xử lý đồng thời nhiều request với mô hình đơn luồng, cùng một số vấn đề khác.

Mô hình Event Loop đơn luồng của Node.js

Như chúng ta đã thảo luận, các ứng dụng Node.js sử dụng mô hình Event Loop (vòng lặp sự kiện) đơn luồng để phục vụ nhiều client đồng thời cùng lúc. Chúng ta có nhiều công nghệ cho ứng dụng web như JSP, Spring MVC, ASP.NET, HTML, Ajax, jQuery, v.v., Nhưng tất cả chúng đều tuân theo kiến trúc Request-Response (yêu cầu-phản hồi) đa luồng để hỗ trợ nhiều client cùng lúc.

Chúng ta đã quen thuộc với kiến trúc này vì nó được sử dụng bởi hầu hết các framework cho ứng dụng web. Vậy tại sao Node.js lại chọn một kiến trúc khác? Đâu là những khác biệt chính giữa kiến trúc Event Loop đa luồng và đơn luồng?

Bất kỳ lập trình viên web nào cũng có thể làm quen với Node.js để phát triển ứng dụng dễ dàng. Tuy nhiên, nếu không hiểu rõ cách hoạt động bên trong của Node.js, chúng ta không thể thiết kế và phát triển các ứng dụng một cách hiệu quả.

Vì vậy, trước khi bắt tay vào việc lập trình ứng dụng với Node.js, trước tiên ta sẽ tìm hiểu về cơ chế hoạt động bên trong của nền tảng này.

Nền tảng Node.js

Node.js sử dụng kiến trúc Event Loop đơn luồng để xử lý từ nhiều client đồng thời. Vậy làm thế nào nó thực sự xử lý các request từ client đồng thời mà không sử dụng nhiều thread? Mô hình Event Loop là gì? Chúng ta sẽ thảo luận lần lượt từng khái niệm này.

Trước khi thảo luận về kiến trúc Event Loop đơn luồng, trước tiên chúng ta sẽ tìm hiểu qua về kiến trúc Request-Response đa luồng phổ biến.

Mô hình xử lý truyền thống của ứng dụng web

Các ứng dụng web không dùng Node.js thường tuân theo mô hình Request-Response đa luồng. Ta có thể gọi đơn giản mô hình này là mô hình Request/Response.

Client gửi request đến server, server sau đó thực hiện một số xử lý dựa trên request của client để chuẩn bị response và gửi lại cho client. Mô hình này sử dụng giao thức HTTP. Vì HTTP là một giao thức stateless (phi trạng thái), mô hình Request/Response này cũng có tính phi trạng thái. Do đó, ta có thể gọi đây là mô hình phi trạng thái Request/Response.

Tuy nhiên, mô hình này sử dụng nhiều thread để xử lý đồng thời các request từ client. Trước khi thảo luận chi tiết về cách mô hình này hoạt động, hãy xem qua sơ đồ dưới đây.

Các bước xử lý của mô hình Request/Response:

  • Client gửi request đến web server.
  • Web server duy trì bên trong một thread pool (nhóm luồng) với số lượng giới hạn để cung cấp dịch vụ cho các request từ client.
  • Web server chạy trong một vòng lặp vô tận, chờ các request đến từ client.
  • Khi web server nhận các request này:
    • Nó chọn một request từ client.
    • Lấy một thread từ thread pool.
    • Gán thread này cho request của client đó.
    • Thread này sẽ chịu trách nhiệm đọc request của client, xử lý request, thực hiện các truy xuất I/O có chặn (blocking) nếu cần và chuẩn bị response.
    • Thread này gửi response đã chuẩn bị về cho web server.
    • Web server sau đó gửi response này đến client tương ứng.

Server chờ trong vòng lặp này vô tận và thực hiện tất cả các bước con như đã đề cập ở trên cho tất cả client. Điều này có nghĩa là mô hình này tạo ra một thread cho mỗi request của Client.

Nếu nhiều request của client yêu cầu truy xuất I/O có blocking, thì hầu hết các thread sẽ bị chiếm dụng để chuẩn bị response. Khi đó, các request còn lại của client sẽ phải chờ đợi lâu hơn.

Sơ đồ mô hình Request/Response truyền thống

Mô tả sơ đồ:

  • Ở đây ta có n client gửi request đến máy chủ. Giả sử họ đều đang truy cập đồng thời vào ứng dụng web của chúng ta. Ta ấn định tên các client là Client-1, Client-2… và Client-n.
  • Web server duy trì một thread pool bên trong với số lượng giới hạn. Giả sử có m thread trong thread pool đó.
  • Web server nhận các request này lần lượt:
    • Nó chọn Request-1 của Client-1, lấy thread T-1 từ thread pool và gán request này cho nó.
      • Thread T-1 đọc Request-1 của Client-1 và xử lý nó.
      • Request-1 của Client-1 không yêu cầu bất kỳ thao tác I/O có blocking nào.
      • Thread T-1 thực hiện các bước cần thiết để chuẩn bị Response-1 và gửi về cho server.
      • Web server sau đó gửi Response-1 này cho Client-1.
    • Web server chọn tiếp Request-2 của Client-2, lấy một thread là T-2 từ thread pool và gán request này cho thread T-2.
      • Thread T-2 đọc Request-2 của Client-2 và xử lý nó.
      • Request-2 của Client-2 không yêu cầu bất kỳ thao tác I/O có blocking nào.
      • Thread T-2 thực hiện các bước cần thiết, chuẩn bị Response-2 và gửi về cho server.
      • Web server sau đó gửi Response-2 này cho Client-2.
    • Web server chọn tiếp Request-n của Client-n, lấy thread T-n từ Thread pool và gán request này cho thread T-n.
      • Thread T-n đọc Request-n của Client-n và xử lý nó.
      • Request-n của Client-n yêu cầu các thao tác I/O có blocking và yêu cầu tính toán phức tạp.
      • Thread T-n mất nhiều thời gian hơn để tương tác với các hệ thống bên ngoài, thực hiện các bước cần thiết, và chuẩn bị Response-n để gửi về cho server.
      • Web server sau đó gửi Response-n này cho Client-n. Nếu n lớn hơn m (trường hợp hay xảy ra), server sẽ gán thread cho các request của client cho đến khi hết số thread có sẵn. Sau khi tất cả m thread đã được sử dụng, các request còn lại của client phải đợi trong queue (hàng đợi) cho đến khi một số thread đã xong việc xử lý request cũ và trở nên rảnh để tiếp nhận request mới. Nếu các thread đó bị chiếm dụng bởi các tác vụ I/O có blocking (ví dụ như tương tác với database, hệ thống file, JMS Queue, các dịch vụ bên ngoài, v.v.) trong thời gian dài, thì các client còn lại sẽ phải chờ đợi lâu hơn.
  • Khi các thread trong thread pool trở nên rảnh và sẵn sàng cho các tác vụ tiếp theo, server sẽ lấy những thread đó và gán chúng cho các request còn lại của client.
  • Mỗi thread sử dụng nhiều tài nguyên như bộ nhớ, v.v. Do đó, trước khi các thread này chuyển từ trạng thái bận sang trạng thái chờ, chúng sẽ cần phải giải phóng tất cả tài nguyên đã được cấp phát.

Nhược điểm của mô hình phi trạng thái Request/Response:

  • Việc xử lý số lượng lớn request cùng lúc từ client trở nên khó khăn.
  • Khi số lượng request đồng thời từ client tăng, hệ thống phải sử dụng ngày càng nhiều thread, dẫn đến tiêu tốn nhiều bộ nhớ hơn.
  • Đôi khi, request của client phải chờ có thread rảnh để được xử lý.
  • Lãng phí thời gian cho việc xử lý các tác vụ I/O có blocking.

Kiến trúc Event Loop đơn luồng của Node.js

Nền tảng Node.js không tuân theo mô hình phi trạng thái đa luồng Request/Response như trên. Thay vào đó, nó sử dụng nô hình Event Loop đơn luồng.

Mô hình xử lý của Node.js chủ yếu dựa trên mô hình hướng sự kiện (event-based) của JavaScript cùng với cơ chế callback (hàm gọi lại) của JavaScript.

Lưu ý: Bạn nên có kiến thức vững chắc về cách event và cơ chế callback trong JavaScript hoạt động. Hãy tham khảo các bài viết hoặc hướng dẫn liên quan để nắm bắt các chủ đề cơ bản này để dễ dàng theo dõi các nội dung phía dưới của bài viết này.

Do Node.js tuân theo kiến trúc này, nó có thể xử lý một số lượng lớn request từ client đồng thời một cách dễ dàng. Trước khi nói về cách mô hình này hoạt động bên trong, hãy xem qua sơ đồ dưới đây. Nó giải thích từng khía cạnh trong cơ chế hoạt động của Node.js.

Phần cốt lõi của mô hình xử lý Node.js chính là Event Loop (vòng lặp sự kiện). Một khi đã hiểu rõ về Event Loop, việc nắm bắt cách hoạt động bên trong của Node.js sẽ trở nên rất dễ dàng.

Các bước xử lý của mô hình Event Loop đơn luồng:

  • Client gửi request đến web server.
  • Node.js duy trì một thread pool bên trong với số lượng giới hạn để phục vụ việc xử lý các request từ client.
  • Web server của Node.js nhận các request này và đưa chúng vào một queue, được gọi là Event Queue (hàng đợi sự kiện).
  • Web server của Node.js có một thành phần nội bộ gọi là Event Loop. Có tên như vậy là vì nó sử dụng một vòng lặp vô tận để nhận và xử lý các request. (Bạn có thể xem đoạn code Java ở cuối bài để hiểu rõ hơn).
  • Event Loop chỉ sử dụng một thread duy nhất. Đây là điểm cốt lõi trong mô hình xử lý của Node.js.
  • Event Loop kiểm tra xem có rquest nào từ client trong Event Queue không. Nếu không có, nó sẽ chờ các request mới vô thời hạn.
  • Nếu có, Event Loop sẽ lấy một request của client từ Event Queue:
    • Bắt đầu xử lý request của client đó.
    • Nếu request của client không yêu cầu bất kỳ thao tác I/O có blocking nào, Event Loop sẽ xử lý toàn bộ, chuẩn bị response và gửi lại cho client.
    • Nếu request của client yêu cầu các thao tác I/O có blocking (như tương tác với database, hệ thống file, dịch vụ bên ngoài), nó sẽ áp dụng một quy trình khác:
      • Kiểm tra xem có thread nào sẵn có trong thread pool nội bộ không.
      • Chọn một thread và gán request của client này cho thread đó.
      • Thread này chịu trách nhiệm tiếp nhận request, xử lý, thực hiện các thao tác I/O có blocking cần thiết, chuẩn bị response và gửi lại cho Event Loop.
      • Event Loop sau đó sẽ gửi response này đến client tương ứng.

Sơ đồ mô hình Event Loop đơn luồng

Mô tả sơ đồ:

  • Ở đó ta có n client gửi request đến web server. Giả sử chúng đang truy cập đồng thời vào ứng dụng web của chúng ta.
  • Giả sử tên các client là Client-1, Client-2… và Client-n.
  • Web server duy trì một thread pool bên trong với số lượng giới hạn. Giả sử có m thread trong thread pool đó.
  • Node.js nhận các request từ Client-1, Client-2… đến Client-n và đặt chúng vào Event Queue.
  • Event Loop của Node.js xử lý các request này lần lượt:
    • Event Loop chọn Request-1 của Client-1:
      • Kiểm tra xem Request-1 của Client-1 có yêu cầu thao tác I/O có blocking nào hoặc cần nhiều thời gian cho các công việc tính toán phức tạp không.
      • Vì đây là request có tính toán đơn giản và là tác vụ I/O không blocking, nó không cần một Thread riêng để xử lý.
      • Event Loop xử lý tất cả các bước được định nghĩa trong Request-1 của Client-1 (ở đây, các bước xử lý tương ứng với các hàm JavaScript) và chuẩn bị Response-1.
      • Event Loop gửi Response-1 cho Client-1.
    • Event Loop chọn Request-2 của Client-2:
      • Kiểm tra xem Request-2 của Client-2 có yêu cầu thao tác I/O có blocking nào hoặc cần nhiều thời gian cho các công việc tính toán phức tạp không.
      • Vì đây là request có tính toán đơn giản và là tác vụ I/O không blocking, nó không cần một thread riêng để xử lý.
      • Event Loop xử lý tất cả các bước được định nghĩa trong Request-2 của Client-2 và chuẩn bị Response-2.
      • Event Loop gửi Response-2 cho Client-2.
    • Event Loop chọn Request-n của Client-n:
      • Kiểm tra xem Request-n của Client-n có yêu cầu thao tác I/O có blocking nào hoặc cần nhiều thời gian cho các công việc tính toán phức tạp không.
      • Vì đây là một request đòi hỏi tính toán rất phức tạp hoặc là tác vụ I/O có blocking, Event Loop không trực tiếp xử lý request này.
      • Event Loop chọn thread T-1 từ thread pool nội bộ và gán Request-n của Client-n này cho thread T-1.
      • Thread T-1 đọc và xử lý Request-n, thực hiện các tác vụ I/O có blocking hoặc tính toán cần thiết, và cuối cùng chuẩn bị Response-n.
      • Thread T-1 gửi Response-n này đến Event Loop.
      • Event Loop sau đó gửi Response-n này cho Client-n.

Ở đây, mỗi request từ client thực chất là một lệnh gọi đến một hoặc nhiều hàm JavaScript. Các hàm JavaScript này có thể gọi các hàm khác hoặc tận dụng cơ chế hàm callback. Do đó, mỗi request có cấu trúc tương tự function(other-functionacall, callback-function).

Ví dụ:

function1(function2,callback1);
function2(function3,callback2);
function3(input-params);

Lưu ý:

  • Nếu bạn chưa nắm rõ cách các hàm này được chạy, khả năng cao là bạn chưa hiểu rõ hàm JavaScript và cơ chế callback.
  • Chúng ta cần có kiến thức cơ bản về các hàm JavaScript và cơ chế callback. Hãy tham khảo các bài hướng dẫn có liên quan trước khi bắt đầu viết ứng dụng Node.js.

Ưu điểm của Event Loop đơn luồng

  1. Dễ dàng xử lý số lượng lớn đồng thời các request từ client.
  2. Ngay cả số lượng request đồng thời từ client tăng lên, ứng dụng Node.js không cần phải tạo thêm nhiều thread.
  3. Ứng dụng Node.js sử dụng ít thread hơn, do đó chỉ tiêu tốn ít tài nguyên và bộ nhớ hơn.

Code phác thảo của Event Loop

Dươi đây là một đoạn pseudo code viết bằng Java để minh họa cách thức hoạt động của Event Loop trong Node.js:

public class EventLoop {
while(true){
        	if(Event Queue receives a JavaScript Function Call){
        		ClientRequest request = EventQueue.getClientRequest();
                            If(request requires BlokingIO or takes more computation time)
                                    Assign request to Thread T1
                            Else
                                  Process and Prepare response
                  }
            }
}

Tổng kết

Event Loop là một khái niệm nền tảng. Việc nắm vững nó dù không trực tiếp ảnh hưởng đến cú pháp code, sẽ giúp bạn xây dựng các ứng dụng Node.js có hiệu năng và kiến trúc tốt hơn. Đừng ngần ngại tìm hiểu sâu hơn các chủ đề tương tự với các bài viết khác của chúng tôi, nơi kiến thức chuyên sâu hữu ích khác luôn chờ đợi bạn khám phá.

0 Bình luận

Đăng nhập để thảo luận

Chuyên mục Hướng dẫn

Tổng hợp các bài viết hướng dẫn, nghiên cứu và phân tích chi tiết về kỹ thuật, các xu hướng công nghệ mới nhất dành cho lập trình viên.

Đăng ký nhận bản tin của chúng tôi

Hãy trở thành người nhận được các nội dung hữu ích của CyStack sớm nhất

Xem chính sách của chúng tôi Chính sách bảo mật.