Kiến trúc Node.js - Single thread, Call stack, I/O đồng bộ và bất đồng bộ trong Node.js

Kiến trúc Node.js - Single thread, Call stack, I/O đồng bộ và bất đồng bộ trong Node.js

Vấn đề

C có lẽ là ngôn ngữ lập trình mà nhiều người học khi mới bắt đầu theo đuổi sự nghiệp. Chúng ta quen với cách viết chương trình thực thi theo từng bước. Tức là mã sẽ được chạy lần lượt theo thứ tự từ trên xuống dưới, từ trái qua phải, xử lý xong đoạn mã này rồi mới đến đoạn mã tiếp theo.

Một ví dụ trong C, chương trình đơn giản in ra hai từ Hello World cách nhau 2 giây:

#include <stdio.h>
#include <unistd.h>

int main() {
    printf("Hello");
    sleep(2);
    printf("World");

    return 0;
}

Tuy nhiên, trong JavaScript/Node.js bạn phải làm quen với khái niệm bất đồng bộ, có những hàm được thiết kế để chạy đồng bộ hoặc nhiều hàm khác cần được chạy bất đồng bộ để tối ưu hiệu suất. Bất đồng bộ tức là đoạn mã đó chưa chắc trả về kết quả ngay lập tức, mà sẽ được trả về ở một thời điểm nào đó trong tương lai.

Ví dụ, chương trình JavaScript sau đây thoạt nhìn thì có vẻ in ra hai từ "Hello World", nhưng chính xác thì lại là "World Hello":

setTimeout(function() {
  console.log("Hello");
}, 0);

console.log("World");

Bởi vì setTimeout là một hàm bất đồng bộ, Node.js "tiếp nhận" chạy đoạn mã đó, tuy nhiên kết quả lại không có ngay lập tức. Cụ thể, trong ví dụ trên thì kết quả trả về sau khi từ "World" được in ra trước.

Vậy thì sự bất đồng bộ này có tác dụng như thế nào đối với Node.js?

Single Thread

Có thể bạn đã từng nghe nói JavaScript hay Node.js là Single thread. Điều này ám chỉ mã JavaScript chỉ chạy trong một luồng duy nhất. Nếu vậy thì những công việc như đọc/ghi file, gọi http sang hệ thống khác… luôn phải thực hiện tuần tự!?

Thử tưởng tượng viết một máy chủ API có một endpoint chứa các mã Javascript mà mỗi request trung bình sẽ tốn 5s để thực thi xong. Và vì Node.js chỉ có một luồng xử lý mã JavaScript nên các request sau đó nó phải đợi request trước hoàn thành thì nó mới được tiếp tục xử lý!? Hãy hình dung một cấp số cộng 5 sẽ lớn đến nhường nào khi có hàng ngàn người truy cập cùng một lúc.

Điều này có thể đúng hoặc không đúng trong vài trường hợp. Nó đúng khi tất cả mã bên trong đều là các hàm đồng bộ, còn khi mã bên trong có cả hàm không đồng bộ thì thời gian chờ sẽ không đến 5s. Bởi vì Node.js có cơ chế xử lý các hàm bất đồng bộ bằng Event Loop (Vòng lặp sự kiện) với sự trợ giúp của Event Queue và cả Thread Pool.

Trong Node.js tồn tại những hàm đồng bộ và không đồng bộ. Các câu lệnh cơ bản như if else, switch case, các vòng lặp, JSON.parse… được thực hiện đồng bộ, ngoài ra Node còn cung cấp rất nhiều hàm đồng bộ như readFileSync, gzipSync… Các hàm bất đồng bộ có thể kể đến như readFile, gzip, các cuộc gọi http thông qua module http… hoặc là thư viện của bên thứ ba có tính năng thao tác với các tệp tin, cơ sở dữ liệu cũng đều cung cấp các hàm bất đồng bộ.

Dưới đây là sơ đồ tổng quát về các thành phần có trong Node.js:

Thành phần Node.js

Có thể thấy Node.js được cấu thành từ ba thành phần chính: V8 của Chrome, Node.js Standard Library và libuv. Trong đó V8 là nơi sẽ thực thi mã JavaScript. Library cung cấp các thư viện mà V8 không làm được như thao tác với tập tin, các cuộc gọi http… và một thành phần nữa là libuv.

Call Stack

Call stack là nơi mà mã JavaScript được đưa vào để xử lý theo thứ tự. Tức là mã viết ra được đưa vào Call stack để nó sắp xếp thứ tự thực hiện. Tại một thời điểm xác định chỉ có một đoạn mã được xử lý.

Để làm rõ hơn điều này, hãy xem một ví dụ về đoạn mã chuyển đổi độ C sang độ F dưới đây:

const add = (a, b) => a + b;
const multiply = (a, b) => a * b;

const addCofficient = (val) => multiply(val, 1.8);
const addConst = (val) => add(val, 32);

const convertCtoF = (val) => {
  let result = val;
  result = addCofficient(result);
  result = addConst(result);
  return result;
};

convertCtoF(100);

Trong ví dụ trên, chúng ta gọi hàm convertCtoF, trong convertCtoF có gọi hai hàm addCofficient, addConst và trong mỗi hai hàm đó lại gọi hai hàm multiply hoặc add.

Thứ tự thực hiện các hàm đó trong call stack sẽ được miêu tả thông qua sơ đồ sau:

Callstack thực hiện chương trình

Chúng ta có thể thấy convertCtoF được đẩy vào Call stack đầu tiên, sau đó là hàm addCofficient. Trong addCofficient có gọi đến hàm multiply nên nó sẽ tiếp tục được đẩy vào trên đầu của ngăn xếp. Khi không còn hàm nào bên trong nữa, nó sẽ thực thi các chức năng bắt đầu từ trên cùng của ngăn xếp. Đó cũng là giải thuật FILO (First In Last Out), vì thế chúng ta nói Call stack là một ngăn xếp.

Nếu trong quá trình thực thi xảy ra lỗi hoặc exception, trình báo lỗi sẽ hiển thị được Error Stack Trace tức là vị trí của lỗi. Bởi vì các hàm được đưa vào Call stack theo thứ tự nên trình báo lỗi dễ dàng truy vết được vị trí của chúng ở đâu trong chương trình.

Ví dụ, sửa lại hàm addConst bằng các đổi tham số thứ hai trong hàm add thành một biến không tồn tại trong chương trình:

const addConst = (val) => add(val, number);

Thì khi thực thi chương trình một lỗi sẽ được bắn ra, bao gồm nguyên nhân và vị trí gây ra lỗi:

ReferenceError: number is not defined
   at addConst:5:32
   at convertCtoF:10:12
   at eval:14:1

Thông điệp này có ý nghĩa là number chưa được khai báo, ở dòng 5, bắt đầu từ cột 32, ở trong hàm convertCtoF ở dòng 10, bắt đầu từ cột 12…

Có thể mọi người đã nghe rằng javascript chạy trên một luồng (single thread) nhưng nếu như vậy thì chẳng phải là quá chậm chạm hay sao? Hay cũng có người nói rằng bản chất của node.js là đa luồng !? Nghe thì có vẻ vô lý nhỉ, vừa nói javascript là đơn luồng xong, node.js cũng dựa trên javascript thì lại bảo là đa luồng. Vậy thực hư điều này là sao? Liệu node.js có phải là đơn luồng không?

Câu trả lời là đúng, node.js là đơn luồng, nhưng nó đã khéo léo xử lý các tác vụ tốn thời gian ở một nơi khác (libuv) và nơi này thì lại xử lý các tác vụ theo phong cách đa luồng!

Tác vụ I/O

Các tác vụ I/O trong Node.js thường nói đến việc đọc/ghi file hay các hoạt động liên quan đến mạng như http request… Trong một chương trình máy chủ trong thực tế, những tác vụ I/O là phổ biến và gần như bạn sử dụng chúng một cách thường xuyên. Những tác vụ này tương đối là tốn thời gian để xử lý bởi vì nó liên quan đến kích thước của file hay là chất lượng đường truyền của mạng hoặc tốc độ xử lý của các máy chủ.

Trong node.js, I/O gồm có hai loại là đồng bộ và không đồng bộ.

I/O đồng bộ

Chúng ta xem xét một ví dụ về việc đọc file như sau:

const pdf = fs.readFileSync(file.pdf);
console.log("pdf size", pdf.size);
const doc = fs.readFileSync(file.doc);
console.log("doc size", doc.size);

Đọc file là một công việc tốn nhiều thời gian để xử lý. readFileSync là một hàm chạy đồng bộ, tức là file.pdf được đọc xong thì file.doc mới được đọc và in kết quả ra màn hình. Thời gian để xử lý cho 2 công việc đó được mô tả như hình dưới đây:

Quá trình đọc file

Chúng ta có thể thấy thời gian đọc file.pdf là 3ms, file.doc là 3ms, tổng thời gian để in kết quả ra màn hình là 2ms thì tổng thời gian chúng ta phải đợi tất cả công việc hoàn thành là 6ms.

6ms là nhanh trong ví dụ, nhưng hãy tưởng tượng kích thước của các file tăng lên kéo theo thời gian đọc tăng lên 30s mỗi file thì sao? Thì lúc đó call stack sẽ bị chặn, tức là trong thời gian đọc file đó sẽ không có bất kì đoạn mã nào được xử lý. Mã sẽ chạy theo thứ tự: Đọc file -> In -> Đọc file -> In một cách tuần tự.

I/O không đồng bộ

Bây giờ hãy sửa lại đoạn mã trên một chút, bằng cách thay hàm readFileSync thành readFile:

const pdf = fs.readFile(file.pdf);
console.log("pdf size", pdf.size);
const doc = fs.readFile(file.doc);
console.log("doc size", doc.size);

readFile là một hàm không đồng bộ. Hàm không đồng bộ là hàm có kết quả không trả về ngay lập tức, mà nó sẽ được trả ra ở một thời điểm nào đó. Nếu chạy đoạn mã trên bạn có thể thấy kết quả là một cái gì đó giống như thế này:

pdf size undefined
doc size undefined

Đó là do kết quả của pdf và doc không được trả về ngay lập tức, vì thế mọi nỗ lực truy cập vào thuộc tính size sẽ không mang lại kết quả nào cả.

Để giải quyết được vấn đề trên đối với các hàm bất đồng bộ, callback là một phương pháp hữu ích. Hiểu nôm na callback là hàm sẽ được gọi sau khi hàm bất đồng bộ có kết quả.

Nói thì khó hình dung nhưng ví dụ thì dễ hiểu, tôi sẽ sửa lại đoạn mã trên một chút:

fs.readFile(file.pdf)
  .then(pdf => console.log("pdf size", pdf.size));

fs.readFile(file.doc)
  .then(doc => console.log("doc size", doc.size));

then được sử dụng như một cách để cung cấp callback cho hàm bất đồng bộ, ngoài ra callback còn được gọi như một tham số thứ hai của hàm readFile như:

fs.readFile(file.pdf, function(err, pdf) {
  console.log("pdf size", pdf.size);
})

Bằng cách thay thế hàm readFileSync thành hàm readFile, thời gian xử lý sẽ được giảm xuống đáng kể bởi việc đọc file sẽ được thực hiện gần như là song song ở một nơi khác mà ta gọi là Thread Pool. Các bạn xem sơ đồ dưới đây để hiểu rõ hơn:

Đọc file bất đồng bộ

Đó là những lợi ích mà bất đồng bộ mang lại cho Node.js. Các tác vụ I/O tốn thời gian được đưa vào Thread Pool để xử lý, tránh việc chúng chiếm thời gian xử lý quá lâu trong call stack sẽ khiến chương trình bị tắc nghẽn.

Một ví dụ rõ ràng để thấy sự khác biệt giữ đồng bộ và không đồng bộ đó là nếu như bạn đã làm việc với ngôn ngữ đồng bộ khác như PHP hay Golang thì các hàm gọi đến cơ sở dữ liệu được chạy lần lượt. Nhưng trong node.js chúng là bất đồng bộ và bạn sẽ phải dùng callback hoặc Promise để hứng kết quả trả ra ở một thời điểm nào đó.

mysql.query("select * from user where id = 1", function (err, result) {
  console.log("user": user);
});

Tổng kết

Node.js là một luồng đơn nên tại một thời điểm chỉ có một đoạn mã được xử lý. Tuy nhiên điều đó không hẳn làm cho node.js chậm chạp do nó sử dụng các hàm không đồng bộ với sự trợ giúp của Event Loop.

Node.js được cấu tạo từ ba thành phần chính trong đó V8 đóng vai trò xử lý mã Javascript, Libuv cung cấp Event Loop để xử lý bất đồng bộ.

Vậy thì node.js xử lý bất đồng bộ như thế nào thì bài viết sau mình sẽ nói rõ hơn về vấn đề này.

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.
Author

Xin chào, tôi tên là Hoài - một anh Dev kể chuyện bằng cách viết ✍️ và làm sản phẩm 🚀. Với nhiều năm kinh nghiệm lập trình, tôi đã đóng góp một phần công sức cho nhiều sản phẩm mang lại giá trị cho người dùng tại nơi đang làm việc, cũng như cho chính bản thân. Sở thích của tôi là đọc, viết, nghiên cứu... Tôi tạo ra trang Blog này với sứ mệnh mang đến những bài viết chất lượng cho độc giả của 2coffee.dev.Hãy theo dõi tôi qua các kênh LinkedIn, Facebook, Instagram, Telegram.

Bạn thấy bài viết này có ích?
Không

Bình luận (0)