Bạn đã bao giờ viết một hàm async trong C# mà ứng dụng vẫn chậm, hoặc dùng Task để chạy song song nhưng hệ thống vẫn bị đơ? Chúng tôi gặp đúng tình huống này nhiều lần khi làm các dự án .NET. Có một khách hàng phàn nàn API phản hồi chậm và server ngốn tài nguyên, trong khi toàn bộ code đã bất đồng bộ từ đầu đến cuối. Khi rà lại, nguyên nhân gần như lần nào cũng giống nhau. Hệ thống không thiếu bất đồng bộ, mà là dùng sai công cụ cho đúng việc.
Trong bài này, chúng tôi chia sẻ cách phân biệt async/await và Task, kèm vài tình huống khiến chính đội ngũ phải ngồi lại xem lại cách viết code của mình. Mục tiêu là giúp bạn quyết định nhanh hơn khi đứng trước một bài toán cụ thể.
Sự nhầm lẫn kinh điển khi dùng async await và task
Khi mới tiếp cận lập trình bất đồng bộ, chúng tôi từng nghĩ async/await và Task là một. Thực tế đây là hai khái niệm nằm ở hai tầng khác nhau. Task là kiểu dữ liệu đại diện cho một công việc đang diễn ra. async/await là cú pháp giúp bạn làm việc với kiểu dữ liệu đó gọn gàng hơn.
Nhiều bạn mới vào nghề thấy hàm trả về Task là thêm async vào theo phản xạ. Ngược lại, thấy async thì nghĩ nó tự tạo thread mới. Cả hai suy nghĩ này đều chưa đúng, và hệ quả khá rõ ràng.
Có lần một đồng nghiệp dùng Task.Run cho mọi thao tác đọc file vì nghĩ làm vậy sẽ nhanh hơn. Kết quả là ứng dụng còn chậm hơn trước, vì hệ thống phải tốn thêm chi phí chuyển đổi ngữ cảnh giữa các thread. Chuyện ngược lại cũng từng xảy ra: một bạn viết toàn bộ xử lý IO bằng await liên tiếp nhưng quên rằng các tác vụ độc lập hoàn toàn có thể chạy song song. Thời gian chờ cộng dồn lại, API vẫn chậm dù code trông rất hiện đại.
Bản chất của async/await trong .NET
Async/await cho phép bạn viết code bất đồng bộ nhưng đọc lên vẫn giống code tuần tự. Bạn viết theo thứ tự từ trên xuống dưới. Khi gặp await, phương thức tạm dừng ở đó để chờ một tác vụ hoàn thành. Trong lúc chờ, thread hiện tại không bị giữ vô ích mà được trả về để phục vụ việc khác.
Lợi thế lớn nhất của cách viết này nằm ở khả năng đọc hiểu. Một phương thức async được viết cẩn thận dễ theo dõi, gần như không khác code đồng bộ. Điều này đặc biệt quan trọng khi làm việc theo nhóm, vì người đọc code sau không cần vẽ lại sơ đồ luồng mới hiểu được ý đồ.
Ví dụ đơn giản khi gọi một API bên ngoài:
var data = await _httpClient.GetFromJsonAsync<MyData>(url);
// Xử lý data ở đây
Đoạn code này ngắn và rõ ràng. Bên dưới cơ chế phức tạp hơn nhiều. Trong lúc chờ phản hồi, thread không bị khóa. Khi dữ liệu về, phương thức tiếp tục chạy từ đúng vị trí await. Nhờ vậy, một ứng dụng web có thể xử lý rất nhiều request đồng thời mà không cần một lượng thread khổng lồ.
Tuy vậy, async/await không giải quyết mọi bài toán. Nếu bạn có nhiều tác vụ độc lập và viết chúng nối tiếp nhau bằng await, tổng thời gian sẽ bằng tổng thời gian từng tác vụ. Khi đó bạn chưa tận dụng được lợi thế của bất đồng bộ. Đây là chỗ Task phát huy tác dụng.
Khi nào nên dùng Task trong .NET?
Task đại diện cho một tác vụ đang chạy hoặc sẽ hoàn thành trong tương lai. Bạn có thể giữ nó trong biến, theo dõi trạng thái, chờ khi cần, hoặc gom nhiều Task lại để xử lý cùng lúc. Có thể hình dung Task như một phiếu ghi nhận rằng công việc đã được giao đi, còn bạn tiếp tục làm việc khác trong lúc chờ kết quả.
Chúng tôi thấy Task thật sự hữu ích khi cần kiểm soát luồng xử lý ở mức chi tiết hơn. Giả sử bạn cần gọi ba API khác nhau, không phụ thuộc lẫn nhau. Nếu dùng await tuần tự, thời gian sẽ là tổng của cả ba. Cách tốt hơn là khởi động cả ba Task trước, rồi chờ tất cả cùng hoàn thành:
var task1 = _httpClient.GetFromJsonAsync<Data1>(url1);
var task2 = _httpClient.GetFromJsonAsync<Data2>(url2);
var task3 = _httpClient.GetFromJsonAsync<Data3>(url3);
await Task.WhenAll(task1, task2, task3);
Với cách viết này, thời gian chờ chỉ bằng tác vụ chậm nhất. Đây là mẫu đội ngũ dùng khá thường xuyên trong các dự án tổng hợp dữ liệu từ nhiều nguồn. Thay đổi nhỏ nhưng hiệu quả rõ rệt.
Task còn hữu ích khi bạn cần các cơ chế nâng cao như giới hạn số tác vụ chạy song song, hoặc hủy giữa chừng bằng CancellationToken. Những việc này chỉ dùng await đơn thuần sẽ khó kiểm soát linh hoạt hơn nhiều. Khi cần tổ chức phần xử lý dữ liệu sau khi nhận về, bạn có thể tham khảo thêm so sánh LINQ method syntax và query syntax để viết gọn hơn phần đó.
Sự khác biệt về mục đích sử dụng
Để dễ hình dung, chúng tôi hay ví von với việc nấu ăn trong bếp. Async/await giống như bạn giao một món cho phụ bếp rồi làm việc khác, khi nào phụ bếp báo xong thì quay lại hoàn thiện. Còn Task giống như bạn lập một danh sách món, giao cho nhiều phụ bếp cùng lúc, rồi chờ tất cả báo cáo. Cả hai đều giúp bếp chạy nhanh hơn, chỉ khác nhau ở cách tổ chức.
Trong thực tế, phần lớn tình huống giải quyết tốt bằng async/await. Nó gọn, dễ bảo trì và ít rủi ro. Chỉ khi bài toán đòi hỏi xử lý song song có chủ đích, hoặc cần quản lý vòng đời của nhiều tác vụ cùng lúc, bạn mới cần đi sâu vào Task.
Một lưu ý chúng tôi rút ra sau nhiều lần vấp: đừng lạm dụng Task.Run để “làm cho nhanh” các tác vụ nặng CPU. Làm vậy sinh ra nhiều thread cạnh tranh tài nguyên, khiến cả hệ thống chậm đi. Với bài toán tính toán nặng, nên ưu tiên tối ưu thuật toán. Nếu cần, hãy xem lại các lưu ý về debug và tối ưu mã cho dev .NET để tìm đúng điểm nghẽn trước khi đổi kiến trúc.
Thực hành: chọn đúng công cụ cho đúng việc
Mỗi khi gặp một bài toán mới, chúng tôi thường tự hỏi hai câu. Thứ nhất, tác vụ này là I/O (đọc file, gọi API, truy vấn database) hay CPU-bound (tính toán nặng)? Thứ hai, có nhiều tác vụ độc lập cần chạy cùng lúc không?
Nếu là I/O và chỉ có một tác vụ, async/await là lựa chọn tự nhiên nhất. Nếu là I/O nhưng có nhiều tác vụ độc lập, hãy tạo các Task rồi dùng Task.WhenAll. Nếu là CPU-bound, đừng vội khoác lớp async hay Task.Run rồi coi như xong. Hãy xem lại thiết kế, có thể cần tách việc hoặc dùng thư viện chuyên cho xử lý song song.
Có một dự án đội ngũ từng làm phải xử lý hàng nghìn file ảnh. Ban đầu cả nhóm viết theo kiểu await tuần tự, thời gian chạy gần như không thể chấp nhận. Sau khi chuyển sang Task.WhenAll với số tác vụ được giới hạn bằng semaphore, thời gian xử lý giảm khoảng bốn lần. Thuật toán không đổi, chỉ có cách tổ chức luồng công việc là khác đi.
Câu hỏi thường gặp
Có phải lúc nào cũng nên dùng async/await thay vì Task?
Không hẳn. async/await là lựa chọn mặc định tốt cho đa số trường hợp vì đơn giản và dễ đọc. Khi bạn cần khởi động nhiều tác vụ độc lập để chạy song song, làm việc trực tiếp với Task sẽ hiệu quả hơn. Điều quan trọng là hiểu bản chất để chọn đúng, thay vì áp dụng một khuôn mẫu cho mọi thứ.
Dùng Task.Run có phải cách tốt để biến code đồng bộ thành bất đồng bộ?
Đây là hiểu lầm khá phổ biến. Task.Run chỉ đưa đoạn code chạy sang một thread khác, nó không giải phóng thread khi gặp thao tác I/O. Trong nhiều trường hợp, nó còn làm tăng chi phí chuyển đổi ngữ cảnh. Cách đúng là dùng các API bất đồng bộ thật sự như HttpClient.GetAsync hoặc File.ReadAllTextAsync.
Hy vọng bài viết giúp bạn nhìn rõ hơn cách dùng async/await và Task trong .NET. Nắm vững hai khái niệm này không chỉ giúp code chạy nhanh hơn mà còn giúp bạn tự tin hơn khi thiết kế các hệ thống phức tạp. Nếu đang phân vân chọn công nghệ cho dự án web, bạn có thể đọc thêm so sánh ASP.NET và PHP. Chúc bạn code vui và ít bug.


