Những điều cần biết về Node.js

Node.js là một trong những công nghệ rất phổ biến hiện nay để build những ứng dụng sử dụng API, ứng dụng mobile, destop hay thậm chí cả các ứng dụng về Internet of Thing một cách hiệu quả. Bài viết sẽ tìm hiểu về những điều cần biết về công nghệ Node.js

1. Web trước khi công nghệ Node.js xuất hiện

Mutil Threaded Server

  • Một ứng dụng web thường được viết theo kiến trúc client/server. Client sẽ gửi request đến server và sau đó server sẽ phản hồi lại request đó cùng với tài nguyên tìm được cho client. Server sẽ chỉ response khi cllient gửi request và sẽ đóng connection sau mỗi response.
  • Với kiểu kiến trúc này thì với mỗi request của client, server sẽ phải mất thời gian, tài nguyên (bộ nhớ, CPU, etc). Để thực hiện request tiếp theo thì server bắt buộc phải hoàn thành xong request trước đó.
  • Như vậy, có phải server sẽ thực hiện 1 request tại một thời điểm? Không hẳn là vậy bởi vì khi server nhận được một request mới, request đó sẽ được xử lý bởi một thread.
  • Một thread hiểu đơn giản là thời gian, tài nguyên mà CPU cần phải tiêu tốn để thực hiện một cấu trúc lệnh (instructions). Như vậy, server sẽ tham gia xử lý nhiều request tại một thời điểm, one per thread (thường được gọi là thread-per-request). Hình vẽ dưới đây sẽ mô tả quá trình đó Để tham gia vào N request tại một thời điểm, thì server phải có N threads. Nếu server muốn lấy N+1 request, thì nó phải đợi cho đến khi một thread trong số N thread kia sẵn sàng.
  • Trong ví dụ của Multil Threaded Server như hình vẽ, server sẽ cho phép tối đa là 4 requests, khi nó nhận được 3 request tiếp theo, những request này phải đợi cho đến khi có những threads trong số 4 threads kia sẵn sàng.
  • Cách để giải quyết hạn chế thread là thêm nhiều tài nguyên (bộ nhớ, CPU, ...) nhưng đó không phải là cách tốt để giải quyết.

Blocking I/O

Số lượng của thread trong một server không phải là vấn đề duy nhất cần phải giải quyết. Một câu hỏi đặt ra là tại sao một thread lại không thể tham gia vào 2 hay nhiều request tại một thời điểm? Đó là bởi vì cơ chế hoạt động của blocking Input/Output
Giả sử ta đang xây dựng một website là một cửa hàng online và cần một trang mà user có thể xem được toàn bộ product của cửa hàng.
User sẽ truy cập vào trang http://<yourdomain>/products, sau đó server sẽ render ra một trang HTML với toàn bộ sản phẩm của cửa hàng từ database?

  • Quá trình diễn ra phía sau đó là:
  1. Khi user truy cập vào địa chỉ /products, request được gửi lên sẽ ứng với một method hoặc một function. Lúc này thread đang hoạt động.
  2. Method hoặc function được thực thi, thread lúc này vẫn đang hoạt động
  3. Khi ta lưu logs vào một file, giả sử ta log "Method X đang thực thi" lúc này là hoạt động của blocking I/O, thread sẽ đợi.
  4. Sau khi log được lưu, lệnh tiếp theo được thực thi, thread sẽ hoạt động trở lại
  5. Khi ta thực hiện câu truy vấn đơn giản như SELECT * FROM products lúc này sẽ là hoạt động của blocking I/O chứ không phải thread, thread sẽ đợi
  6. Ta lấy được mảng các products, thread lúc này vẫn đang đợi
  7. Với product đã lấy được, lúc này sẽ chuẩn bị thực hiện render, nhưng trước đó cần đọc nó trước, thread lúc này vẫn trong trạng thái đợi.
  8. Khi response được gửi trả về phía client, thread sẽ hoạt động trở lại
    Như vậy, hoạt động của I/O sẽ làm thread phải đợi và tiêu tốn tài nguyên

2. Bài toán C10K

Bài toán này là ở những năm đầu 2000, máy server và client lúc đó rất chậm, vấn đề đặt ra là phải handle được 10000 request của client trong một server. Vì sao mô hình thread-per-request không thể giải quyết vấn đề trên, hãy cùng làm một phép toán
Một thread sẽ chiếm 1MB bộ nhớ, như vậy 10k thread sẽ cần phải có 10GB RAM => quá lớn tại thời điểm đó
Giờ đây máy server và client đã tốt hơn rất nhiều nên có thể giải quyết được vấn đề trên, có thể handle được thậm chí là 10 triệu connection từ phía client trong một máy server (tham khảo thêm )

  • Node.js có thể giải quyết được bài toán C10K, hãy cùng tìm hiểu lý do tại sao?

Non-Blocking I/O

Node.js là non-blocking I/O, tức là:

  1. Main thread sẽ không bị block bởi blocking I/O
  2. Server sẽ giữ request đến
  3. Chúng ta sẽ làm việc với những đoạn code không đồng bộ Hãy cùng tìm hiểu thông qua một ví dụ, với mỗi request đến đường dẫn /home, server sẽ render ra một trang HTML, trong trường hợp khác sẽ in ra dòng chữ 'Hello World'. Để gửi được một trang HTML trước tiên cần đọc file
    home.html
    <html>
      <body>
        <h1>This is home page</h1>
      </body>
    </html>

Trang index.js

    const http = require('http');
    const fs = require('fs');

    const server = http.createServer(function(request, response) {
      if (request.url === '/home') {
        fs.readFile(`${ __dirname }/home.html`, function (err, content) {
          if (!err) {
            response.setHeader('Content-Type', 'text/html');
            response.write(content);
          } else {
            response.statusCode = 500;
            response.write('An error has ocurred');
          }

          response.end();
        });
      } else {
        response.write('Hello World');
        response.end();
      }
    });

    server.listen(8080); 

Nếu một request gửi đến url /home, module fs sẽ được sử dụng để đọc file home.html. Những function được truyền vào http.createServer và fs.readFile là các callback. Chúng sẽ được thực thi tại thời điểm nào trong tương lai (function đầu là khi server nhận được request, function sau sẽ là file được đọc)
Như vậy khi đọc file, Node.js vẫn có thể nhận được request, thậm chí còn có thể đọc file lại lần nữa, tất cả được thực hiện trong một thread bằng cách nào ???

The Event Loop

Hãy cùng tìm hiểu về event loop trong node.js để xem điều gì xảy ra. Hiểu đơn giản nó là một vòng lặp vô hạn, và chỉ khi thread sẵn sàng.
The event loop có 6 phase

  • timers: phase này sẽ thực hiện callback trong các hàm setTimeout(), setInterval()
  • pending callbacks: sẽ thực hiện hầu hết các callback với các exception khi close callback, các callback được đặt bởi timers và hàm setImmediate()
  • idle, prepare: chỉ sử dụng bên trong
  • poll sẽ nhận các I/O event
  • check : setImmediate() sẽ được gọi ở đây, close callback như là socket.on('close') Như vậy là chỉ có một thread là Event Loop, vậy nó thực thi hoạt động I/O như thế nào, ở đâu???
    Khi Event Loop cần thực hiện các hoạt động I/O, nó sẽ sử dụng một OS thread từ một pool (thông qua thư viện libuv), và khi thực hiện xong, callback sẽ được đưa vào hàng đợi để thực hiện trong phase pending callbacks

3. Ví dụ áp dụng

Hãy cùng xem một ví dụ về xây dựng một API để tính các số nguyên tố. Đầu vào của API sẽ là một số N, và phải trả về N số nguyên tố đầu tiên.
Module về tìm số nguyên tố sẽ như sau

         function isPrime(n) {
              for(let i = 2, s = Math.sqrt(n); i <= s; i++)
                if(n % i === 0) return false;
              return n > 1;
        }

        function nthPrime(n) {
          let counter = n;
          let iterator = 2;
          let result = [];

          while(counter > 0) {
            isPrime(iterator) && result.push(iterator) && counter--;
            iterator++;
          }

          return result;
        }

        module.exports = { isPrime, nthPrime };

Ta có một file index.js

    const http = require('http');
    const url = require('url');
    const primes = require('./primes');

    const server = http.createServer(function (request, response) {
      const { pathname, query } = url.parse(request.url, true);

      if (pathname === '/primes') {
        const result = primes.nthPrime(query.n || 0);
        response.setHeader('Content-Type', 'application/json');
        response.write(JSON.stringify(result));
        response.end();
      } else {
        response.statusCode = 404;
        response.write('Not Found');
        response.end();
      }
    });

    server.listen(8080);

prime.js sẽ là module thực hiện việc tìm số nguyên tố, isPrime sẽ check nếu số đầu vào là một số nguyên tố, nthPrime sẽ lấy nth số nguyên tố đầu
Để lấy được 20 số nguyên tố đầu ta sẽ thực hiện request với url là http://localhost:8080/primes?n=20.
Giả sử có 3 clients đang truy cập kết quả sẽ như sau

  • Client 1 request với n = 5
  • Client 2 với n = 1000
  • Client 3 với n = 10,000,000,000 Khi client 3 gửi request có thể thấy main thread bị block lại bởi bị nó bận thực hiện việc tính toán và không thể làm gì khác, điều này gọi là CPU intensive
    Ta sẽ sửa lại đoạn code trên, nhớ lại phần trước về thư viện libuv nó giúp Node.js thực hiện các hoạt động I/O với một thread OS để tránh bị block main thread. phiên bản Node.js v10.5 đã sử dụng Worker Threads
    Code sẽ được sửa lại như sau
    file primes.js
    const { workerData, parentPort } = require('worker_threads');

    function isPrime(n) {
      for(let i = 2, s = Math.sqrt(n); i <= s; i++)
        if(n % i === 0) return false;
      return n > 1;
    }

    function nthPrime(n) {
      let counter = n;
      let iterator = 2;
      let result = [];

      while(counter > 0) {
        isPrime(iterator) && result.push(iterator) && counter--;
        iterator++;
      }

      return result;
    }

    parentPort.postMessage(nthPrime(workerData.n));

file index.js

    const http = require('http');
    const url = require('url');
    const { Worker } = require('worker_threads');

    const server = http.createServer(function (request, response) {                                                                                              
      const { pathname, query } = url.parse(request.url, true);

      if (pathname === '/primes') {                                                                                                                                    
        const worker = new Worker('./primes-workerthreads.js', { workerData: { n: query.n || 0 } });

        worker.on('error', function () {
          response.statusCode = 500;
          response.write('Oops there was an error...');
          response.end();
        });

        let result;
        worker.on('message', function (message) {
          result = message;
        });

        worker.on('exit', function () {
          response.setHeader('Content-Type', 'application/json');
          response.write(JSON.stringify(result));
          response.end();
        });
      } else {
        response.statusCode = 404;
        response.write('Not Found');
        response.end();
      }
    });

    server.listen(8080);

index.js với mỗi lần gọi sẽ tạo ra một instance của Worker class để load và thực hiện file primes.js trong một thread worker. Khi list các số nguyên tố được tính toán xong, message event sẽ được giải phóng, gửi kết quả đến main thread, và bởi vì đã xong nên exit event cũng được giải phóng, cho phép main thread gửi kết quả đến client.
primes.js sẽ thay đổi một chút, nó import workerDataparentPort để gửi message đến main thread. Kết quả lúc này sẽ như sau
Có thể thấy main thread đã không bị block nữa!!!
Trên đây là những gì tìm hiểu các kiến thức về Node.js. Hi vọng bài viết có ích cho mọi người. See you!