Tính toán song song trong Node.js

Tính toán song song trong Node.js
Photo by Sudharshan TK from Unsplash

Máy tính ngày càng mạnh hơn, CPU có nhiều lõi hơn, tốc độ và khả năng tính toán ngày càng tốt hơn. Cùng với sự phát triển của phần cứng, phần mềm cũng ngày càng phức tạp hơn. Để tối ưu sức mạnh phần cứng, các phần mềm cũng được phát triển để thực hiện nhiều tính toán song song trên nhiều thread khác nhau. Với Node.js, việc chạy multithread lại không đơn giản như các ngôn ngữ lập trình khác. Trong bài viết này, chúng ta sẽ tìm hiểu cách Node.js thực hiện tính toán song song.

Cách Node.js tính toán bất đồng bộ

JavaScript là ngôn ngữ được phát triển để chạy trên một thread duy nhất. Do ban đầu, đây là ngôn ngữ để chạy trên trình duyệt, giúp người dùng tương tác với các trang web.

Tuy nhiên, sẽ có một vài xử lý tốn nhiều thời gian để hoàn thành. Việc sử dụng một thread có thể block việc hiển thị trang web. Do đó, JavaScript được phát triển để thực hiện tính toán bất đồng bộ trên một thread duy nhất. Do chỉ có một thread, cách thực hiện tính toán bất đồng bộ của JavaScript dựa trên event loop và lập trình theo kiểu event-driven.

Khi Node.js được phát triển, nó vẫn tiếp tục sử dụng cơ chế của JavaScript và chỉ thực hiện xử lý trên một thread duy nhất (theo tài liệu của Node.js):

Thread-based networking is relatively inefficient and very difficult to use. Furthermore, users of Node.js are free from worries of dead-locking the process, since there are no locks. Almost no function in Node.js directly performs I/O, so the process never blocks except when the I/O is performed using synchronous methods of the Node.js standard library. Because nothing blocks, scalable systems are very reasonable to develop in Node.js.

Node.js có thể chạy multithread hay không?

Multithread nghĩa là một chương trình có thể thực hiện song song nhiều thread xong một tiến trình (process). Các thread được thực thi độc lập nhưng chia sẻ chung tài nguyên của tiến trình đó.

Multithreading is a program execution model that allows multiple threads to be created within a process. The threads execute independently but concurrently share process resources.

multithread
Nguồn: Wikimedia Commons

Để hiểu rõ hơn về multithread, trước hết cần hiểu về single thread. Trong một tiến trình mà chỉ có 1 thread (single thread), các thao tác sẽ được thực hiện tuần tự. Khi một thao tác đã hoàn thành thì thao tác kế tiếp mới được thực thi.

Trong trường hợp như vậy, nếu một thao tác nào đó mất quá nhiều thời gian, chiếm dụng hết tài nguyên hệ thống, nó sẽ block toàn bộ tiến trình. Nhưng với các tiến trình multithread, các thao tác có thể được thực hiện song song bởi nhiều thread khác nhau.

Node.js được phát triển dựa trên JavaScript mà JavaScript vốn là single thread. Tuy nhiên, điều đó không ngăn cản Node.js thực thi các thao tác song song. Vì bản chất là single thread, việc thực thi các thao tác song song của Node.js rất đặc thù, không giống với multithread thường gặp trên các ngôn ngữ khác.

Trong những phần tiếp theo, chúng ta sẽ tìm hiểu việc thực hiện các thao tác song song trong Node.js

Chạy nhiều tiến trình (multiprocess) trong Node.js

Node.js có thể thực hiện tính toán song song bằng nhiều tiến trình khác nhau bằng cách dùng module child_process. Các tiến trình được thực thi hoàn toàn độc lập, không dùng chung tài nguyên hệ thống, cho phép chúng thực hiện tính toán song song một cách hiệu quả.

Module child_process cung cấp 4 phương thức khác nhau để tạo ra các tiến trình con: spawn, exec, execFilefork.

Dưới đây là một demo nho nhỏ sử dụng fork. Phương thức fork cho phép tạo ra một tiến trình con và tiến trình đó vẫn có thể giao tiếp với tiến trình cha. Phương thức fork nhận 3 tham số:

  • Một string để chỉ đường dẫn file JavaScript sẽ thực thi trong tiến trình con.
  • Một array hoặc string là các tham số sẽ được truyền cho tiến trình con.
  • Một object là các tùy chọn khác để thực thi tiến trình con.

Ví dụ:

fork("sub.js", ["arguments"], { cwd: process.cwd() });

Trong file chính của chương trình main.js, sử dụng module child_process và tạo các tiến trình con như sau:

// main.js
const child_proc = require("child_process");

console.log("running main.js");
const sub = child_proc.fork("./sub.js");

// truyền thông tin từ tiến trình cha sang tiến trình con
sub.send({ from: "parent" });

// tiến trình cha nhận thông tin từ tiến trình con
sub.on("message", (message) => {
    console.log("PARENT got message from " + message.from);
    sub.disconnect();
});

Tiến trình con này sẽ thực thi file sub.js. Ví dụ một file với nội dung đơn giản như sau:

// sub.js
console.log("sub.js is running");

setTimeout(() => {
    // gửi thông tin cho tiến trình cha
    process.send({ from: "client" });
}, 2000);

// nhận thông tin từ tiến trình cha
process.on("message", (message) => {
    console.log("SUBPROCESS got message from " + message.from);
});

Khi thực thi main.js, chúng ta sẽ nhận được kết quả như bên dưới:

running main.js
sub.js is running
SUBPROCESS got message from parent
PARENT got message from client

Việc thực hiện tính toán song song bằng tiến trình con được gọi là multiprocess. Nó. khác với multithread bởi vì chúng ta phải tạo ra nhiều tiến trình khác nhau, và các tiến trình này hoạt động độc lập, không dùng chung tài nguyên hệ thống. Việc tạo ra thực thi một tiến trình con tốn nhiều thời gian và công sức hơn so với thread.

Còn với multithread, một process sẽ tạo ra nhiều thread. Mỗi thread sẽ đảm nhiệm một phần tính toán của tiến trình đó và dung chung tài nguyên của tiến trình. Việc tạo và quản lý các thread trong một tiến trình thường dễ dàng hơn nhiều.

Worker thread

Worker thread có thể được sử dụng để thực hiện những tính toán nặng, cần nhiều CPU mà không block các xử lý khác trong event loop. Khác với child_process, worker_threads có thể dùng chung bộ nhớ bằng cách truyền tham số ArrayBuffer hoặc SharedArrayBuffer.

Module worker_threads đã được bổ sung vào Node.js từ lâu (kể từ phiên bản 10.5.0), làm việc với module này hoàn toàn không cần cài đặt thêm bất kỳ thư viện nào.

Tuy nhiên, trong tài liệu của Node.js về worker thread cũng có lưu ý không nên lạm dụng module này:

Workers (threads) are useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work. The Node.js built-in asynchronous I/O operations are more efficient than Workers can be.

Lấy một ví dụ đơn giản như sau, một chương trình sẽ tạo worker thread và thực thi một file khác với các tham số đầu vào:

const { Worker } = require("worker_threads");

function doSomethingCPUIntensive(name) {
    return new Promise((resolve, reject) => {
        const worker = new Worker("./sub.js", { workerData: { name } });

        worker.on("message", resolve);
        worker.on("error", reject);
        worker.on("exit", (code) => {
            if (code !== 0) {
            reject(new Error(`stopped with exit code ${code}`));
            }
        });
    });
}

(async () => {
    try {
        const result = await doSomethingCPUIntensive("John");
        console.log("Parent: ", result);
    } catch (err) {
        console.log(err);
    }
})();

Một worker thread được tạo ra nhận tham số là file để thực thi và các dữ liệu tham số khác. Worker thread có thể trigger nhiều event khác nhau và thực thi các logic tương ứng. Khi worker thread đã hoàn thành tính toán, nó có thể trả về “exit code”.

Trong ví dụ trên, chúng ta có thể tạo một file sub.js để thực thi worker thread như sau:

// sub.js
const { workerData, parentPort } = require("worker_threads");

function theCPUIntensiveTask(name) {
    return `Hello World ${name}`;
}

const intensiveResult = theCPUIntensiveTask(workerData.name);

parentPort.postMessage({ intensiveResult });

workerData là những dữ liệu nhận được khi worker thread được tạo ra, parentPort là phương tiện để trả về kết quả sau khi thực thi. Worker thread là phương pháp hiệu quả khi cần thực hiện tính toán nặng, tiêu tốn CPU. Tuy nhiên, trong nội dung bài viết, tôi chỉ lấy một ví dụ đơn giản.

Khi thực thi main.js, đây là kết quả chúng ta sẽ nhận được:

Parent:  { intensiveResult: 'Hello World John' }

Worker thread hoạt động không hoàn toàn giống multithread, nhưng nó có thể coi là một giải pháp thay thế trong những trường hợp cần thiết.

Kết luận

Trong bài viết này, tôi đã trình bày những hiểu biết của mình về cách thức Node.js thực hiện tính toán song song (multiprocess, worker thread). Những kiến thức này chỉ ở mức sơ khai, cũng chưa thật đầy đủ và chính xác. Hy vọng bài viết giải đáp một phần nào các thắc mắc của mọi người muốn tìm hiểu sâu hơn về Node.js.

Tôi xin lỗi nếu bài viết có bất kỳ typo nào. Nếu bạn nhận thấy điều gì bất thường, xin hãy cho tôi biết.

Nếu có bất điều gì muốn nói, bạn có thể liên hệ với tôi qua các mạng xã hội, tạo discussion hoặc report issue trên Github.