Giới thiệu
Khi bắt đầu viết mã C# cần thực hiện nhiều tác vụ đồng thời, bạn sẽ thường gặp hai phương thức:
- Task.Run
- Parallel.ForEachAsync
Ban đầu, cả hai đều có vẻ như là những cách "thần kỳ" để chạy các tác vụ song song. Nhưng thực tế, chúng được thiết kế cho những công việc khác nhau. Hãy cùng tìm hiểu chúng từng bước nhé.
1. Task.Run và Parallel.ForEachAsync là gì?
🟢 Task.Run
Bạn có thể nghĩ về Task.Run như là:
👉 "Chạy một công việc nặng trên một luồng nền để ứng dụng của tôi không bị treo."
Nó rất thích hợp cho các công việc CPU-bound như:
- Thay đổi kích thước hình ảnh
- Mã hóa file
- Thực hiện các phép toán dài
🟢 Parallel.ForEachAsync
Còn phương thức này giống như:
👉 "Tôi có một danh sách các tác vụ cần xử lý. Hãy xử lý chúng một cách song song, nhưng đừng để quá tải — hãy giữ nó trong tầm kiểm soát."
Nó được xây dựng cho các công việc I/O-bound như:
- Gọi nhiều API
- Tải xuống file
- Truy vấn cơ sở dữ liệu
Phương thức này cũng cho phép bạn đặt giới hạn (MaxDegreeOfParallelism) để không làm quá tải máy chủ hoặc máy của bạn. Và đúng, nó hỗ trợ CancellationToken ngay từ đầu.
2. Lịch sử ngắn gọn
Khi Parallel.ForEach được giới thiệu lần đầu tiên trong .NET 4 (2010), nó được thiết kế cho các tác vụ CPU-bound trong bộ nhớ — những việc như xử lý mảng, tính toán số liệu, hoặc lặp qua dữ liệu trong bộ nhớ.
Thời điểm đó, việc gọi dịch vụ web bên trong Parallel.ForEach là không được khuyến khích vì:
- Mỗi yêu cầu sẽ chặn một luồng trong khi chờ đợi.
- Điều này gây ra tình trạng đói luồng và khả năng mở rộng kém.
Đó là lý do tại sao lời khuyên là: "Parallel.ForEach chỉ dành cho công việc CPU-bound trong bộ nhớ."
🔹 Về sau, vào .NET 6 (2021): nhóm phát triển .NET đã giới thiệu Parallel.ForEachAsync.
Tại sao?
Để mang lại sức mạnh của vòng lặp song song cho các tác vụ async/await.
Để đảm bảo an toàn và hiệu quả khi chạy các tác vụ I/O-bound (như yêu cầu HTTP, truy vấn DB hoặc đọc/ghi file) một cách song song.
Để cung cấp cho các lập trình viên khả năng điều tiết tích hợp với MaxDegreeOfParallelism, giúp bạn không cần phải tự xây dựng các vòng lặp SemaphoreSlim.
Vì vậy, nếu bạn đã nghe rằng "Parallel chỉ dành cho dữ liệu trong bộ nhớ" — điều này đúng với API đồng bộ cũ, nhưng với .NET 6+, Parallel.ForEachAsync chính là cách được khuyến nghị để xử lý I/O async một cách song song.
3. Ví dụ thực tế
Ví dụ A: Gọi API song song (I/O-bound)
Dưới đây là cách bạn có thể lấy nhiều trang web cùng một lúc:
csharp
var urls = new[]
{
"https://api.site.com/page/1",
"https://api.site.com/page/2",
"https://api.site.com/page/3"
};
await Parallel.ForEachAsync(
urls,
new ParallelOptions { MaxDegreeOfParallelism = 3, CancellationToken = cancellationToken },
async (url, ct) =>
{
var response = await httpClient.GetStringAsync(url, ct);
Console.WriteLine($"{url} => {response.Length} ký tự");
});
👉 Tối đa 3 yêu cầu chạy cùng một lúc.
👉 Mỗi cuộc gọi đều tôn trọng việc hủy bỏ.
👉 Không có luồng nào bị lãng phí trong khi chờ đợi.
Ví dụ B: Xử lý hình ảnh nặng (CPU-bound)
Bây giờ, giả sử bạn đang xử lý một bức ảnh lớn. Đây là công việc CPU, không phải I/O.
csharp
var result = await Task.Run(
() => HeavyImageProcessing(inputImage),
cancellationToken);
Console.WriteLine($"Đã xử lý {result.Count} pixel");
👉 Ở đây, Task.Run đảm bảo rằng công việc nặng về CPU không chặn luồng chính của bạn (như yêu cầu ASP.NET hoặc giao diện người dùng).
4. Những sai lầm cần tránh
❌ Bọc các cuộc gọi async trong Task.Run
csharp
// Sai: không bọc I/O async
await Task.Run(() => httpClient.GetStringAsync(url));
Điều này chỉ lãng phí một luồng.
❌ Sử dụng Parallel.ForEachAsync cho các vòng lặp nặng về CPU
csharp
// Sai: không thực hiện công việc CPU ở đây
await Parallel.ForEachAsync(files, async (file, ct) =>
{
var data = ProcessFile(file); // Nặng về CPU
});
Điều này tiêu tốn các luồng mà không mang lại lợi ích. Hãy sử dụng Task.Run hoặc Parallel.For thay vào đó.
5. Quy tắc tổng quát
Dưới đây là bảng cheat sheet 📝:
- Sử dụng Parallel.ForEachAsync → nhiều tác vụ I/O async (gọi API, truy vấn DB, tải file).
- Sử dụng Task.Run → một công việc nặng về CPU (tính toán, xử lý hình ảnh).
- Cả hai → đều chấp nhận CancellationToken, để bạn có thể dừng chúng nếu cần.
Hãy nghĩ về nó như thế này:
🔹 Parallel.ForEachAsync = “nhiều tác vụ async cùng lúc”
🔹 Task.Run = “một công việc nặng, ra khỏi luồng chính”
🔹 CancellationToken = “và tôi có thể ngắt bất cứ lúc nào”
6. Kết luận
Đừng nhầm lẫn chúng:
- Nếu bạn đang chờ trên mạng, hãy sử dụng Parallel.ForEachAsync.
- Nếu bạn đang làm nóng CPU, hãy sử dụng Task.Run.
Cả hai đều giúp mã của bạn chạy nhanh hơn và phản hồi tốt hơn khi được sử dụng đúng cách.
👉 Bước tiếp theo: Hãy thử viết lại một trong các vòng lặp của bạn. Hãy tự hỏi: đây là I/O hay CPU? Đó là cách bạn biết công cụ nào nên sử dụng.