Tối Ưu Hiệu Năng .NET Với Async Cho Tác Vụ I/O

Tối Ưu Hiệu Năng .NET Với Async Cho Tác Vụ I/O
Tối Ưu Hiệu Năng .NET Với Async Cho Tác Vụ I/O

Vì sao nên dùng async cho tác vụ I/O thay vì đồng bộ? Đây là câu hỏi đội ngũ chúng tôi gặp rất thường xuyên. Câu trả lời thường lộ ra khi đọc log. Có lần, chúng tôi phân tích log của một API bán vé bị nghẽn đúng giờ cao điểm. Request xếp hàng dài, trong khi server gần như rảnh tay.

Nguyên nhân nằm ở chỗ luồng xử lý bị giữ lại chỉ để chờ ổ đĩa hoặc mạng phản hồi. Đó là lúc chúng tôi hiểu rõ bản chất vấn đề. Nhiều bạn mới vào nghề đọc khái niệm async/await trong tài liệu nhưng chưa cảm nhận được lợi ích thực tế. Bài này chúng tôi kể lại đúng những gì đã xảy ra.

Khi chuyển các đoạn gọi API, đọc file và truy vấn database sang bất đồng bộ, hiệu năng thay đổi rõ rệt. Bạn sẽ thấy điều đó qua từng ví dụ bên dưới. Hãy đọc theo thứ tự, vì mỗi phần đều nối tiếp phần trước.

Vì sao cách gọi đồng bộ cho I/O lại gây lãng phí tài nguyên

Xử Lý Đồng Bộ Cho Các Tác Vụ I/O Và Lý Do Nên Chuyển Sang Async Trong .NET
Xử Lý Đồng Bộ Cho Các Tác Vụ I/O Và Lý Do Nên Chuyển Sang Async Trong .NET

Gọi hàm đọc file hay truy vấn database theo kiểu đồng bộ trông rất đơn giản. Code chạy từ trên xuống dưới, dễ đọc và dễ debug khi mới học nghề. Điều này khiến nhiều người yên tâm rằng cách viết ấy là đủ tốt.

Tuy nhiên, phía sau đó, luồng xử lý, đơn vị mà CPU dùng để chạy code, bị giữ nguyên tại chỗ. Nó đứng chờ ổ đĩa hoặc mạng trả lời mà không làm được việc khác. Trong lúc đó, CPU vẫn đang rảnh.

Thread pool của .NET chỉ cấp phát một số luồng worker nhất định, tính theo số nhân CPU. Con số này đủ dùng khi lượng truy cập thấp. Nhưng mỗi request giữ một luồng để chờ I/O sẽ làm số luồng trống cạn rất nhanh khi người dùng tăng lên.

Chuyện gì xảy ra khi thread pool cạn luồng

Với một API nội bộ ít người dùng, vấn đề này gần như không đáng kể. Nhưng đội ngũ chúng tôi từng vào sửa một hệ thống đặt vé, nơi mỗi request gọi liên tiếp ba API bên ngoài theo kiểu đồng bộ. Server có 200 luồng trong thread pool, nghe có vẻ nhiều. Chỉ cần 200 request đến cùng lúc là toàn bộ luồng bị chiếm.

Request thứ 201 phải xếp hàng, dù server không hề bận tính toán. Nó chỉ đang chờ mạng trả lời. Đây là kiểu lãng phí tài nguyên phổ biến khi xử lý I/O theo cách chặn luồng. Nhiều team đổ lỗi cho server yếu rồi nâng cấu hình, nhưng tình trạng nghẽn vẫn tái diễn ở vài trăm request tiếp theo.

Khó hơn là nhiều dev chưa thật sự hiểu async/await hoạt động ra sao bên dưới, kể cả những người đã có vài năm kinh nghiệm. Học qua loa cú pháp, thấy code chạy được là yên tâm. Có trường hợp thêm async vào hàm rồi lại gọi .Result ngay sau đó, vô tình biến nó trở lại đồng bộ. Cách này còn tệ hơn bản gốc vì dễ gây deadlock khi chạy trong ASP.NET. Nếu ứng dụng có dấu hiệu treo bất thường, đừng vội đổ lỗi cho hạ tầng. Hãy xem qua checklist debug lỗi thường gặp trong ASP.NET trước, nguyên nhân thường nằm ở đây.

Async giúp luồng rảnh tay trong lúc chờ đọc file và gọi API

Async Giúp Luồng Rảnh Tay Khi Chờ Đọc File Và Gọi API
Async Giúp Luồng Rảnh Tay Khi Chờ Đọc File Và Gọi API

Bản chất của thao tác I/O là chờ đợi. Đó có thể là chờ ổ cứng trả về trang dữ liệu, chờ API bên thứ ba phản hồi, hoặc chờ database hoàn thành câu truy vấn. Trong khoảng chờ đó CPU không cần làm gì, nhưng nếu viết theo kiểu đồng bộ thì luồng vẫn bị giữ lại.

Một request web thông thường đi qua nhiều bước I/O: đọc session, gọi API xác thực, ghi log, truy vấn đơn hàng. Cộng dồn lại, thời gian chờ chiếm phần lớn tổng thời gian xử lý. Có lần chúng tôi đo thử và thấy phần chờ I/O chiếm hơn 80% tổng thời gian, còn phần tính toán thật sự chỉ chiếm vài phần trăm.

Luồng đi đâu trong lúc chờ

Async thay đổi cách chờ theo hướng khác. Khi gọi một hàm bất đồng bộ, .NET trả luồng về thread pool ngay khi tác vụ I/O bắt đầu. Luồng đó tiếp tục phục vụ request khác. Khi ổ đĩa hay API trả kết quả, runtime mới lấy một luồng để chạy phần code còn lại. Luồng đó không nhất thiết là luồng ban đầu.

Cơ chế này nghe hơi trừu tượng, nhưng hiệu quả thể hiện rõ qua số liệu. Trên cùng một server, cùng cấu hình, ứng dụng viết theo async xử lý được nhiều request đồng thời hơn hẳn bản đồng bộ.

Đọc file log vài trăm MB, gọi API thanh toán, truy vấn Entity Framework, tất cả đều là ứng viên tốt để chuyển sang async. Đội ngũ chúng tôi từng đổi một endpoint tổng hợp báo cáo, vốn gọi tuần tự bốn API nội bộ và mất hơn ba giây. Khi chuyển sang gọi song song bằng Task.WhenAll, thời gian phản hồi giảm xuống dưới một giây. Lý do đơn giản là không còn luồng nào phải chờ lần lượt.

Cần lưu ý, async không làm code chạy nhanh hơn về bản chất. Nó chỉ giúp không lãng phí thời gian chờ. Khi gọi API bên ngoài, lỗi mạng là chuyện thường gặp. Một sai lầm hay thấy là gom mọi lỗi vào một khối try-catch lớn, không phân biệt timeout với các lỗi khác. Hãy xem cách xử lý exception đúng cách để bắt đúng lỗi, đúng chỗ.

Lợi ích cụ thể khi áp dụng async cho ứng dụng .NET

Những Lợi Ích Thực Tế Khi Dùng Async Trong Ứng Dụng .NET
Những Lợi Ích Thực Tế Khi Dùng Async Trong Ứng Dụng .NET

Dưới đây là những thay đổi chúng tôi quan sát được trên các server đang chạy thực tế.

  • Xử lý đồng thời nhiều request hơn: với cùng CPU và RAM, bản async phục vụ được nhiều request mỗi giây hơn đáng kể. Tùy mức độ nghẽn I/O ban đầu, có lúc tăng gấp ba đến năm lần, vì luồng không bị giữ chết trong lúc chờ.
  • Giảm nghẽn thread pool: số luồng worker bị giới hạn theo số nhân CPU. Khi hết luồng, request mới phải đợi, dù RAM và CPU vẫn còn dư.
  • Tận dụng hạ tầng hiện có: nhiều team nghĩ phải nâng cấp server khi hệ thống chậm. Thực tế, chỉ cần chuyển vài đoạn gọi I/O sang async là có thể cải thiện đáng kể.

Ba lợi ích trên liên quan chặt chẽ với nhau. Luồng được giải phóng sớm nên xử lý được nhiều request hơn. Áp lực lên thread pool vì thế giảm theo. Server phản hồi ổn định hơn khi lượng truy cập tăng đột biến, chẳng hạn lúc mở bán flash sale.

Một lỗi đội ngũ chúng tôi thường gặp khi mới chuyển sang async là quên truyền CancellationToken cho các cuộc gọi I/O. Khi phía client timeout và ngắt kết nối, tác vụ bên dưới vẫn tiếp tục chạy và chiếm tài nguyên. Gắn CancellationToken và truyền xuyên suốt chuỗi gọi async là một thói quen nhỏ nhưng giúp tránh nhiều rắc rối khi hệ thống chịu tải cao.

Bắt đầu từ đoạn code gọi I/O đang chặn luồng trong dự án của bạn

Bắt Đầu Từ Đoạn Code Gọi I/O Đang Chặn Luồng Trong Dự Án
Bắt Đầu Từ Đoạn Code Gọi I/O Đang Chặn Luồng Trong Dự Án

Bạn không cần đổi toàn bộ hệ thống cùng lúc. Đội ngũ chúng tôi thường bắt đầu từ đoạn code gọi I/O tốn thời gian nhất, như API bên ngoài, truy vấn database nặng hoặc đọc ghi file lớn. Sau khi chuyển sang async, hãy đo lại thời gian phản hồi trước và sau bằng chỉ số p95 latency trên dashboard giám sát.

Một nguyên tắc chúng tôi luôn nhắc các thành viên mới: đã dùng async thì phải async xuyên suốt, từ controller xuống tới tầng truy cập database. Chỉ cần một chỗ gọi .Result hoặc .Wait() ở giữa là chuỗi bất đồng bộ phía trên bị phá vỡ và có nguy cơ deadlock.

Áp dụng đúng async cho các tác vụ I/O giúp ứng dụng .NET tối ưu hiệu năng thực sự, xử lý nhiều yêu cầu đồng thời hơn mà không cần thêm phần cứng. Nếu muốn rà thêm các điểm khác dễ bị bỏ sót ngoài async, bạn có thể đọc tiếp bài tối ưu hiệu năng ứng dụng .NET, nơi chúng tôi tổng hợp những điểm dev thường bỏ qua.

Tham khảo thêm tài liệu chính thức về lập trình bất đồng bộ trong C# của Microsoft để hiểu sâu hơn về cơ chế async/await.