+5

Nghiên cứu về NodeJS và những nguyên lý cốt lõi

Tổng quan

NodeJS là một Javascript runtime environment mã nguồn mở và đa nền tảng, cung cấp đối tượng, môi trường để Javascript có thể giao tiếp với hệ thống. Như chúng ta đã biết thì ngôn ngữ Javascript là một ngôn ngữ lập trình với mục đích ban đầu là chạy trên trình duyệt Web. Tuy nhiên vào năm 2009, Ryan Dahl đã giới thiệu NodeJS, bằng cách sử dụng V8 engine (cốt lõi của Google Chrome), NodeJS đã có thể cung cấp môi trường để Javascript có thể chạy bên ngoài trình duyệt, từ đó mang đến một công cụ mạnh mẽ để phát triển nhiều loại phần mềm dựa trên Javascript.
NodeJS đang có sự phát triển mạnh mẽ vì nhiều lợi ích của nó. Khi mà giờ đây chúng ta có thể phát triển cả Frontend lẫn Backend chỉ cần với một ngôn ngữ duy nhất là Javascript. NodeJS có rất nhiều khái niệm rất thú vị để học hỏi, vì vậy với bài viết này mình sẽ cung cấp một vài thông tin về những khái niệm cốt lõi của NodeJS.

Blocking và Non-blocking

  • Blocking và Non-blocking là hai khái niệm cơ bản nền móng cho kiến trúc của môi trường NodeJS.
  • Trong lập trình, các câu lệnh thường sẽ được thực hiện theo thứ tự đã được định sẵn, lệnh này được xử lý xong mới đến lệnh khác. Vì vậy sẽ gây ra một hiện tượng khi một câu lệnh hoặc một tác vụ cần có thời gian xử lý lâu thì tất cả các lệnh ở phía sau nó sẽ không được thực thi, hiện tượng này gọi là blocking.
  • Ngược lại với blocking, non-blocking nghĩa là khi một câu lệnh hoặc tác vụ có thời gian xử lý lâu nhưng nó không làm nghẽn lại chương trình mà các câu lệnh tiếp theo vẫn có thể chạy tiếp dù là câu lệnh đó chưa thực hiện xong.
  • Việc sử dụng blockingnon-blocking hợp lý sẽ giúp chương trình không gặp tình trạng tắc nghẽn hoặc không phản hồi khi có một tiến trình tốn nhiều thời gian đang chạy. Điều này là hết sức quan trọng nhất là với việc lập trình phần mềm phía server cho nhiều client sử dụng.
  • Ex code:
const fs = require('node:fs');

const data = fs.readFileSync('/file.md'); // blocks here until file is read
console.log(data);
moreWork(); // will run after console.log

const fs = require('node:fs');

fs.readFile('/file.md', (err, data) => {
  if (err) throw err;
  console.log(data);
});
moreWork(); // will run before console.log
  • Lưu ý: Khi sử dụng code non-blocking và code blocking cần để ý đến lúc sẽ thực thi của chúng ➔ tránh trường hợp thứ tự thực thi câu lệnh không đúng gây ra lỗi hệ thống
  • Ex:
const fs = require('node:fs');

fs.readFile('/file.md', (err, data) => {
  if (err) throw err;
  console.log(data);
});
fs.unlinkSync('/file.md'); // Lệnh này sẽ chạy trước nên có khả năng file bị unlink trước khi đọc
// Cách đúng
const fs = require('node:fs');

fs.readFile('/file.md', (readFileErr, data) => {
  if (readFileErr) throw readFileErr;
  console.log(data);
  fs.unlink('/file.md', unlinkErr => {
    if (unlinkErr) throw unlinkErr;
  });
});

Asynchronous

  • Đồng bộ (synchronous) là khái niệm chỉ đến các câu lệnh được chạy theo thứ tự. Còn bất đồng bộ là khái niệm ngược lại, chỉ đến các câu lệnh chạy không theo thứ tự. Nguồn

  • Hệ thống máy tính được thiết kế theo nguyên tắc bất đồng bộ (asynchronous). Nghĩa là mọi tác vụ có thể xảy ra bên cạnh, song song với luồng chính đang chạy.

  • Các ngôn ngữ lập trình phổ biến hiện nay như C, Java, C#, PHP, Go, Ruby, Python đều mặc định là synchronous (chạy đồng bộ). Và một vài ngôn ngữ cung cấp tính năng bất đồng bộ thông qua sử dụng những thứ như threads, spawning a new process.

  • Mặc định JavaScript cũng là ngôn ngữ đồng bộ và đơn luồng, nghĩa là code không thể tạo thread mới và chạy song song. (Lý do JavaScript chạy đơn luồng là vì mục đích thiết kế ban đầu của JavaScript là cho browser và thao tác với DOM. Vì vậy JavaScript chỉ đơn luồng để đảm bảo thao tác trên DOM không bị xung đột với nhau).

  • Vì vậy để xử lý các tác vụ cần có sự bất đồng bộ để tránh hiện tượng blocking code thì runtime environment của Javascript sẽ cung cấp các giải pháp để giải quyết. Mỗi runtime environment khác nhau sẽ cung cấp các cách thức giải quyết khác nhau. Với NodeJS thì nó cung cấp non-blocking I/O environment để giải quyết vấn đề này.

  • Với khái niệm bất đồng bộ thì có một khái niệm quan trọng khác liên quan là khái niệm callback mà ta sẽ tiếp tục tìm hiểu dưới đây.

Callback

  • Callback là một function được truyền vào làm tham số cho một function khác (vì trong Javascript function được xem như 1 object) ➔ callback sẽ được gọi sau khi function cha chạy.
  • Thông thường chức năng của callback là sau khi thực hiện xong một tác vụ của một function thì function này sẽ gọi callback để xử lý tác vụ tiếp theo cần thiết.
  • Callback là một khái niệm quan trọng trong việc lập trình bất đồng bộ, vì khi tạo ra các function bất đồng bộ thì chúng ta không thể biết chính xác cụ thể là nó sẽ hoàn thành xong tác vụ đó khi nào, vì vậy với callback chúng ta có thể điều khiển được sau khi tác vụ bất đồng bộ đó làm xong nó sẽ tiếp tục làm gì (ví dụ như trả về kết quả, trả về lỗi hoặc khởi chạy tác vụ mới).
  • Ex:
function parent(params, callback) {
	// Do something
	const callbackParam = params[0];
	callback(callbackParam);
}

parent([1, 2, 3], function (result) => {
	console.log(result); // => 1
})
  • Lưu ý thông thường khi muốn xử lý error trong callback thì tham số đầu tiên được truyền vào là tham số error. Nếu như không có error thì tham số này sẽ là null. Đây gọi là error first callback
  • Một vấn đề khi sử dụng callback cần lưu ý đó là callback hell. Đó là hiện tượng khi sử dụng quá nhiều callback lồng vào nhau thì nó sẽ tăng cao độ phức tạp cũng như là giảm đi khả năng dễ đọc của đoạn code. Để giải quyết vấn đề này thì hiện tại có nhiều phương thức có thể dùng để thay thế việc sử dụng callbacks như là Promise(ES6) và Async/Await (ES2017).
  • Ex:
function1(paramA => {
	function2(paramB =>  {
		function3(paramC => {
			...
			functionN(paramN => {
				...
			})
		})
    })
})

Events Loop

  • Event Loop là một khái niệm logic của NodeJS cung cấp khả năng thực thi các non-blocking I/O operations. Nó cũng là yếu tố cốt lõi xây dựng nên các hệ thống phần mềm dựa trên NodeJS có khả năng xử lý tác vụ đồng thời cao.
  • Nguyên lý hoạt động tổng quan: Event Loop sẽ theo dõi các tác vụ được đưa vào Call stack và Queue. Khi tất cả các tác vụ đã được xử lý xong trong Call stack và Call stack rỗng thì Event Loop sẽ theo dõi xem trong Queue có bất kì callback nào đang chờ được thực thi hay không? Nếu có thì sẽ tiếp tục được callback đó vào Call stack để thực thi. Vòng lặp (loop) sẽ cứ tiếp tục diễn ra như vậy để xử lý tất cả các tác vụ.
  • Nguyên lý hoạt động chi tiết
  • Mỗi mục trên là một được gọi là một phase

Timers

  • Là nơi xử lý các callback của các function như setTimeout, setInterval
  • Những Timer callback sẽ được gọi sớm nhất có thể theo thời gian đã được lên lịch ➔ dù vậy thì thời gian xử lý các callback khác có thể làm ảnh hưởng đến thời gian này
  • Về mặt kĩ thuật thì poll phase quyết định khi nào timers được thực thi
  • Khi Event Loop tiến vào poll phase thì nó sẽ xem xét xem Call Stack có đang trống không và có timer callback nào đang cần được thực thi không. Nếu có thì sẽ thực thi còn nếu không thì sẽ chờ cho đến khi nào Call stack trống để thực thi
  • NodeJS sử dụng thư viện libuv của C để xây dựng event loop và tất cả các asynchronous behaviors.

Pending callbacks

  • Là phase sẽ thực thi callbacks cho hoạt động hệ thống như là các loại lỗi TCP

Poll

  • Poll phase gồm có 2 function chính:
    • Tính toán thời gian block và poll cho I/O
    • Xử lý các sự kiện trong poll queue
  • Khi Event Loop vào poll phase thì có 2 trường hợp xảy ra:
    • Poll queue không trống, lúc đó Event Loop sẽ đưa tất cả các callback trong queue theo thứ tự vào trong callstack và sau đó thức thi chúng theo cơ chế tuần tự cho đến khi poll queue trống hoặc đạt đến ngưỡng tối đa của hệ thống
    • Nếu như poll queue trống:
      • Nếu như có sử dụng setImmediate() thì event loop sẽ qua phase tiếp theo là check phase để kiểm tra và thực thi chúng
      • Nếu không có thì Event Loop sẽ chờ cho đến khi callbacks mới được thêm vào queue và sẽ tiếp tục xử lý chúng
  • Khi poll queue trống thì Event Loop sẽ xem xét ở timers xem có callback nào đến lượt thực thi không. Nếu như có thì Event Loop sẽ quay lại timers phase để thực thi các callback này.

Check

  • Phase này cho phép thực thi callbacks trực tiếp sau khi poll phase hoàn thành. Khi đoạn code được đưa vào queue bởi câu lệnh setImmediate() thì event loop sẽ ở check phase thay vì chờ đợi ở poll phase.
  • setImmediate() là một timers đặt biệt được chạy ở một phase riêng trong Event Loop. Nó sử dụng API của thư viện libuv để lên lịch thực thi callbacks sau khi poll phase được hoàn thành.

Close Callbacks

  • Thực thi các callbacks khi sockets hoặc handle bị đóng đột ngột.

process.nextTick()

  • nextTickQueue sẽ được xử lý sau khi những operation hiện tại được hoàn thành, không phụ thuộc vào phase của Event Loop
  • Khi gọi đến process.nextTick() trong bất kì phase nào thì tất cả callbacks được đưa vào process.nextTick() sẽ luôn được resolved trước khi event loop được tiếp tục hay nói cách khác nó sẽ được thực thi ngay lập tức sau khi tiến trình đang diễn ra kết thúc ➔ Điều này có thể gây ra một số vấn đề ví dụ như block event loop

setImmediate() vs process.nextTick()

  • process.nextTick() sẽ được gọi ngay lập tức không liên quan tới phase
  • setImmediate() sẽ được gọi phụ thuộc vào tiến trình và queue hiện tại ➔ sẽ luôn được sắp xếp lên đầu queue so với setTimeout, setInterval khi ở trong pending callback
  • Về mặt ý nghĩa thì tên của 2 function này nên được đổi cho nhau vì process.nextTick() thực thi ngay lập tức hơn so với setImmediate()
  • Được khuyến nghị dùng setImmediate() trong tất cả các trường hợp nếu có thể vì như vậy sẽ dễ dàng debug hơn.
  • Điểm khác biệt giữa Promise.then(), setImmediate(), setTimeout()process.nextTick() là loại queue mà chúng được đưa vào để xử lý
    • process.nextTick sẽ được đưa vào process.nextTick queue và đây là queue sẽ được xử lý đầu tiên ngay sau khi một operation kết thúc
    • promise.then sẽ được đưa vào promise microtask queue, sẽ được xử lý trước macrotask queue
    • setImmediate, setTimeout sẽ được đưa vào macrotask queue là nơi được xử lý sau cùng
    • Lưu ý là với ES module thì sẽ có một số sự khác biệt vì ES module load scripts với một wrapped với asynchronous operation.
    • Ex:
const baz = () => console.log('baz');
const foo = () => console.log('foo');
const zoo = () => console.log('zoo');

const start = () => {
  console.log('start');
  setImmediate(baz);
  new Promise((resolve, reject) => {
    resolve('bar');
  }).then(resolve => {
    console.log(resolve);
    process.nextTick(zoo);
  });
  process.nextTick(foo);
};

start();

// start foo bar zoo baz
// If it's ES module: start bar foo zoo baz

Khi nào nên sử dụng process.nextTick()

  • Cho pháp xử lý lỗi, dọn dẹp những tài nguyên không cần thiết hoặc mong muốn thử lại request một lần nữa trước khi event loop tiếp tục chạy.
  • Đôi khi cần cho phép callback chạy sau khi call stack được giải phóng nhưng trước khi event loop được chạy tiếp
  • Ex:
const EventEmitter = require('node:events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();
    this.emit('event');
  }
}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
}); // Không nhận được event vì nó không xác định được điểm mà event này sẽ được emit.
const EventEmitter = require('node:events');

class MyEmitter extends EventEmitter {
  constructor() {
    super();

    // use nextTick to emit the event once a handler is assigned
    process.nextTick(() => {
      this.emit('event');
    });
  }
}

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
  console.log('an event occurred!');
}); // Khi sử dụng process.nextTick() thì event này sẽ được emit ngay sau khi constructor

Kết luận

Ở trên là những khái niệm nguyên lý cốt lõi của NodeJS. Để có thể xây dựng một sản phẩm phần mềm có chất lượng cao thì việc nắm rõ những nguyên lý cơ bản làm nền móng để xây dựng nên nó là rất quan trọng. Nên mình mong là qua bài viết này có thể cung cấp thêm một số thông tin cho mọi người về chủ đề này. NodeJS vẫn còn rất nhiều khái niệm hay cũng như nhiều chủ đề như cách xây dựng hệ thống, cách tối ưu, ... nhưng sẽ hẹn mọi người trong một bài viết khác. Cảm ơn mọi người đã đọc bài viết này.

Nguồn tham khảo


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí