
Lập trình bất đồng bộ với async/await trong C#
February 20, 2025Tiếp tục bài viết Concurrency, bạn có thể tìm đọc lại tại đây:
Trong thế giới chuyển đổi số ngày nay, việc xây dựng các ứng dụng có tính đáp ứng cao, khả năng mở rộng tốt và hiệu suất tối ưu là vô cùng quan trọng. Khi phải xử lý số lượng lớn yêu cầu truyền nhận, đọc dữ liệu từ tệp tin hoặc truy vấn cơ sở dữ liệu hay như vấn đề bị chặn luồng chính có thể dẫn đến trải nghiệm người dùng kém. Lập trình bất đồng bộ giúp giải quyết vấn đề này bằng cách thực thi các tác vụ chạy ngầm mà không làm gián đoạn ứng dụng.
Trong C#, lập trình bất đồng bộ được triển khai một cách hiệu quả thông qua các từ khóa async và await. Cách tiếp cận này giúp cải thiện hiệu suất và khả năng đáp ứng của ứng dụng mà không cần sử dụng các kỹ thuật đa luồng phức tạp.
Tại sao cần lập trình bất đồng bộ?
Trước khi đi sâu vào cú pháp, cần hiểu lý do vì sao lập trình bất đồng bộ trở thành một phần quan trọng của các ứng dụng hiện đại.
Đầu tiên, các tác vụ chạy lâu như I/O tệp tin, gọi API hoặc truy vấn cơ sở dữ liệu có thể chặn luồng chính, khiến giao diện người dùng bị treo. Bằng cách xử lý chúng bất đồng bộ, chương trình có thể tiếp tục thực thi các tác vụ khác mà không bị gián đoạn.
Thứ hai, việc tránh chặn luồng không cần thiết giúp tối ưu hóa việc sử dụng tài nguyên hệ thống, tăng hiệu suất tổng thể của ứng dụng. Cuối cùng, người dùng mong đợi các ứng dụng phản hồi nhanh. Khi các tác vụ dài được xử lý bất đồng bộ, ứng dụng vẫn duy trì khả năng phản hồi ngay cả khi đang thực thi nhiều công việc phức tạp.
Lập trình bất đồng bộ với async/await không phải lúc nào cũng là lựa chọn tối ưu mà cần được áp dụng trong những tình huống thích hợp.
Cụ thể, async/await đặc biệt hữu ích khi xử lý các tác vụ I/O-bound, tức là những tác vụ liên quan đến truy cập mạng, đọc/ghi tệp tin, truy vấn cơ sở dữ liệu hoặc gọi API bên ngoài. Những tác vụ này có thể mất thời gian để hoàn thành, và nếu thực thi theo cách đồng bộ, chúng có thể chặn luồng chính, làm giảm hiệu suất ứng dụng.
Ngược lại, với các tác vụ CPU-bound, như xử lý thuật toán phức tạp, mã hóa/giải mã dữ liệu hoặc tính toán ma trận lớn, việc sử dụng async/await không mang lại nhiều lợi ích. Trong những trường hợp này, Task Parallel Library (TPL) hoặc Parallel.ForEach là lựa chọn tốt hơn để tận dụng khả năng đa luồng của CPU.
Một quy tắc chung là nếu một phương thức có thể bị tạm dừng trong quá trình thực thi do phải chờ kết quả từ nguồn bên ngoài, thì đó là ứng cử viên phù hợp để sử dụng async/await. Ví dụ điển hình là khi gọi API từ một dịch vụ web hoặc đọc dữ liệu từ tệp tin mà không muốn chặn ứng dụng. Tuy nhiên, nếu công việc cần hoàn thành ngay lập tức mà không có bất kỳ thao tác I/O nào, thì async/await không cần thiết và có thể dẫn đến hiệu suất kém hơn do chi phí quản lý luồng bất đồng bộ.

Cách hoạt động của async và await trong C#
Từ khóa async được sử dụng để định nghĩa một phương thức bất đồng bộ, trong khi await cho phép tạm dừng thực thi phương thức cho đến khi một tác vụ hoàn tất. Điều này giúp mã nguồn trở nên dễ đọc hơn và mang lại hiệu suất tốt hơn so với các kỹ thuật đa luồng truyền thống.
Một khái niệm quan trọng trong lập trình bất đồng bộ C# là Task-based Asynchronous Pattern (TAP), trong đó các phương thức trả về Task hoặc Task có thể được await
và thực thi bất đồng bộ. Khi một thao tác được await
, phương thức sẽ trả quyền điều khiển lại cho hàm gọi nó cho đến khi hoàn tất, giúp tránh việc chặn luồng.
Ví dụ cơ bản về async/await
public async Task<string> FetchDataAsync(string url)
{
using (HttpClient client = new HttpClient())
{
string result = await client.GetStringAsync(url);
return result;
}
}
Trong ví dụ trên, phương thức FetchDataAsync
thực hiện lấy dữ liệu từ một URL mà không chặn luồng chính. Từ khóa await
giúp chương trình chờ dữ liệu được tải về nhưng không làm gián đoạn toàn bộ ứng dụng.
Các thực tiễn tốt nhất trong lập trình bất đồng bộ
Để tận dụng tối đa sức mạnh của lập trình bất đồng bộ trong C#, cần tuân theo một số nguyên tắc và tránh các lỗi phổ biến.
Một sai lầm thường gặp là sử dụng các lệnh chặn đồng bộ trong phương thức async. Ví dụ, sử dụng .Result
trong một phương thức bất đồng bộ sẽ làm mất đi lợi ích của việc lập trình bất đồng bộ.
Sai cách:
public async Task<string> GetData()
{
var data = httpClient.GetStringAsync("<http://ntechdevelopers.com>").Result; // Chặn luồng
return data;
}
Cách đúng:
public async Task<string> GetData()
{
var data = await httpClient.GetStringAsync("<http://ntechdevelopers.com>");
return data;
}
Ngoài ra, ngoại trừ các trình xử lý sự kiện, các phương thức async nên trả về Task hoặc Task thay vì void để cho phép gọi và xử lý lỗi hiệu quả hơn.
public async Task PerformOperationAsync()
{
await Task.Delay(1000); // Mô phỏng một tác vụ bất đồng bộ
}
Một điểm tối ưu quan trọng là sử dụng ConfigureAwait(false) khi không cần trở lại ngữ cảnh gốc, đặc biệt trong các ứng dụng phía server để tránh chi phí không cần thiết.
public async Task<string> GetDataAsync()
{
var data = await httpClient.GetStringAsync("<http://ntechdevelopers.com>").ConfigureAwait(false);
return data;
}
Một lỗi phổ biến khác là sử dụng fire-and-forget mà không quản lý tác vụ, có thể dẫn đến lỗi không mong muốn hoặc rò rỉ tài nguyên.
public async Task PerformTasksAsync()
{
var task1 = DoWorkAsync(); // Không nên bỏ qua await
await task1;
}
Trong trường hợp có nhiều tác vụ độc lập cần chạy song song, sử dụng Task.WhenAll để tối ưu hóa hiệu suất thay vì thực thi tuần tự.
public async Task FetchMultipleUrlsAsync(string[] urls)
{
var tasks = urls.Select(url => httpClient.GetStringAsync(url));
var results = await Task.WhenAll(tasks); // Thực thi tất cả các tác vụ song song
}
Khi làm việc với các phương thức async, cần đảm bảo xử lý lỗi một cách hiệu quả để tránh lỗi runtime hoặc mất dữ liệu.
public async Task<string> FetchDataWithErrorHandlingAsync(string url)
{
try
{
var data = await httpClient.GetStringAsync(url);
return data;
}
catch (HttpRequestException ex)
{
// Ghi log hoặc xử lý lỗi
return "Lỗi khi lấy dữ liệu";
}
}
Phân biệt lập trình bất đồng bộ và lập trình song song
Lập trình bất đồng bộ và lập trình song song đều giúp cải thiện hiệu suất, nhưng chúng phục vụ các mục đích khác nhau. Lập trình bất đồng bộ tập trung vào việc tối ưu hóa các thao tác I/O, trong khi lập trình song song (sử dụng Task Parallel Library, Parallel.ForEach
) được thiết kế để thực thi các tác vụ tính toán nặng trên nhiều luồng.
Bằng cách áp dụng lập trình bất đồng bộ, chúng ta có thể đảm bảo rằng ứng dụng luôn duy trì trạng thái phản hồi tốt trong khi xử lý các tác vụ dài.
Lập trình bất đồng bộ với async/await trong C# mang lại cách tiếp cận đơn giản nhưng mạnh mẽ để nâng cao hiệu suất và khả năng đáp ứng của ứng dụng. Khi tuân theo các thực tiễn tốt nhất như tránh chặn đồng bộ, sử dụng Task thay vì void, tận dụng ConfigureAwait(false) và thực thi song song khi cần thiết, bạn có thể xây dựng các ứng dụng linh hoạt và hiệu quả hơn.
Anh em đã áp dụng lập trình bất đồng bộ trong các dự án C# của mình như thế nào? Hãy chia sẻ những kinh nghiệm và phương pháp tối ưu hóa mã async của bạn!