[Node.js] Vượt rào "Đơn luồng" với Child Process API: Bơm thêm cơ bắp cho Server
Chào anh em, lại là mình đây.
Ở bài trước, mình đã cùng anh em bóc trần sự thật về Event Loop và cách mà Node.js (hay JavaScript nói chung) "lươn lẹo" để xử lý hàng nghìn request I/O cùng lúc dù chỉ mang tiếng là kẻ "đơn luồng" (single-thread). Mọi thứ nghe có vẻ rất hoàn hảo, cho đến khi... anh em bắt nó làm toán.
Đúng vậy, Event Loop rất giỏi việc "nhờ vả" (chờ database query, chờ đọc ghi file, chờ API response), nhưng nó lại cực kỳ sợ những công việc đòi hỏi "cơ bắp" thuần túy của CPU (CPU-intensive).
Giả sử anh em có một task cần resize hàng loạt ảnh độ phân giải cao, convert video, mã hóa dữ liệu, hay parse một file JSON nặng vài GB. Nếu anh em ném đống code đó vào Call Stack của Node.js, luồng chính sẽ lập tức bị "tắc đường". Toàn bộ các request khác của user gửi đến server sẽ bị treo đứng im lìm chờ đợi.
Để cứu vãn tình huống này, Node.js cung cấp cho chúng ta một món vũ khí tối thượng: Child Process API.
Child Process là gì?
Hiểu một cách dân dã: Thay vì thằng sếp (Main Process của Node.js) tự xắn tay áo lên làm cái việc nặng nhọc kia, nó sẽ nhấc máy gọi điện thuê mấy thằng thợ phụ (Child Processes) ở bên ngoài. Mấy thằng thợ này sẽ dùng một luồng riêng, một vùng nhớ riêng do Hệ điều hành cấp phát để cày cuốc. Sếp chỉ việc ngồi uống trà, khi nào thợ làm xong báo cáo lại thì sếp lấy kết quả trả về cho user.
Trong Node.js, module child_process cung cấp cho chúng ta 4 phương thức chính để đẻ ra các "thợ phụ". Tuy nhiên, mỗi thằng thợ lại có một đặc điểm riêng mà anh em cần nắm rõ để không bị bóp team.
exec()- Thằng thợ "gom củi 3 năm thiêu 1 giờ"
Thằng exec này sẽ mở ra hẳn một cái Shell (như Bash trên Linux hoặc CMD trên Windows) rồi gõ lệnh vào đó. Điểm đặc biệt của nó là nó sẽ đợi lệnh chạy xong xuôi tất cả, gom toàn bộ kết quả vào một cái Buffer, rồi mới trả về cho anh em một cục duy nhất thông qua callback.
const { exec } = require('child_process');
exec('ls -la', (error, stdout, stderr) => {
if (error) {
console.error(`Lỗi rồi: ${error.message}`);
return;
}
console.log(`Kết quả: ${stdout}`);
});
Kinh nghiệm xương máu: Chỉ xài exec cho những lệnh trả về ít dữ liệu (ví dụ check status, lấy version, list một thư mục nhỏ). Tại sao? Vì cái Buffer mặc định của nó chỉ chịu được 200KB. Nếu lệnh của anh em in ra quá nhiều text, nó sẽ ném ra cái lỗi huyền thoại Error: maxBuffer exceeded và crash luôn.
2. spawn() - Thằng thợ "nước chảy đá mòn"
Đây mới là chân ái khi anh em phải xử lý khối lượng dữ liệu khổng lồ. Khác với exec, spawn không gom data lại thành một cục. Nó trả dữ liệu về liên tục dưới dạng Stream (từng chunk nhỏ). Dữ liệu sinh ra tới đâu, nó bắn về tới đó.
const { spawn } = require('child_process');
// Ví dụ: gọi lệnh find để quét toàn bộ ổ cứng
const child = spawn('find', ['/']);
child.stdout.on('data', (chunk) => {
console.log(`Nhận được một khúc data: ${chunk}`);
});
child.on('close', (code) => {
console.log(`Tiến trình đã xong với code ${code}`);
});
Vì xài Stream, spawn không bao giờ bị giới hạn bộ nhớ (no memory limit) như exec. Giả sử hệ thống anh em cần một module tính toán cực kỳ phức tạp và đòi hỏi tốc độ bàn thờ. Anh em hoàn toàn có thể viết một script tối ưu bằng Golang (build ra file binary executable), sau đó dùng spawn trong Node.js để gọi file thực thi của Golang đó lên xử lý, rồi hứng kết quả trả về. Vừa tận dụng được I/O Non-blocking của Node, vừa xài ké được tốc độ xử lý CPU của Go. Quá mượt!
3. execFile() - Phiên bản an toàn của exec
Thằng này cơ chế gom data y hệt như exec (tức là vẫn có nguy cơ tràn Buffer). Nhưng điểm khác biệt là nó không mở Shell. Nó gọi trực tiếp vào file thực thi luôn.
Nhờ không phải khởi tạo Shell, execFile tốn ít tài nguyên và chạy nhanh hơn exec một chút, đồng thời an toàn hơn trước các rủi ro bảo mật kiểu Shell Injection (ví dụ user cố tình truyền tham số && rm -rf /).
4. fork() - Clone nhẫn thuật của Node.js
Nếu 3 thằng trên thường dùng để chạy các script ngôn ngữ khác hoặc lệnh hệ thống, thì fork sinh ra là để chạy các file .js.
Khi anh em xài fork('script.js'), nó thực chất là gọi một lệnh spawn nhưng dành riêng cho Node. Điểm bá đạo của fork là nó thiết lập sẵn một kênh giao tiếp IPC (Inter-Process Communication) giữa sếp và thợ. Anh em có thể truyền message qua lại giữa các process cực kỳ dễ dàng bằng process.send() và process.on('message').
// Sếp (main.js)
const { fork } = require('child_process');
const worker = fork('./worker.js');
worker.on('message', (msg) => {
console.log('Sếp nhận báo cáo:', msg);
});
worker.send('Xử lý cục data này cho tao!');
// Thợ (worker.js)
process.on('message', (msg) => {
console.log('Thợ nhận lệnh:', msg);
// ... làm việc nặng nhọc ...
process.send('Dạ em làm xong rồi sếp!');
});
Trường hợp anh em phải tạo report Excel chục vạn dòng bằng thư viện JS mà không muốn server bị treo, cứ đẻ ra một cái fork cho nó chạy background là xong.
Chốt hạ
Dùng Child Process rất sướng vì nó cởi trói cho Node.js khỏi giới hạn đơn luồng. Nhưng cái gì cũng có giá của nó. Mỗi lần anh em spawn hay fork ra một process mới, Hệ điều hành sẽ tốn một lượng RAM và CPU nhất định để duy trì. Đừng vui tay mà đẻ ra vài nghìn cái child processes cùng lúc, server của anh em sẽ chết chìm vì cạn kiệt tài nguyên (cái này gọi là Over-spawning).
Nắm vững 4 bí kíp exec, spawn, execFile, và fork, anh em đã tự tin xử đẹp mọi bài toán về CPU trên môi trường Node.js rồi đấy.
Nếu thấy bài viết này gãi đúng chỗ ngứa, anh em cho xin một upvote để có động lực lên tiếp series nhé. Chúc anh em code mượt, ít bug!
All rights reserved