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

Tin ngắn hàng ngày dành cho bạn
  • Mình luôn luôn nhấn mạnh rằng viết mang lại rất nhiều giá trị. Đối với mình thì nó giúp giải toả căng thẳng, hệ thống hoá kiến thức và cả cách trình bày thế nào cho dễ hiểu. Bên cạnh đó thì viết cũng là để suy nghĩ 🤓

    Writing is thinking

    Nhiều cái ở trong đầu tưởng biết nhưng hoá ra lại không biết nhiều đến như vậy. Không tin bạn cứ thử viết ra một điều gì đó mà nghĩ rằng mình đang biết rất rõ xem. Nhiều người cho rằng cần gì phải viết vì đọc cũng đủ rồi mà. Hoàn toàn đồng ý, nhưng sẽ tốt hơn nữa nếu như viết ra. Tưởng tượng quá trình này giống như bạn tiêu thụ rồi sản xuất nội dung vậy. Tiêu thụ mà không sản xuất thì ngay đến cả chính bản thân còn không biết có đang tiêu thụ hay không, vì sản xuất mới chứng minh được tính hiệu quả của quá trình này.

    » Xem thêm
  • Hôm qua sách "Thiết kế hệ thống học máy - Designing Machine Learning Systems" của chị Huyen Chip mới về. Trước đọc mấy bài về ML của chị thấy "bánh cuốn", cuối cùng sách cũng có bản tiếng Việt nên là mua luôn.

    Để đọc song mình viết vài dòng cho mọi người nha ☺️

    » Xem thêm
  • Thêm một bài viết nữa hướng dẫn rất dễ hiểu về jj nè mọi người ơi 🤓

    Jujutsu For Busy Devs

    » Xem thêm

Vấn đề

C là ngôn ngữ lập trình được nhiều người biết đến khi bắt đầu theo đuổi sự nghiệp. C đơn giản để tiếp cận nhưng sẽ trở thành "cơn ác mộng" cho những ai muốn dấn sâu hơn vào nó. Lập trình cũng giống như cách mà bạn đang cố gắng giải thích vấn đề gì đó với người khác, nhưng ở đây là giải thích với máy tính. Cách để tạo ra một chương trình là diễn giải điều muốn làm theo từng bước, từng dòng. Sau đó máy tính sẽ chạy chương trình theo thứ tự đã viết. Tức là mã đượ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.

Ví dụ chương trình in ra hai từ Hello World cách nhau 2 giây trong C.

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

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

  return 0;
}

Thoạt nhìn vào ai cũng có thể đoán được đầu tiên đoạn mã trên in ra Hello, sau khi gặp hàm sleep nó dừng 2 giây rồi tiếp tục in ra World. Mã chạy như vậy gọi là mã đồng bộ.

Vậy nhưng trong JavaScript/Node.js cầ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ộ, cũng có hàm được thiết kế bất đồng bộ để tối ưu hiệu suất. Bất đồng bộ tức là đoạn mã đó chưa trả về kết quả ngay lập tức mà được trả về ở một thời điểm nào đó trong tương lai.

Hãy nhìn vào ví dụ dưới đây và đoán xem kết quả cuối cùng là gì?

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

console.log("World");

Đoạn mã trên in ra World Hello thay vì Hello World, mặc dù dòng console.log("Hello") được viết trước. Bởi vì setTimeout là một hàm bất đồng bộ, kết quả không có ngay lập tức nên đoạn mã tiếp tục chạy và từ World được in ra trước.

Tại sao JavaScript/Node.js cần phải bất đồng bộ? Muốn biết được lý do, trước tiên cần phải biết nguyên lý hoạt động của nó.

Single Thread

Bạn đã nghe nhiều về JavaScript/Node.js chỉ có một luồng. Điều này ám chỉ mã chỉ được thực thi trong một luồng duy nhất. Nếu như vậy thì làm sao nó có thể xử lý hàng ngàn yêu cầu cùng lúc chỉ với một luồng? Tưởng tượng một máy chủ API có một điểm cuối (endpoint) mất 5 giây để trả về kết quả, trong khi có rất nhiều yêu cầu gửi đến? Vì Node.js chỉ có một luồng nên các yêu cầu sau phải đợi yêu cầu trước hoàn thành thì mới được xử lý!? Nếu vậy thì chẳng phải là quá tệ so với một ngôn ngữ phía máy chủ hay sao?

Thật may điều này không xảy ra vì JavaScript/Node.js được trang bị cơ chế xử lý bất đồng bộ. 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ư khai báo biến, các phép tính +-x:, if else, switch case, các vòng lặp, JSON.parse, các hàm trong Node.js được kết thúc bằng chuỗi sync như readFileSync, gzipSync... được thực hiện đồng bộ. Còn lại hầu hết các hàm được thiết kế theo kiểu bất đồng bộ như readFile, gzip... các yêu cầu http... để nhằm mục đích không chặn vòng lặp sự kiện.

Call Stack

Call stack là ngăn xếp cuộc gọi để thực thi các lệnh JavaScript. Mã viết ra muốn chạy được cần phải đưa vào call stack. Vì là một stack nên nó tuân theo tắc vào sau ra trước (Last In First Out). Call stack là nơi đảm bảo thứ tự thực hiện chương trình.

Để làm rõ cách call stack hoạt động. 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 cuối cùng hàm convertCtoF được gọi. convertCtoF gọi hai hàm addCofficientaddConst. Call stack đảm bảo thứ tự thực hiện chương trình bằng cách sắp xếp hàm được gọi theo ngăn xếp.

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 gọi hàm multiply nên nó 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, call stack bắt đầu thực thi chương trình. Call stack còn có một vai trò quan trọng trong việc xác định vị trí lỗi. Nếu xảy ra lỗi, trình báo lỗi 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.

Hãy 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.

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

Chạy chương trình, một lỗi 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...

Điều gì xảy ra nếu một hàm mất nhiều thời gian xử lý được đưa vào call stack? Giả sử addConst mất 5 giây để thực hiện thì nó chiếm 5 giây của call stack trước khi được giải phóng. Đó là lý do chúng ta cần các hàm bất đồng bộ. Vậy khi gặp hàm bất đồng bộ, call stack xử lý chúng như thế nào? Để hiểu được điều đó trước tiên chúng ta cần tìm hiểu về I/O.

Tác vụ I/O

Tác vụ I/O là những công việc có liên quan đến đọc/ghi dữ liệu, tệp tin, hoặc liên quan đến mạng như truy vấn http, socket... I/O xuất hiện ở khắp mọi nơi trong lập trình máy chủ. Một cách hình dung đơn giản là truy vấn cơ sở dữ liệu cũng được coi là tác vụ I/O.

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

I/O đồng bộ

Xem xét ví dụ đọc nội dung tệp dưới đây.

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

Nhớ lại các hàm kết thúc bằng sync thường là đồng bộ. Đọc nội dung một tệp là tác vụ I/O tốn nhiều thời gian để xử lý. readFileSync là một hàm chạy đồng bộ, file.pdf đọc xong thì file.doc mới đến lượt. Thời gian xử lý của 2 hàm được mô tả như hình dưới đây.

Quá trình đọc file

Thời gian đọc file.pdf là 3ms, file.doc là 3ms. Thời gian in kết quả ra màn hình là 2ms, 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à rất nhanh. Nhưng hãy tưởng tượng kích thước của các tệp tin tăng lên kéo theo thời gian đọc tăng lên thì sao? Lúc đó nhiều khả năng call stack không thể thực hiện thêm đoạn mã nào cả. Chương trình lúc đó chạy theo thứ tự đọc -> in -> đọc -> 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 đọc tệp tin không 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ả. Kết quả của hàm bất đồng bộ thường được trả về thông qua một hàm gọi lại, gọi là callback. Từ ES6 trở đi, chúng ta có thêm khái niệm Promise, kết quả của hàm bất đồng bộ còn được trả về trong then của Promise.

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ận kết quả tương tự callback của hàm bất đồng bộ. Nếu không sử dụng then thì dùng hàm callback để nhận kết quả như sau.

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

Thời gian xử lý giảm xuống đáng kể khi thay thế hàm readFileSync thành readFile. Thể hiện như ở sơ đồ dưới đây.

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

Kết luận

Node.js với kiến trúc đơn luồng (single thread) kết hợp cùng cơ chế bất đồng bộ đã giải quyết hiệu quả bài toán xử lý đồng thời mà không gây tắc nghẽn, một thách thức lớn đối với các hệ thống máy chủ truyền thống. Call stack đảm bảo thứ tự thực thi mã theo nguyên tắc vào sau ra trước (LIFO). Sự khác biệt giữa I/O đồng bộ và bất đồng bộ cũng được làm rõ: trong khi I/O đồng bộ chặn toàn bộ tiến trình cho đến khi tác vụ hoàn tất, I/O bất đồng bộ cho phép chương trình tiếp tục thực thi, giảm đáng kể thời gian chờ và tăng khả năng xử lý các yêu cầu lớn.

Ở bài viết tiếp theo, chúng ta hãy cùng nhau tìm hiểu xem JavaScript/Node.js xử lý bất đồng bộ như thế nào nhé.

Cao cấp
Hello

Bí mật ngăn xếp của Blog

Là một lập trình viên, bạn có tò mò về bí mật công nghệ hay những khoản nợ kỹ thuật về trang blog này? Tất cả bí mật sẽ được bật mí ngay bài viết dưới đây. Còn chờ đợi gì nữa, hãy bấm vào ngay!

Là một lập trình viên, bạn có tò mò về bí mật công nghệ hay những khoản nợ kỹ thuật về trang blog này? Tất cả bí mật sẽ được bật mí ngay bài viết dưới đây. Còn chờ đợi gì nữa, hãy bấm vào ngay!

Xem tất cả

Đăng ký nhận thông báo bài viết mới

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.

Bình luận (0)

Nội dung bình luận...