Node.js: Blocking & Non-blocking I/O

Node.js: Blocking & Non-blocking I/O
Photo by Victor Barrios from Unsplash

Gần đây tôi làm việc với Node.js nhiều, nghe phong thanh ở đâu đó nói rằng Node.js hỗ trợ non-blocking I/O. Điều đó giúp Node.js có thể xử lý lượng truy vấn đồng thời rất lớn (lên đến hàng triệu), khiến nó rất thích hợp với những ứng dụng đòi hỏi tốc độ phản hồi cao, các ứng dụng thời gian thực.

Tôi thì mới làm việc với Node.js nên hiểu biết chưa đâu vào đâu. Tạm thời tôi không đánh giá nhận xét trên là đúng hay sai. Tuy nhiên, có một điều chắc chắn là Node.js có thể lập trình được non-blocking I/O thật. Vậy nó là gì mà thần thánh đến vậy?

Trong bài viết này, tôi sẽ trình bày những hiểu biết của tôi về khái niệm này.

Lịch sử

Khi tác giả của Node.js, Ryan Dahl, tạo ra công cụ này vào năm 2009, anh ta cho rằng I/O (input và output) đang được xử lý sai cách. Mỗi khi có xử lý I/O, toàn bộ tiến trình sẽ bị dừng lại. Nguyên nhân là do các xử lý là đồng bộ. Kỹ thuật truyền thống khi chạy các ứng dụng web (và có thể nhiều ứng dụng khác nữa) là sử dụng thread, mỗi truy vấn sẽ được xử lý bởi một thread. Tuy nhiên, trong hầu hết ứng dụng, input hay output thường chiếm phần lớn thời gian (và lâu hơn cả thời gian xử lý logic).

Bởi vì phần lớn thời gian chỉ là thời gian chờ (để input hay output hoàn thành), các tài nguyên của hệ thống gắn với thread (điển hình là bộ nhớ) cũng trong trạng thái chờ và không được sử dụng hiệu quả 🤑. Rõ ràng mô hình sử dụng thread để xử lý truy vấn khó có thể scale tốt khi lượng truy vấn là rất lớn.

Nhu cầu hiển nhiên là phần mềm (nhất là các ứng dụng web) cần phải xử lý đa nhiệm tốt và anh ta (không chắc là anh ta tự nghĩ ra vì Node.js cũng dùng engine V8 của Google 😂 và cách làm này rất giống cách trình duyệt thực thi JavaScript) đề xuất phương pháp để loại bỏ thời gian chờ trong các xử lý I/O. Thay vì sử dụng multi thread, cách mà anh ta đưa ra là chỉ sử dụng một thread (single thread) nhưng kết hợp với event loop và non-blocking I/O.

Ví dụ, khi chúng ta truy vấn dữ liệu từ database, thay vì chờ để nhận được kết quả, chúng ta sẽ truyền một callback để xử lý kết quả, trong khi vẫn tiếp tục thực hiện các logic khác. Khi database hoàn thành truy vấn và trả về dữ liệu, callback sẽ được thực thi.

Event loop (vòng lặp sự kiện) chính là thứ giúp Node.js có thể thực hiện các thao tác non-blocking I/O cho dù JavaScript chỉ chạy single thread. Vòng lặp sẽ chạy chung thread với code JavaScript, sẽ lấy code và thực thi. Nếu một tác vụ nào đó là bất đồng bộ hoặc là I/O, vòng lặp sẽ đẩy việc đó xuống kernel (hạt nhân của hệ thống) hoặc một thread pool, tùy vào nội dung của tác vụ. Sau đó vòng lặp sẽ tiếp tục thực thi các code tiếp theo.

Bởi vì việc xử lý ở tầng dưới (kernel, thread pool) có thể thực hiện multi thread, những tác vụ này có thể được thực hiện song song dưới nền. Và một khi nó hoàn thành (được gọi là một sự kiện – event), kernel sẽ thông báo cho Node.js và callback tương ứng sẽ được đưa vào hàng chờ để xử lý.

Node.js có cơ chế để theo dõi và quản lý các xử lý bất đồng bộ và event loop sẽ liên tục kiểm tra các tác vụ đó đã được hoàn thành hay chưa.

Blocking và non-blocking I/O

Thực ra, blocking và non-blocking xuất hiện trong mọi xử lý của Node.js. Nên khi nói về blocking hay non-blocking, mọi thứ không giới hạn trong I/O. Tuy nhiên, trong bài viết này tôi muốn tập trung vào I/O bởi vì đây là những xử lý tốn nhiều thời gian nhất (mất nhiều thời gian chờ nhất). Đặc biệt là với những ứng dụng web, việc I/O thường được thực hiện thông qua môi trường mạng nên còn mất thời gian hơn bình thường.

Blocking

Blocking là kiểu xử lý mà các thao tác khác sẽ phải dừng lại cho đến khi thao tác hiện tại hoàn thành. Những thao tác dạng blocking là các xử lý đồng bộ (lần lượt xử lý này xong mới đến xử lý tiếp theo).

Trong Node.js, blocking sẽ xảy ra khi mà các xử lý bằng JavaScript chạy bởi Node.js sẽ phải dừng lại và chờ (thường là với các xử lý bên ngoài, non-JavaScript) một xử lý khác hoàn thành. Điều này xảy ra là bởi vì event loop của JavaScript không quản lý được các xử lý này và bắt buộc phải chờ.

Dưới đây là một ví dụ sử dụng hàm readFileSync để đọc nội dung file và nó là một thao tác blocking I/O trong Node.js.

const fs = require('fs');
const filepath = 'text.txt';
const data = fs.readFileSync(filepath);
console.log(data);

// Tính tổng các số từ 1-10
let sum = 0;
for (let i = 1; i <= 10; i++) {
    sum = sum + i;
}
console.log('Sum:', sum);

Chạy đoạn code này và chúng ta sẽ thu được kết quả như dưới đây. Để ý rằng, quá trình tính tổng phải chờ đến khi việc đọc dữ liệu từ file hoàn thành mới được thực thi.

$ node index.js
This is from text file.
Sum:  55

Non-blocking

Non-block thì ngược lại, nghĩa là các xử lý của chương trình sẽ không cản trở lẫn nhau. Các thao tác non-blocking sẽ được xử lý bất đồng bộ (các xử lý không nhất thiết phải lần lượt, các xử lý được gọi là chạy dưới nền, còn chương trình sẽ tiếp túc với các lệnh tiếp theo).

Tất các các thao tác I/O trong thư viện chuẩn của Node.js đều có phiên bản bất đồng bộ, chúng đều là các thao tác non-blocking. Để sử dụng những thao tác này, chúng ta phải định nghĩa và truyền vào một callback. Một số hàm có cả phiên bản bất đồng bộ và đồng bộ (các hàm đồng bộ thường có thêm hậu tố Sync để phân biệt).

Ví dụ thao tác đọc file và tính tổng tương tự ở trên, nhưng sử dụng non-blocking I/O như sau:

const fs = require('fs');
const filepath = 'text.txt';
fs.readFile(filepath, { encoding: 'utf8' }, (err, data) => {
    console.log(data);
});

// Tính tổng các số từ 1-10
let sum = 0;
for (let i = 1; i <= 10; i++) {
    sum = sum + i;
}
console.log('Sum:', sum);

Chạy code này và chúng ta sẽ nhìn thấy sự khác biệt. Trong thao tác non-blocking, chương trình sẽ chạy mà không bị cản trở, kết quả là tổng được ghi ra trước nội dung của file. Bởi vì chương trình không cần phải chờ quá trình đọc file hoàn thành mới thực thi các lệnh tiếp theo. Khi readFile hoàn thành quá trình đọc file thì callback được gọi là lúc này kết quả mới được ghi ra.

Về mặt kỹ thuật thì các callback sẽ luôn luôn được thực thi sau code đồng bộ, cho dù thao tác bất đồng bộ có nhanh thế nào đi chăng nữa. Nguyên nhân là do các callback này được đưa vào event loop và sẽ được thực thi lần lượt sau khi code đồng bộ đã được thực thi hết.

$ node index.js
Sum:  55
This is from text file.

Trong hai cách làm này, thì cách đầu tiên (blocking) sẽ đơn giản hơn, do chúng ta dễ dàng kiểm soát được quá trình thực thi của code. Tuy nhiên, nó có một vấn đề như đã nói, thao tác đọc file sẽ dừng toàn bộ các thao tác khác cho đến khi hoàn thành, do đó các tài nguyên của hệ thống không được sử dụng một cách tối ưu.

Lưu ý rằng, với xử lý blocking, nếu có lỗi trong quá trình đọc file thì nó sẽ gây ra một exception và chúng ta phải dùng cơ chế try..catch nếu muốn xử lý lỗi. Còn với phiên bản non-blocking, lập trình viên có thể truyền callback để xử lý lỗi hay không mà chương trình vẫn tiếp tục được thực thi.

Một số vấn đề liên quan

Concurrency & Throughput

Việc thực thi JavaScript trong Node.js là single thread nên concurrency (xử lý đồng thời) được hiểu là khả năng của event loop để xử lý các JavaScript callback sau khi các thao tác bất đồng bộ hoàn thành. Tất cả code muốn được xử lý đồng thời cần phải có cơ chế cho phép event loop tiếp tục chạy các code khác tương tự như cơ chế non-blocking I/O ở trên.

Lấy một ví dụ đơn giản, giả sử bạn có một server web và nó cần 50ms để hoàn thành việc nhận và trả lời một truy vấn. Trong đó có khoảng 45ms là thời gian để truy xuất dữ liệu từ database. Thao tác truy xuất dữ liệu này hoàn toàn có thể thực hiện bất đồng bộ, và nếu chúng ta dùng non-blocking I/O với thao tác này, chúng ta sẽ không lãng phí tài nguyên trong 45ms đó để phản hồi cho người dùng, và trong lúc đó, server sẽ tiếp tục xử lý các truy vấn khác.

Toàn bộ xử lý có thể minh họa bằng quá trình như hình ảnh dưới đây. Bằng việc xử dụng non-blocking I/O và callback, kiểu lập trình JavaScript với Node.js có thể nói là dạng event-based.

web
Nguồn: Medium

Sự khác biệt là rất lớn giữa blocking và non-blocking I/O trong trường hợp này. Và event loop của Node.js là một mô hình rất khác so với phần lớn các ngôn ngữ lập trình khác sẽ dùng thread để xử lý các thao tác đồng thời. Nhưng cần lưu ý rằng, có thể xử lý nhiều truy vấn đồng thời không có nghĩa là người dùng chưa thể nhận được kết quả ngay lập tức. Dữ liệu vẫn cần thời gian để trả về cho người dùng.

Cẩn thận với callback trong non-blocking

Khi thao tác I/O, có một số trường hợp chúng ta phải hết sức cẩn thận. Lấy một ví dụ đơn giản như sau:

const fs = require('fs');
fs.readFile('/file', (err, data) => {
    if (err) throw err;
    console.log(data);
});
fs.unlinkSync('/file');

Trong ví dụ trên, fs.unlinkSync sẽ được thực thi trước và nó có thể sẽ xóa file trước thao tác đọc. Với trường hợp này, cách làm đúng mà vẫn dùng non-blocking I/O sẽ như bên dưới:

const fs = require('fs');
fs.readFile('/file', (readFileErr, data) => {
    if (readFileErr) throw readFileErr;
    console.log(data);
    fs.unlink('/file', (unlinkErr) => {
        if (unlinkErr) throw unlinkErr;
    });
});

Chúng ta sẽ dùng cơ chế non-blocking cho fs.unlink (thực ra chỗ này blocking cũng được nhưng không hay) trong callback của fs.readFile, điều đó sẽ giúp đảm bảo rằng quá trình thực thi code sẽ đúng thực tự. Thế nhưng, cũng cần lưu ý thêm rằng, việc dùng quá trình quá nhiều callback lồng nhau cũng cần hết sức cẩn thận, nếu không có thể dẫn đến callback hell (aka callback pyramid of doom).

Async/await

Như đã nói ở trên, dùng quá trình callback lồng nhau cũng rất nguy hiểm. Và async/await kết hợp với Promise chính là một giải pháp để phòng tránh các vấn đề có thể xảy ra. Trong bài viết này, tôi không muốn trình bày lại về những khái niệm đó (tôi đã viết một lần rồi 😆).

Trong phần này, tôi muốn tập trung vào câu hỏi: async/await là blocking hay non-blocking? Câu trả lời ngắn gọn là: async/await là non-blocking.

Do async/await có thể giúp lập trình viên lập trình bất đồng bộ không khác gì đồng bộ, điều này có thể là băn khoăn của nhiều người (có thể là chỉ của riêng tôi 😊).

Giả sử chúng ta có một đoạn code đơn giản như sau, dùng async/await để xử lý các truy vấn với ứng dụng web của chúng ta.

router.get('/route1', async (req, res) => {
    const test = await sleep();
    res.send('HELLO WORLD');
});

router.get('/route2', (req, res) => {
    res.send('HELLO WORLD');
});

Hai route ở trên được định nghĩa khác nhau đôi chút. Route route1 sử dụng một hàm async (để có thể sử dụng await) trong khi route2 thì dùng hàm bình thường. Cả hai route đều được định nghĩa kiểu non-blocking với callback.

Trong trường hợp này, await chỉ block việc thực thi của hàm async bao nó mà thôi. Nó không block toàn bộ xử lý của tiến trình. Trên thực tế, hàm async hoạt động một cách bất đồng bộ, ngay khi được gọi nó sẽ trả về một promise và các xử lý khác vẫn có thể chạy bình thường.

Cụ thể, khi gặp await sleep, hàm async của chúng ta sẽ tạm dừng cho đến khi sleep trả về kết quả, tuy nhiên, bản thân hàm async trước đó đã ngay lập tức trả về một promise (trạng thái là chưa hoàn thành). Do kết quả của hàm async không được sử dụng ở đâu khác, Node.js chỉ đơn giản là bỏ qua nó và event loop sẽ tiếp tục thực thi các lệnh khác.

Promise được trả về bởi hàm async sẽ vẫn tiếp tục chạy và trả về kết quả cho người dùng. Trong lúc đó, nếu có truy vấn khác đến server, các event sẽ tiếp tục được đẩy vào hàng đợi và Express sẽ xử lý lần lượt các truy vấn đó theo đúng cơ chế gọi callback của non-blocking.

Như vậy, thực sự await có tạm dừng việc thực thi nhưng nó chỉ tạm dừng một hàm mà thôi. Các lệnh khác vẫn thực thi bình thường và server có thể xử lý rất nhiều request đến cùng một lúc.

Tóm lại

Blocking I/O là các thao tác khác sẽ phải dừng lại cho đến khi I/O hoàn thành. Với blocking I/O, chúng ta phải chờ và cần dùng multi thread để xử lý đồng thời nhiều truy vấn.

Non-blocking I/O là các thao tác I/O sẽ được xử lý bất đồng bộ và callback sẽ được gọi khi hoàn thành, các thao tác khác sẽ tiếp tục được thực thi. Non-blocking I/O cho phép chúng ta xử lý nhiều truy vấn đồng thời chỉ bằng một thread.

Trong hầu hết (phải đến 99%) các ứng dụng, việc sử dụng cơ chế nào không thực sự quá quan trọng. Chỉ một số ứng dụng có yêu cầu cao về hiệu suất, khả năng xử lý đồng thời thì non-blocking I/O của Node.js có thể là giải pháp cho bạn (tất nhiên việc lựa chọn ngôn ngữ nào còn rất nhiều yếu tố khác nữa 😁).

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.