Nghiên cứu về Thread trong Nodejs
Node.js là một môi trường chạy phổ biến để thực thi mã JavaScript bên ngoài trình duyệt, chủ yếu được biết đến với kiến trúc không chặn (non-blocking) và hướng sự kiện (event-driven). Mặc dù Node.js vốn dĩ là đơn luồng (single-threaded), nhưng nó cung cấp nhiều cơ chế khác nhau để tận dụng khả năng đa luồng nhằm tối ưu hiệu suất.
Bản thân Node.js là một môi trường đa luồng (multithreaded) và sử dụng các luồng ẩn thông qua thư viện libuv. Thư viện này xử lý các thao tác I/O như đọc tệp từ đĩa hoặc gửi yêu cầu mạng. Nhờ các luồng ẩn này, Node.js cung cấp các phương thức bất đồng bộ (asynchronous) giúp mã của bạn có thể thực hiện các yêu cầu I/O mà không làm chặn luồng chính.
Mặc dù Node.js có một số luồng ẩn, nhưng bạn không thể sử dụng chúng để xử lý các tác vụ nặng về CPU, chẳng hạn như tính toán phức tạp hoặc thay đổi kích thước hình ảnh. Cách duy nhất để tăng tốc một tác vụ tiêu tốn nhiều CPU là nâng cao tốc độ xử lý của bộ vi xử lý.
Trong những năm gần đây, các máy tính được trang bị nhiều Core hơn. Để tận dụng những core này, Node.js đã giới thiệu mô-đun worker-threads, cho phép bạn tạo luồng (thread) và thực thi nhiều tác vụ JavaScript song song. Khi một luồng hoàn thành tác vụ, nó sẽ gửi một thông báo chứa kết quả về luồng chính, giúp kết quả này có thể được sử dụng trong các phần khác của mã.
Lợi ích của việc sử dụng worker threads là các tác vụ nặng về CPU sẽ không làm chặn luồng chính, giúp ứng dụng vẫn có thể xử lý các tác vụ khác một cách mượt mà. Ngoài ra, bạn có thể chia nhỏ và phân phối một tác vụ cho nhiều worker, tối ưu hóa hiệu suất và tận dụng tối đa tài nguyên của CPU.
Sự khác biệt giữa Process và Thread
Process | Thread |
---|---|
* Một tiến trình là một chương trình đang chạy, có không gian bộ nhớ và tài nguyên riêng. |
- Các tiến trình không chia sẻ bộ nhớ với nhau, giao tiếp giữa các tiến trình cần thông qua IPC (Inter-Process Communication).
- Việc tạo một tiến trình mới khá tốn kém vì nó cần cấp phát bộ nhớ và tài nguyên riêng. | Một luồng là đơn vị nhỏ nhất trong một tiến trình, có thể chạy song song với các luồng khác trong cùng một tiến trình.
- Các luồng trong cùng một tiến trình chia sẻ bộ nhớ và tài nguyên chung, giúp giao tiếp giữa các luồng nhanh hơn.
- Tạo một luồng mới nhẹ hơn so với tạo một tiến trình mới. | Process là một chương trình độc lập có bộ nhớ riêng, giao tiếp chậm hơn nhưng ổn định.
Thread là một đơn vị nhỏ hơn trong process, chia sẻ tài nguyên nên nhanh hơn nhưng dễ bị lỗi nếu không kiểm soát tốt việc truy cập bộ nhớ chung.
Process
Đó là một chương trình đang chạy trong hệ điều hành. Nó có bộ nhớ riêng và không thể nhìn thấy hoặc truy cập bộ nhớ của các chương trình khác đang chạy. Nó cũng có một con trỏ lệnh, cho biết lệnh nào đang được thực thi trong chương trình. Chỉ có thể thực thi một tác vụ tại một thời điểm. Sử dụng child_process để tạo mới 1 process
const { fork } = require('child_process');
// Fork a new Node.js process
const child = fork('child.js');
// Send a message to the child process
child.send({ hello: 'world' });
// Receive messages from the child process
child.on('message', (message) => {
console.log('Received message from child:', message);
});
child.js
process.on('message', (message) => {
console.log('Received message from parent:', message);
process.send({ reply: 'hello parent' });
});
Trong ví dụ này:
Script cha tạo một tiến trình mới bằng cách sử dụng fork('child.js'). Tiến trình cha và tiến trình con giao tiếp với nhau thông qua tin nhắn. Mỗi tiến trình có không gian bộ nhớ riêng, không chia sẻ bộ nhớ với nhau.
Thread
Các Thread cũng giống như Process: Chúng có con trỏ riêng và thực thi một tác vụ JS tại một thời điểm. Tuy nhiên Thread không có bộ nhớ riêng mà nằm trong bộ nhớ của Process. Một Process có thể chứa nhiều Thread. Các Thread có thể giao tiếp với nhau thông qua truyền tin nhắn hoặc chia sẻ dữ liệu trong bộ nhớ của Process Khi thực thi các (threads), chúng có hành vi tương tự như (processes).
- Trên hệ thống một lõi (single-core), nếu có nhiều luồng chạy cùng lúc, hệ điều hành sẽ chuyển đổi giữa các luồng theo từng khoảng thời gian nhất định, giúp mỗi luồng có cơ hội thực thi trực tiếp trên CPU duy nhất.
- Trên hệ thống đa lõi (multi-core), hệ điều hành sẽ lập lịch cho các luồng trên tất cả các lõi và thực thi mã JavaScript đồng thời.
- Nếu số lượng luồng được tạo ra nhiều hơn số lõi có sẵn, mỗi lõi sẽ phải xử lý nhiều luồng cùng lúc (chạy đồng thời nhưng không thực sự song song).
Sử dụng worker_threads để tạo 1 thread mới
const { Worker, isMainThread, parentPort, workerData } = require('worker_threads');
if (isMainThread) {
// This is the main thread
const worker = new Worker(__filename, {
workerData: { start: 1, end: 1e6 }
});
// Listen for messages from the worker
worker.on('message', (result) => {
console.log(`Result from worker: ${result}`);
});
worker.on('error', (error) => {
console.error('Worker error:', error);
});
worker.on('exit', (code) => {
if (code !== 0) console.error(`Worker stopped with exit code ${code}`);
});
} else {
// This is the worker thread
const { start, end } = workerData;
let sum = 0;
for (let i = start; i <= end; i++) {
sum += i;
}
// Send the result back to the main thread
parentPort.postMessage(sum);
}
Trong ví dụ này: Luồng chính (main thread) tạo một worker thread bằng cách sử dụng new Worker(filename, { workerData: { start: 1, end: 1e6 } }). Worker thread thực hiện một phép tính và gửi kết quả về lại cho luồng chính. Cả hai luồng chia sẻ cùng một không gian bộ nhớ, giúp việc truyền dữ liệu diễn ra hiệu quả hơn. Bây giờ bạn đã hiểu về threads, chúng ta có thể tiếp tục với vấn đề chính.
Hiểu về các luồng ẩn trong NodeJS**
NodeJS thực sự cung cấp các luồng bổ sung, vì vậy nó được coi là đa luồng. NodeJS triển khai thư viện libuv, thư viện này cung cấp bốn luồng bổ sung cho một tiến trình Node.js. Các luồng này giúp xử lý các tác vụ I/O (nhập/xuất) một cách riêng biệt. Khi một tác vụ I/O hoàn thành, vòng lặp sự kiện (event loop) sẽ đưa callback liên quan đến tác vụ I/O đó vào hàng đợi microtask. Tuy nhiên, callback này không chạy song song, mà chỉ được thực thi khi call stack của luồng chính đã trống. Điều này có nghĩa là:
- Tác vụ I/O (ví dụ: đọc file hoặc gửi yêu cầu mạng) được xử lý nhờ các luồng bổ sung.
- Sau khi hoàn tất, callback của tác vụ sẽ chạy trong luồng chính. Bên cạnh 4 luồng từ libuv, V8 engine cũng cung cấp 2 luồng bổ sung để xử lý các tác vụ như thu gom rác tự động (garbage collection).
Như vậy, tổng số luồng trong một tiến trình Node.js là 7 luồng, bao gồm:
- 1 luồng chính (main thread)
- 4 luồng của Node.js (từ libuv, xử lý I/O)
- 2 luồng của V8 engine (xử lý thu gom rác, tối ưu hóa bộ nhớ)
Điều này giúp Node.js có thể xử lý hiệu quả các tác vụ bất đồng bộ, mà không làm chặn luồng chính.
Tách một tác vụ nặng CPU bằng mô-đun worker-threads
Trong phần này, bạn sẽ tách một tác vụ nặng CPU sang một luồng khác bằng cách sử dụng mô-đun worker-threads để tránh làm nghẽn luồng chính.Để thực hiện điều này, bạn sẽ tạo một tệp worker.js chứa tác vụ nặng CPU.Trong tệp index.js, bạn sẽ sử dụng module worker-threads để khởi tạo luồng và bắt đầu tác vụ trong tệp worker.js, chạy song song với luồng chính. Khi tác vụ hoàn thành, luồng công nhân sẽ gửi một tin nhắn chứa kết quả về luồng chính.
Nếu hệ thống hiển thị hai hoặc nhiều lõi CPU, bạn có thể tiếp tục với bước này.
Tiếp theo, hãy tạo và mở tệp worker.js
const { parentPort } = require("worker_threads");
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
parentPort.postMessage(counter);
multi-threading_demo/worker.js
Ở đây, bạn gọi phương thức postMessage() của lớp parentPort, phương thức này sẽ gửi một tin nhắn đến luồng chính chứa kết quả của tác vụ tiêu tốn CPU được lưu trong biến counter.
Mở tệp index.js trong trình soạn thảo văn bản của bạn.
const express = require("express");
const { Worker } = require("worker_threads");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
function calculateCount() {
return new Promise((resolve, reject) => {
let counter = 0;
for (let i = 0; i < 20_000_000_000; i++) {
counter++;
}
resolve(counter);
});
}
app.get("/blocking", async (req, res) => {
const counter = await calculateCount();
res.status(200).send(`result is ${counter}`);
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
Tiếp theo, trong callback của app.get("/blocking"), thêm đoạn mã sau để khởi tạo thread:
multi-threading_demo/index.js
const express = require("express");
const { Worker } = require("worker_threads");
...
app.get("/blocking", async (req, res) => {
const worker = new Worker("./worker.js");
worker.on("message", (data) => {
res.status(200).send(`result is ${data}`);
});
worker.on("error", (msg) => {
res.status(404).send(`An error occurred: ${msg}`);
});
});
...
Bây giờ, toàn bộ file multi-threading_demo/index.js sẽ trông như sau:
- Import worker_threads và lấy class Worker.
- Trong callback của app.get("/blocking"), tạo một instance của Worker, chỉ định file worker.js làm đối số, giúp tạo một thread mới chạy trên một core khác.
- Lắng nghe sự kiện "message" để nhận kết quả từ worker.js, sau đó trả về phản hồi cho người dùng.
- Lắng nghe sự kiện "error" để xử lý lỗi và trả về mã lỗi 404 cùng thông tin lỗi.
const express = require("express");
const { Worker } = require("worker_threads");
const app = express();
const port = process.env.PORT || 3000;
app.get("/non-blocking/", (req, res) => {
res.status(200).send("This page is non-blocking");
});
app.get("/blocking", async (req, res) => {
const worker = new Worker("./worker.js");
worker.on("message", (data) => {
res.status(200).send(`result is ${data}`);
});
worker.on("error", (msg) => {
res.status(404).send(`An error occurred: ${msg}`);
});
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
Save and run the server:
node index.js
run http://localhost:3000/blocking http://localhost:3000/non-blocking
kiểm tra kết quả
All rights reserved