Deep Dive into Node.js

 

Chắc hẳn bạn đã nghe đến Node.js và biết đây là một môi trường lập trình không thể phổ biến hơn khi xây dựng ứng dụng web, API, và nhiều thứ khác nữa. Nhưng để thực sự khai thác hết sức mạnh của Node.js, chúng ta cần đào sâu vào bên trong nó để hiểu rõ hơn về những điều đang diễn ra.

 

Node.js không chỉ là một công cụ phổ biến, mà là một cánh cửa mở ra thế giới của những cơ hội không ngừng. Trong bài viết này, chúng ta sẽ cùng nhau khám phá những bí mật đằng sau những dòng mã, những điều mà bạn có thể đã bỏ qua trong quá trình sử dụng. Đồng hành cùng tôi để tìm hiểu cách tận dụng tối đa sức mạnh ẩn sau Node.js và làm thế nào để bạn có thể trở thành một nhà phát triển hiệu quả trong thế giới đầy thách thức này.

 

Khám Phá Đặc Điểm Nổi Bật của Node.js

 

Bạn có biết Node.js là gì không? Theo định nghĩa của chính nhà sáng tạo Ryan Dahl, Node.js là một “bộ sưu tập các thư viện chạy trên công cụ V8, cho phép chúng ta chạy mã JavaScript trên máy chủ.” Còn Wikipedia thì mô tả nó như một “môi trường thực thi JavaScript nguồn mở, đa nền tảng thực thi mã bên ngoài trình duyệt.”

 

Nhìn chung, Node.js là một runtime mạnh mẽ cho phép thực thi JavaScript không chỉ trong trình duyệt mà còn bên ngoài nó. Tuy nhiên, đây không phải là lần đầu tiên mà JavaScript xuất hiện trên máy chủ. Vào năm 1995, Netscape đã triển khai LiveScript (JavaScript đầu tiên) trên máy chủ thông qua một sản phẩm được gọi là Netscape Enterprise Server.

 

Vào năm 2009, Node.js chính thức xuất hiện do sự đóng góp của Ryan Dahl và sự tài trợ của Joyent. Nó ra đời để khắc phục những hạn chế của Apache HTTP Server, máy chủ web phổ biến nhất thời đó trong việc xử lý nhiều kết nối đồng thời và để giải quyết vấn đề mã tuần tự khiến quá trình xử lý trở nên tắc nghẽn.

 

Lịch sử hình thành Node.js được bắt đầu tại JSConf EU vào ngày 8 tháng 11 năm 2009, nơi Node.js kết hợp V8 và event loop từ thư viện mới là libuv, cùng với API I/O cấp thấp.

 

Vì JavaScript là thành phần cấp cao nhất của Node.js, chúng ta hãy bắt đầu bằng cách hỏi làm thế nào để đoạn mã của chúng ta chạy, JavaScript hoạt động như thế nào?

Hầu hết mọi người đều biết một vài từ và cứ lặp lại chúng:

  • JavaScript là đơn luồng.
  • JavaScript sử dụng hàng đợi callback.
  • Có một event loop nào đó.

Nhưng bạn đã đào sâu hơn vào những câu hỏi này chưa?

  • Đơn luồng thực sự là gì?
  • Cách thức hoạt động của hàng đợi callback như thế nào? Có phải chỉ có một hàng đợi duy nhất không?
  • Event loop là gì? Nó hoạt động như thế nào? Ai cung cấp nó? Nó có phải là một phần của JS không?

Đơn luồng

Đơn luồng có nghĩa là thời gian chạy Javascript chỉ thực thi một đoạn mã (hoặc câu lệnh) một cách đồng bộ tại bất kỳ thời điểm nào. Nó chỉ có một call stack và head memory. Nhưng sau đó thời gian chạy xử lý nhiều hoạt động không đồng bộ một cách hiệu quả như thế nào? Node.js xử lý nó một cách hiệu quả bằng cách sử dụng phương pháp hướng sự kiện.

I/O(input/output) là hoạt động chậm nhất trong số các hoạt động cơ bản của máy tính. Nó liên quan đến việc truy cập dữ liệu trên đĩa, đọc và ghi tệp, chờ đầu vào của người dùng, thực hiện cuộc gọi mạng, thực hiện một số thao tác cơ sở dữ liệu, v.v. Nó thêm độ trễ giữa thời điểm yêu cầu được gửi đến thiết bị và thời điểm thao tác hoàn tất.

Trong lập trình blocking I/O truyền thống, lệnh gọi hàm tương ứng với yêu cầu I/O sẽ chặn việc thực thi luồng cho đến khi thao tác hoàn tất. Vì vậy, bất kỳ máy chủ web nào được triển khai bằng cách blocking I/O sẽ không thể xử lý nhiều kết nối trong cùng một luồng. Giải pháp cho vấn đề này là sử dụng một luồng (hoặc quy trình) riêng biệt để xử lý từng kết nối đồng thời.

Hầu hết các hệ điều hành hiện đại đều hỗ trợ một cơ chế khác để truy cập tài nguyên được gọi là non-blocking I/O, trong đó lệnh gọi hệ thống luôn quay trở lại ngay lập tức mà không cần đợi thao tác I/O hoàn tất. Để xử lý các tài nguyên không chặn đồng thời một cách hiệu quả, nó sử dụng một cơ chế được gọi là giao diện phân kênh sự kiện đồng bộ hoặc giao diện thông báo sự kiện. Kênh sự kiện đồng bộ theo dõi nhiều tài nguyên và trả về một sự kiện mới (hoặc tập hợp các sự kiện) khi một thao tác đọc hoặc ghi được thực hiện trên một trong các tài nguyên đó hoàn tất. Ưu điểm ở đây là kênh sự kiện đồng bộ là đồng bộ nên nó chặn cho đến khi có sự kiện mới cần xử lý.

Điều này cho phép chúng ta xử lý nhiều thao tác I/O bên trong một luồng, được thực hiện bằng cách sử dụng phân kênh. Phân kênh cho phép chúng ta xử lý nhiều tài nguyên chỉ bằng một luồng.

Hãy xem xét ví dụ dưới đây:

Ở đây việc đợi trước cửa bếp hay đợi khách đang chọn đồ ăn “Blocking” toàn bộ công suất của người phục vụ. Theo nghĩa của các hệ thống Điện toán, nó có thể chờ phản hồi IO hoặc DB hoặc bất cứ thứ gì chặn toàn bộ luồng, mặc dù luồng đó có khả năng hoạt động khác trong khi chờ đợi.

Còn cách non-blocking hoạt động:

Trong hệ thống non-blocking, người phục vụ chỉ nhận order và phục vụ, không đợi ở đâu cả. Anh ấy chia sẻ số điện thoại di động của mình để gọi lại khi họ hoàn tất đơn đặt hàng. Tương tự, anh ấy chia sẻ số điện thoại của mình với Kitchen để gọi lại khi đơn hàng sẵn sàng phục vụ.

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:

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:

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:

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:

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!

Event Loop

Event Loop là thứ cho phép node.js thực hiện các tác vụ I/O không đồng bộ, mặc dù trên thực tế Javascript là đơn luồng bằng cách giảm tải các hoạt động cho nhân hệ điều hành bất cứ khi nào có thể.

Vì hầu hết các kernel hiện đại là đa luồng, chúng có thể xử lý nhiều tác vụ thực thi ở chế độ nền (background). Khi một trong những task này hoàn thành, kernel sẽ thông báo cho node.js để hàm callback đính kèm có thể được thêm vào hàng đợi poll và chờ được thực thi.

Event Loop hoạt động như thế nào?

Khi node.js khởi động, nó khởi tạo Event Loop, xử lý tập lệnh đầu vào được cung cấp (hoặc REPL) có thể bao gồm việc thực hiện các hàm không đồng bộ, schedule timers hoặc process.nextTick(), sau đó bắt đầu xử lý Event Loop.

Sơ đồ sau đây cho thấy một cái nhìn tổng quan đơn giản về thứ tự hoạt động của Event Loop:

Mỗi pha có một hàng đợi FIFO chứa các hàm callbacks. Mỗi giai đoạn đều có một nhiệm vụ riêng, nhưng nói chung khi Event Loop bước vào một giai đoạn nhất định, nó sẽ xử lý bất kỳ dữ liệu nào cho giai đoạn đó, sau đó thực hiện các hàm callbacks trong hàng đợi của pha đó cho đến khi hết hoặc đạt đến giới hạn thực thi. Tiếp đến Event Loop sẽ chuyển sang các giai đoạn tiếp theo.

Sau khi tất cả các tác vụ trong một giai đoạn được thực thi, vòng lặp sự kiện sẽ chuyển sang giai đoạn tiếp theo. Vòng quay lại tiếp tục đến giai đoạn kế tiếp và xử lý các tác vụ trong đó.

Vì mỗi pha có thể có một số lượng lớn các hàm callbacks chờ được xử lý thế nên một số callback của các hàm timers (bộ đếm thời gian) có thể sẽ có thời gian chờ thực hiện lâu hơn là so với ngưỡng ban đầu đặt ra, ngưỡng thời gian ban đầu chỉ đảm bảo thời gian chờ ngắn nhất chứ không phải là thời gian chờ chính xác.

Giữa mỗi lần lặp của Event Loop, Node.js sẽ kiểm tra xem nó có đang đợi bất kỳ I/O không đồng bộ hoặc timers nào không và thoát nếu không còn gì.

Chi tiết các pha (phase) của Event Loop

Timers

Một timers (bộ đếm thời gian) chỉ định ngưỡng mà sau đó một hàm callback có thể được thực hiện. Hàm callback của timers sẽ chạy sớm nhất có thể sau khi lượng thời gian được chỉ định trôi qua. Tuy nhiên, chúng cũng có thể bị delay trong một khoảng thời gian nào đó.

Lưu ý: Về mặt kỹ thuật, poll kiểm soát khi timers được thực thi.

Ví dụ: Giả sử chúng ta thiết lập một hàm setTimeout() được thực thi sau 100ms, sau đó chạy một hàm someAsyncOperation thực hiện việc đọc một file không đồng bộ mất 95ms:

Khi Event Loop bước vào giai đoạn poll, nó có một hàng đợi trống (fs.readFile() chưa hoàn thành), vì vậy nó sẽ đợi số ms còn lại cho đến khi đạt đến ngưỡng của bộ định thời sớm nhất. Trong khi chờ 95 ms qua đi, fs.readFile() đọc xong và hàm callback của nó mất 10ms để hoàn thành sẽ được thêm vào hàng đợi của poll và được thực thi. Khi hàm callback thực thi xong, không còn callback nào trong hàng đợi, do đó Event Loop sẽ thấy rằng ngưỡng của bộ định thời sớm nhất đã đạt đến sau đó kết thúc lại giai đoạn bộ định thời để thực hiện lệnh gọi lại của bộ định thời. Trong ví dụ này, bạn sẽ thấy rằng tổng thời gian trễ giữa bộ đếm thời gian được lập lịch và cuộc gọi lại của nó được thực thi sẽ là 105ms.

Lưu ý: Để ngăn giai đoạn thăm dò làm đói vòng lặp sự kiện, libuv (thư viện C triển khai Event Loop của node.js) cũng có giá trị tối đa (phụ thuộc vào hệ thống) trước khi nó dừng polling nhiều sự kiện hơn.

Pending callbacks

Giai đoạn này thực hiện các hàm callback đối với một số hoạt động của hệ thống, chẳng hạn như các loại lỗi TCP. Ví dụ: nếu socket TCP nhận được ECONNREFUSED khi cố gắng kết nối, một số hệ thống *nix muốn đợi để báo lỗi. Nó sẽ được đưa vào hàng đợi này để chờ được thực thi.

Poll

Poll có hai chức năng chính:

  • Tính toán thời gian nó sẽ chặn và thăm dò các sự kiện I/O, sau đó:
  • Xử lý các sự kiện trong hàng đợi poll

Khi Event Loop bước vào giai đoạn poll và không có các callback của timers nào, một trong hai trường hợp sau sẽ xảy ra:

  • Nếu hàng đợi poll không trống, Event Loop sẽ lặp lại qua các hàm callback của nó và thực hiện lần lượt chúng cho đến khi hàng đợi hết hoặc đạt đến giới hạn của hệ thống.
  • Nếu hàng đợi poll trống, một trong hai trường hợp nữa sẽ xảy ra:
  • Nếu các tập lệnh đã được lên lịch trước bởi setImmediate(), Event Loop sẽ kết thúc giai đoạn poll và tiếp tục đến giai đoạn check để thực thi các tập lệnh đã được lên lịch đó.
  • Nếu các tập lệnh chưa được lên lịch trước bởi setImmediate(), Event Loop sẽ đợi các hàm callbacks được thêm vào hàng đợi, sau đó thực thi chúng ngay lập tức.

Khi hàng đợi poll trống, Event Loop sẽ kiểm tra xem có bộ đếm thời gian nào đạt đến ngưỡng được thực thi. Nếu một hoặc nhiều cái đã sẵn sàng, Event Loop sẽ quay trở lại giai đoạn timers để thực hiện các hàm callbacks đó.

Check

Giai đoạn này cho phép chúng ta thực hiện các hàm callbacks ngay sau khi giai đoạn poll hoàn thành. Nếu giai đoạn poll đang có hàng đợi trống và có các tập lệnh đã được lên lịch trước bởi setImmediate(), Event Loop có thể tiếp tục đến giai đoạn này thay vì phải đợi.

setImmediate() là một bộ đếm thời gian đặc biệt chạy trong một giai đoạn riêng biệt của Event Loop. Nó sử dụng một API libuv để lập lịch các hàm callbacks thực thi sau khi giai đoạn poll hoàn thành.

Nói chung, khi các đoạn mã được thực thi, Event Loop cuối cùng sẽ đến giai đoạn poll – nơi nó sẽ đợi các kết nối đến, request, v.v… Tuy nhiên, nếu một hàm callback đã được lên lịch bởi setImmediate() và giai đoạn poll vào trạng thái nhàn rỗi, nó sẽ kết thúc và tiếp tục đến giai đoạn check hơn là chờ đợi các sự kiện của poll.

Close callback

Nếu một socket hoặc handle bị đóng đột ngột (ví dụ socket.destroy()), sự kiện ‘close’ sẽ được phát ra trong giai đoạn này. Nếu không, nó sẽ được phát ra thông qua process.nextTick().

Ngoài những thứ này, còn có hai hàng đợi microtask đặc biệt có thể được thêm các lệnh gọi lại vào chúng trong khi một giai đoạn đang chạy.

  • Hàng đợi microtask đầu tiên xử lý các lệnh callbacks được đăng ký bằng process.nextTick().
  • Hàng đợi microtask thứ hai xử lý các promise reject hoặc resolve.

Thứ tự ưu tiên và cách thực thi các tác vụ trong Node.js

  • Các tác vụ siêu nhỏ (microtask) được ưu tiên hơn các tác vụ thông thường trong cùng một giai đoạn. Tưởng tượng như có hai hàng xếp riêng, hàng ưu tiên sẽ được xử lý trước.
  • Trong hàng đợi tác vụ siêu nhỏ, các tác vụ “next tick” được chạy trước các tác vụ “promise”. Trong hàng ưu tiên cũng có sự sắp xếp riêng, giống như có hai làn ưu tiên.

Tổng Kết

Chúng ta đã cùng nhau mở cánh cửa khám phá về Node.js trong bài viết này, một môi trường thực thi JavaScript trên nền tảng máy chủ mà không chỉ là một công cụ đơn thuần, mà còn là một cảm giác của sự mạnh mẽ và đổi mới.

Node.js không chỉ giúp chúng ta chạy mã JavaScript hiệu quả trên máy chủ, mà còn đem lại sức mạnh của non-blocking I/O và sự linh hoạt trong xử lý sự kiện. Điều này mở ra một thế giới mới của khả năng và hiệu suất khi xây dựng ứng dụng web và API.

377 lượt xem