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
  • Void - cái tên mình đã nhắc đến từ cách đây khá lâu. Từ đợt mà continue.dev mới nổi lên á. Nó tương tự như Cursor và Windsurf, mới hôm nay họ đã phát hành phiên bản Beta và cho phép mọi người tải xuống.

    Điểm mạnh thì đây là nguồn mở, miễn phí, dùng các mô hình miễn phí cục bộ trên máy qua Ollama hoặc LM Studio... Không thích thì cắm API của bên khác vào cũng được. Mình vừa dùng thử thì thấy khả năng gợi ý và khung chat khá tương đồng với Cursor, có cả tính năng Agent luôn nhé 👏. Hoạt động ổn định hơn continue.dev (lần cuối dùng), việc còn lại là chọn mô hình xịn xịn tí 🤤

    » Xem thêm
  • Zed mới đây đã giới thiệu thêm tính năng Agent - tương tự như Agent trong Cursor hay Write trong Windsurf và họ gọi nó là The Fastest AI Code Editor.

    Cũng nhanh thật đấy vì Zed viết bằng Rust. Cơ mà chiến lược của họ có vẻ thay đổi, tập trung vào AI thay vì phát triển kho tiện ích mở rộng vốn đang có rất ít, không thể cạnh tranh được với VSCode 🥶

    Zed: The Fastest AI Code Editor

    » Xem thêm
  • Ngay sau thông tin OpenAI đạt được thoả thuận mua lại Windsurf với giá 3 tỉ đô thì ngày hôm nay Cursor đã miễn phí 1 năm dùng bản Pro cho sinh viên. Chaaaaà 🤔

    OpenAI Reaches Agreement to Buy Startup Windsurf for $3 Billion

    Cursor for Students | Cursor - The AI Code Editor

    » 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

Tôi & khao khát "chơi chữ"

Bạn đã thử viết? Và rồi thất bại hoặc chưa ưng ý? Tại 2coffee.dev chúng tôi đã có quãng thời gian chật vật với công việc viết. Đừng nản chí, vì giờ đây chúng tôi đã có cách giúp bạn. Hãy bấm vào để trở thành hội viên ngay!

Bạn đã thử viết? Và rồi thất bại hoặc chưa ưng ý? Tại 2coffee.dev chúng tôi đã có quãng thời gian chật vật với công việc viết. Đừng nản chí, vì giờ đây chúng tôi đã có cách giúp bạn. Hãy bấm vào để trở thành hội viên 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...