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
  • Từ lâu rồi suy nghĩ làm thế nào để tăng sự hiện diện thương hiệu, cũng như người dùng cho blog. Nghĩ đi nghĩ lại thì chỉ có cách chia sẻ lên mạng xã hội hoặc trông chờ họ tìm kiếm, cho đến khi...

    In cái áo này được cái tắc đường khỏi phải lăn tăn, càng đông càng vui vì hàng trăm con mắt nhìn thấy cơ mà 🤓

    (Có tác dụng thật nha 🤭)

    » Xem thêm
  • Một vòng của sự phát triển nhiều dự án khá là thú vị. Tóm tắt lại trong 3 bước: Thấy một cái gì đó phức tạp -> Làm cho nó đơn giản đi -> Thêm thắt tính năng cho đến khi nó phức tạp... -> Quay trở lại vòng lặp mới.

    Tại sao lại như vậy? Để mình lấy 2 ví dụ cho các bạn thấy.

    Markdown ra đời với mục tiêu tạo ra một định dạng văn bản thô "dễ viết, dễ đọc, dễ dàng chuyển thành một dạng gì đó như HTML". Vì thời đó chẳng ai đủ kiên nhẫn mà vừa ngồi viết vừa thêm định dạng cho văn bản hiển thị ở trên web như thế nào. Ấy vậy mà giờ đây người ta đang "nhồi nhét" hoặc tạo ra các biến thể dựa trên markdown để bổ sung thêm nhiều định dạng mới đến mức... chẳng nhớ nổi hết cú pháp.

    React cũng là một ví dụ. Từ thời PHP, việc khát khao tạo ra một cái gì đó tách biệt hẳn giao diện người dùng và phần xử lý logic chính của ứng dụng thành 2 phần riêng biệt cho dễ đọc, dễ viết. Kết quả là các thư viện UI/UX phát triển rất mạnh mẽ, mang lại khả năng tương tác với người dùng rất tốt, còn phần logic ứng dụng thì nằm ở một máy chủ riêng biệt. Bộ đôi Front-end, Back-end cũng từ đấy mà thịnh hành, không thể thiếu anh bồi bàn REST API. Ấy vậy mà giờ đây React trông cũng không khác biệt gì so với PHP là mấy, kéo theo là cả Vue, Svelte... lại cùng quy tất cả về một mối.

    Cơ mà không phải vòng lặp là xấu, ngược lại vòng lặp này mang tính tiến hoá nhiều hơn là "cải lùi". Nhiều khi lại tạo ra được cái hay hơi cái cũ thế là người ta lại dựa trên cái hay đó để tiếp tục lặp. Nói cách khác là chắc lọc tinh hoa từng tí một tí một á 😁

    » Xem thêm
  • Song song với các dự án chính thức thì thi thoảng mình vẫn thấy các dự án "bên lề" nhằm tối ưu hoặc cải tiến ngôn ngữ theo khía cạnh nào đó. Ví dụ nature-lang/nature là một dự án hướng tới cải tiến Go, mang lại một số thay đổi nhằm giúp cho việc sử dụng Go trở nên thân thiện hơn.

    Nhìn lại mới thấy hao hao JavaScript 😆

    » 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

5 bài học sâu sắc

Mỗi sản phẩm đi kèm với những câu chuyện. Thành công của người khác là nguồn cảm hứng cho nhiều người theo sau. 5 bài học rút ra được đã thay đổi con người tôi mãi mãi. Còn bạn? Hãy bấm vào ngay!

Mỗi sản phẩm đi kèm với những câu chuyện. Thành công của người khác là nguồn cảm hứng cho nhiều người theo sau. 5 bài học rút ra được đã thay đổi con người tôi mãi mãi. Còn bạn? 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...