Ở bài viết trước, mình đã giới thiệu các kiến thức cơ bản về Event Loop - Các cơ chế hoạt động của Event Loop trong JavaScript. Trong bài viết này, mình sẽ tiếp tục giới thiệu về Event Loop trong Nodejs runtime và cung cấp bức tranh hoàn chỉnh nhất về khái niệm này.
1. Giới thiệu Nodejs Runtime
Nodejs runtime là một môi trường giúp bạn thực thi những đoạn mã JS bên ngoài trình duyệt, bao gồm 3 thành phần chính:
JavaScript (JS) library: Bao gồm các thư viện chuẩn như fs
và http
, giúp xử lý file và mạng.
Bindings C/C++: Cho phép JavaScript gọi các thư viện được viết bằng C/C++ để tối ưu hóa hiệu suất và xử lý các tác vụ phức tạp.
Dependencies: Gồm các thành phần quan trọng như: V8, libuv, crypto,… Trong bài viết này, mình sẽ focus vào V8 và libuv.
- V8 Engine: Trình thông dịch mã JS, giúp biên dịch và thực thi mã JS.
- Libuv: Thư viện C++ hỗ trợ xử lý bất đồng bộ, quản lý các sự kiện và hệ thống tệp trong môi trường Nodejs.
2. Cách Nodejs thực thi mã Javascript
Để hiểu cách Nodejs thực thi mã JavaScript, hãy chú ý vào các thành phần chính trong môi trường Nodejs:
2.1 V8 Engine:
- Memory Heap: Lưu trữ dữ liệu của biến, đối tượng trong quá trình thực thi.
- Call Stack: Nơi lưu trữ các task thực thi theo thứ tự. Khi hàm được gọi, sẽ được đẩy vào Call Stack. Khi hàm hoàn thành, sẽ được loại bỏ khỏi Call Stack.
- Global(): Chứa hàm và biến toàn cục có thể truy cập từ bất kỳ đâu trong ứng dụng.
2.2 Libuv:
- Quản lý bất đồng bộ: Libuv là một thư viện C++ được sử dụng để xử lý các phương thức bất đồng bộ, chẳng hạn như: đọc/ghi tệp, xử lý mạng, quản lý các sự kiện,...
- Event loop: Cho phép Nodejs xử lý các sự kiện một cách bất đồng bộ. Khi một tác vụ bất đồng bộ hoàn thành (chẳng hạn như đọc xong một tệp), một sự kiện sẽ được đặt vào hàng đợi để xử lý tiếp.
2.3 Quy trình thực thi
- Khi bạn chạy tập lệnh JavaScript, mã sẽ được nạp vào bộ nhớ.
- V8 Engine biên dịch JS sang mã máy và bắt đầu tiến hành thực thi.
- Các hàm, biến sẽ lưu trữ trong Memory Heap và Call Stack.
- Các tác vụ như: đọc tệp, call API sẽ được gửi đến Libuv để xử lý.
- Khi một tác vụ bất đồng bộ hoàn thành, sự kiện sẽ được đẩy vào queue của event loop.
- Event loop sẽ kiểm tra hàng đợi và xử lý từng sự kiện một cách tuần tự.
2.1. Quá trình thực thi mã đồng bộ trong Nodejs
Đầu tiên bạn hãy xem qua video để có cái nhìn tổng quan về quá trình thực thi mã đồng bộ trong Nodejs trước khi xem đoạn giải thích của mình bên dưới nhé.
- Như các bạn có thể thấy trong video, các tác vụ đồng bộ sẽ được thực thi ngay lập tức.
- Vậy bạn tưởng tượng nếu như có một tác vụ mất nhiều thời gian, chương trình sẽ bị chặn (block), không thể làm bất kỳ việc gì khác cho đến khi tác vụ này hoàn thành.
- Ví dụ: Nếu bạn sử dụng tác vụ đồng bộ để đọc tệp lớn, các task sau sẽ phải đợi cho đến khi việc đọc tệp hoàn tất. Từ đó, bạn sẽ nhận thấy nếu quá nhiều tác vụ đồng bộ sẽ làm chậm chương trình.
=> Để chương trình phản hồi nhanh chóng và phòng tránh điều này, Nodejs khuyến khích sử dụng tác vụ bất đồng bộ.
2.2. Thực thi mã bất đồng bộ trong Nodejs
- Khi bắt đầu quá trình thực thi, chương trình luôn bắt đầu từ phạm vi toàn cục, hàm
global
được đẩy vào Call Stack. - Dòng 1:
console.log('First')
được đẩy vào Call Stack và thực thi ngay, log ra "First". - Dòng 2-4: Vì phương thức
readFile
là một hoạt động bất đồng bộ, nó được chuyển đến Libuv để xử lý. - Dòng 5:
console.log('Third')
được đẩy vào Call Stack và thực thi ngay, in ra 'Third' tại console. - Nhiệm vụ của Event Loop là liên tục kiểm tra Call Stack đến khi phát hiện Call Stack rỗng. Event Loop sẽ đưa task từ Libuv sang Call Stack để thực thi.
- Khi
readFile
hoàn thành, callback sẽ được đẩy vào Call Stack để thực thi, in ra 'Second'. - Kết quả cuối cùng là 'First', 'Third', 'Second'.
3. Libuv và hoạt động bất đồng bộ
- Như mình đã đề cập ở trên thì Libuv là thư viện C++ hỗ trợ xử lý bất đồng bộ, quản lý các sự kiện và hệ thống tệp trong môi trường Nodejs.
- Ở đây, những tác vụ bất đồng bộ khi đi vào Call Stack, chúng sẽ được đẩy sang Libuv để xử lý.
- Những đoạn mã khi được đưa vào đây nó sẽ không đi qua tất cả mà sẽ được phân loại để đưa vào từng loại queue.
- Vậy thì sẽ có những loại queue nào? Để hiểu rõ hơn chúng ta sẽ đi sang vòng đời của Event Loop.
4. Các giai đoạn Event Loop trong Nodejs
4.1 Timer Queue
- Đầu tiên là timer queue, chứa các callback của setTimeout, setInterval, timer queue thực chất là một min-heap, đảm bảo callback có thời gian chờ ngắn nhất sẽ thực thi trước.
4.2 I/O Queue
- Thứ hai là I/O queue, nó sẽ chứa các callback liên quan đến các hoạt động I/O bất đồng bộ, chẳng hạn như đọc/ghi file, các hoạt động mạng,.... Các callback này sẽ được thêm vào queue khi hoạt động I/O hoàn thành.
4.3 Check Queue
- Thứ ba là check queue, sẽ chứa các callback của hàm setImmediate. Các callback này được lên lịch để thực thi ngay sau khi giai đoạn polling của Event Loop kết thúc.
4.4 Close Queue
- Thứ tư là close queue, sẽ chứa các callback liên quan đến việc đóng các tài nguyên bất đồng bộ, chẳng hạn như socket hoặc xử lý file.
4.5 Microtask Queue
Đây là queue (hàng đợi) đặc biệt, được chia thành 2 hàng đợi con: là nextTick queue và promise queue
- Next Tick Queue: Chứa các callback của hàm process.nextTick. Các callback này có độ ưu tiên cao nhất và được thực thi ngay sau khi callback hiện tại kết thúc, trước khi Event Loop chuyển sang bất kỳ giai đoạn nào khác.
- Promise Queue: Chứa các callback của các Promise đã được resolve hoặc reject. Các callback này được thực thi sau khi next tick queue đã được xử lý xong.
Trong 6 queue này không phải đều thuộc Libuv mà chỉ có timer, I/O, check và close là một phần của thư viện libuv, trong khi microtask queue là một phần của Nodejs runtime.
5. Event Loop hoạt động như thế nào
- Nhìn vào hình sơ đồ Event Loop bạn vừa xem qua tại mục 4, bạn có thể nhầm lẫn. Vì vậy, mình sẽ làm rõ về thứ tự ưu tiên của các loại queue.
- Trước tiên, bạn cần hiểu rằng tất cả mã JS đồng bộ sẽ được ưu tiên thực thi trước mã bất đồng bộ.
5.1. Cách thức hoạt động
Trong Event Loop, trình tự thực hiện tuân theo các quy tắc nhất định. Có khá nhiều quy tắc khiến cho bạn phải suy nghĩ, vì vậy hãy cùng điểm qua từng quy tắc theo thứ tự thực thi nhé.
- Mọi callback trong microtask queue được thực thi. Các tác vụ trong nextTick queue được thực thi đầu tiên, sau đó là các tác vụ trong hàng promise queue.
- Sau đó, callback trong timer queue được thực thi.
- Tiếp theo, callback trong I/O queue được thực thi.
- Callback trong microtask queue (nếu có) được thực thi, bắt đầu với nextTick queue và sau đó là promise queue.
- Mọi callback trong check queue được thực thi.
- Callback trong microtask queue (nếu có) được thực thi sau mỗi callback trong check queue. Đầu tiên là các tác vụ trong nextTick queue, sau đó là các tác vụ trong promise queue.
- Mọi callback trong hàng đợi close được thực thi.
- Lần cuối cùng trong vòng lặp, các hàng đợi microtask được thực thi. Đầu tiên là các tác vụ trong hàng đợi nextTick, sau đó là các tác vụ trong hàng đợi promise.
Nếu có callback cần xử lý trong thời điểm này, vòng lặp sẽ được duy trì để chạy thêm một lần nữa và các bước tương tự sẽ được lặp lại. Mặt khác, nếu tất cả các lệnh gọi lại được thực thi, không còn mã nào để xử lý thì Event Loop sẽ thoát.
5.2. Độ ưu tiên
Với thứ tự thực hiện chặt chẽ, Event Loop đảm bảo độ ưu tiên cho các tác vụ khác nhau. Microtask queue luôn được ưu tiên hàng đầu, đảm bảo các tác vụ bất đồng bộ quan trọng như xử lý Promise được thực thi nhanh chóng.
Tuy nhiên, Event Loop cũng rất linh hoạt. Thứ tự giữa Timer queue, I/O queue, Check queue và Close queue sẽ phụ thuộc vào loại tác vụ nào được bắt đầu trước. Nếu đoạn code đầu tiên của bạn là một setTimeout, thì Timer queue sẽ được ưu tiên.
Điểm đặc biệt cần lưu ý:
- Timer và Check queue: Nếu hai queue này được đặt ngoài chu trình I/O, độ ưu tiên của chúng là không xác định.
- Timer và Check queue trong I/O: Khi nằm trong chu trình I/O, Check queue luôn được ưu tiên hơn Timer queue.
- Trường hợp đầu tiên là timer queue và check queue nằm ngoài chu trình I/O:
- Còn khi timer queue và check queue được đặt trong chu trình của I/O queue thì check queue luôn có độ ưu tiên cao hơn, dưới đây là ví dụ:
6. Kết luận
Event Loop là thành phần cốt lõi trong Nodejs, đảm bảo quản lý và thực thi các tác vụ bất đồng bộ một cách hiệu quả mà không làm gián đoạn luồng chính.
Hiểu rõ về cơ chế và thứ tự ưu tiên của các phase trong vòng lặp sự kiện, rất quan trọng để xây dựng các ứng dụng Nodejs có hiệu suất cao và khả năng đáp ứng tốt.
Nếu bạn quan tâm các chủ đề khác liên quan, bạn có thể tham khảo một số bài viết dưới đây:
Bài viết liên quan
Cách Discord Lưu Trữ Hàng Nghìn Tỷ Tin Nhắn Với ScyllaDB
Dec 06, 2024 • 9 min read
Idempotent Consumer: Xử lý thông điệp trùng lặp trong Microservices
Dec 04, 2024 • 7 min read
Hướng dẫn tích hợp Redux và React Query trong dự án React Vite
Nov 22, 2024 • 8 min read
Giới thiệu Kiến trúc Backend for Frontend (BFF)
Nov 16, 2024 • 10 min read
Flask là gì? Hướng dẫn tạo Ứng dụng Web với Flask
Nov 15, 2024 • 7 min read
Webhook là gì? So sánh Webhook và API
Nov 15, 2024 • 8 min read