Hầu hết các thao tác chậm trong một chương trình thực chất là chờ đợi: chờ máy chủ web trả lời, chờ cơ sở dữ liệu trả về các hàng, chờ đọc xong một tệp. async và await cho phép một phương thức tạm dừng trong lúc chờ như vậy mà không giữ chân một thread nào, rồi tiếp tục từ chỗ đã dừng. Code vẫn đọc từ trên xuống dưới như code bình thường.
Phương thức async đầu tiên
Một phương thức async trả về Task (không có kết quả) hoặc Task<T> (kết quả kiểu T). Bên trong nó, await chờ một task khác và đưa bạn kết quả của task đó.
Đầu ra:
Tea 2.50, cake 4.00, total 6.50
GetPriceAsync viết return 2.50m, và trình biên dịch bọc giá trị đó vào Task<decimal> mà phương thức trả về. await lại mở gói nó ra. Task.Delay là phiên bản async của Thread.Sleep: nó hoàn thành khi hết thời gian, mà không chặn thread nào trong lúc chờ.
Từ C# 7.1, chính Main cũng có thể là async, và đó là cách bạn viết chương trình này trong .NET hiện đại:
static async Task Main()
{
decimal tea = await GetPriceAsync("tea");
Console.WriteLine(tea);
}
Các ví dụ chạy được trong trang này gọi phương thức async RunAsync từ một Main bình thường bằng .GetAwaiter().GetResult(), cũng chính là code mà trình biên dịch sinh ra cho một Main async. Trong ứng dụng console, cách này an toàn; phần nói về deadlock sẽ giải thích vì sao cùng lời gọi đó lại nguy hiểm trong code giao diện.
Điều gì xảy ra tại một await
Một phương thức async chạy đồng bộ cho đến await đầu tiên trên một task chưa hoàn thành. Tại điểm đó, nó trả về một Task cho bên gọi, và phần còn lại của phương thức trở thành một continuation, sẽ chạy khi task đang chờ hoàn thành.
Đầu ra:
Calling DownloadAsync
Download: starting
DownloadAsync returned a task; doing other work
Download: finished
Download awaited
"Download: starting" được in ra trước khi DownloadAsync trả về, vì mọi thứ trước await đầu tiên đều chạy trên thread của bên gọi. Sau đó phương thức trả về một task chưa xong, bên gọi tiếp tục chạy, và "Download: finished" chỉ xuất hiện khi hết thời gian delay. Gọi một phương thức async là khởi động nó; await nó là cách bạn chờ kết quả.
async không tạo ra thread mới. Trong lúc phương thức tạm dừng, không có thread nào đứng chờ nó cả, vì vậy một máy chủ có thể có hàng nghìn request đang chờ cơ sở dữ liệu mà chỉ cần vài thread. Với công việc nặng về CPU cần chạy trên thread khác, hãy dùng Task.Run, được trình bày trong trang task.
Chạy đồng thời các thao tác với Task.WhenAll
Await từng lời gọi một là chạy tuần tự: mỗi lời gọi chỉ bắt đầu sau khi lời gọi trước đã xong. Khi các thao tác không phụ thuộc vào nhau, hãy khởi động tất cả rồi chờ chúng cùng nhau bằng Task.WhenAll:
Đầu ra mẫu:
Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms
Phiên bản tuần tự mất tổng thời gian của cả ba lần chờ, còn phiên bản đồng thời chỉ mất khoảng thời gian của thao tác chậm nhất. Task.WhenAll trả về kết quả theo đúng thứ tự các task bạn truyền vào, bất kể task nào xong trước. Nó cũng dùng được với một danh sách, đây là dạng thường gặp khi cần "lấy từng mục": await Task.WhenAll(ids.Select(id => FetchAsync(id))).
Task.WhenAny là phương thức tương ứng, hoàn thành ngay khi task đầu tiên xong, hữu ích cho timeout và kiểu "câu trả lời đầu tiên thắng".
Ngoại lệ trong code async
Một ngoại lệ (exception) ném ra bên trong phương thức async được lưu trong task trả về và được ném lại khi task đó được await, nên một khối try/catch bình thường quanh await vẫn hoạt động:
Đầu ra:
Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7
Lời gọi LoadProfileAsync(-1) không ném ngoại lệ: ngoại lệ nằm trong task cho đến khi có thứ gì đó await nó. Một task không bao giờ được await sẽ giấu hoàn toàn ngoại lệ của nó, thêm một lý do để luôn await những gì bạn khởi động. Với WhenAll, await ném lại ngoại lệ đầu tiên, còn thuộc tính Exception của task tổng hợp (một AggregateException) chứa tất cả. Đọc ok.Result ở đó không sao vì ok đã hoàn thành.
async void: chỉ dành cho event handler
Một phương thức async cũng có thể trả về void. Hãy tránh cách này ở mọi nơi trừ event handler:
// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
await db.InsertAsync(order); // if this throws, the process may crash
}
// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
await db.InsertAsync(order);
}
// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
try { await SaveAsync(currentOrder); }
catch (Exception ex) { ShowError(ex); }
}
Với async void, không có task nào để await, nên bên gọi chạy tiếp trước khi công việc xong, và ngoại lệ ném ra bên trong được ném thẳng lên synchronization context (hoặc thread pool), nơi không khối try/catch nào của bên gọi chạm tới được. Trong ứng dụng console hay máy chủ, điều này thường làm dừng tiến trình. Bên trong một event handler async void, hãy tự bắt mọi ngoại lệ.
Một lỗi liên quan là gọi phương thức async mà không có await. Trình biên dịch sẽ cảnh báo (CS4014), và phương thức chạy ngầm mà không ai theo dõi kết quả hay lỗi của nó.
Deadlock do .Result và .Wait()
Chặn một task bằng .Result hoặc .Wait() là chỗ code async hay hỏng nhất. Trong ứng dụng có synchronization context (WinForms, WPF, MAUI, ASP.NET cổ điển), trình tự diễn ra như sau:
- Thread giao diện gọi
GetDataAsync().Resultvà bị chặn, chờ task. - Bên trong
GetDataAsync, mộtawaithoàn thành. Mặc định, continuation của nó phải chạy trên context nơi nó bắt đầu: thread giao diện. - Thread giao diện đang bị chặn ở bước 1, nên continuation không bao giờ chạy, nên task không bao giờ hoàn thành, nên bước 1 không bao giờ kết thúc.
Ứng dụng bị đơ mà không có ngoại lệ nào. Ứng dụng console và ASP.NET Core không có context như vậy, đó là lý do cùng một đoạn code chạy được trong chương trình thử nhưng lại treo trong ứng dụng desktop. Cách khắc phục:
- Dùng
awaitxuyên suốt chuỗi lời gọi thay vì chặn ("async từ đầu đến cuối"). Event handler có thể làasync voidcho mục đích này. - Trong code thư viện không cần quay về context của bên gọi, hãy viết
await SomethingAsync().ConfigureAwait(false);. Khi đó continuation chạy trên thread pool thay vì context đã lưu, và thư viện bỏ qua được một lần chuyển thread không cần thiết. Cách này chỉ bảo vệ được bên gọi đang chặn nếu mọiawaittrong chuỗi đều làm như vậy, nên hãy coi nó là thói quen tốt khi viết thư viện, không phải cách chữa cho.Result.
Code ứng dụng trong ASP.NET Core không cần ConfigureAwait(false), vì không có context nào để quay về.
Lỗi thường gặp
- Await từng lời gọi độc lập một. Hãy khởi động chúng, rồi
await Task.WhenAll(...). - Phương thức
async void. Hãy trả vềTask; chỉ dùngasync voidcho event handler. .Resultvà.Wait()trong code giao diện hoặc ASP.NET cổ điển. Chúng gây deadlock; hãy dùng await.- Quên
await. Công việc chạy mà không ai theo dõi và ngoại lệ của nó biến mất. - Bọc thao tác I/O trong
Task.Run. Một API async nhưFile.ReadAllTextAsynchayHttpClient.GetStringAsyncvốn đã giải phóng thread;Task.Runchỉ thêm một bước nhảy sang thread pool. - Nghĩ rằng
asyncnghĩa là "chạy trên thread khác". Nó có nghĩa là "có thể tạm dừng mà không chặn". Hãy dùngTask.Runcho công việc nặng về CPU.
Câu hỏi thường gặp
async và await hoạt động thế nào trong C#?
Đánh dấu một phương thức là async cho phép nó dùng await. Khi phương thức gặp await trên một task chưa hoàn thành, nó trả quyền điều khiển về bên gọi ngay lập tức, kèm theo một Task đại diện cho phần việc còn lại. Khi thao tác đang chờ hoàn thành, phương thức tiếp tục chạy từ sau await. Không có thread nào bị chặn trong lúc chờ.
Task và Task<T> khác nhau thế nào?
Task đại diện cho một thao tác không tạo ra giá trị, phiên bản async của một phương thức void. Task<T> đại diện cho thao tác tạo ra một giá trị kiểu T: await một Task<int> sẽ cho bạn giá trị int. Một phương thức async khai báo async Task<int> chỉ cần viết return 42;, và trình biên dịch sẽ bọc giá trị đó vào task.
Làm sao chạy nhiều thao tác async cùng lúc?
Khởi động tất cả trước, rồi chờ chúng cùng nhau: var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);. Viết await GetUserAsync(); await GetOrdersAsync(); sẽ chạy lần lượt từng cái, nên tổng thời gian là tổng của cả hai thay vì chỉ bằng thao tác lâu nhất.
Tại sao async void là cách viết không tốt trong C#?
Một phương thức async void không thể được await, nên bên gọi không biết khi nào nó xong, và ngoại lệ ném ra bên trong không thể được bên gọi bắt: ngoại lệ đó được ném lên synchronization context và thường làm sập tiến trình. Hãy trả về Task thay vào đó. async void chỉ tồn tại cho các event handler, vốn có chữ ký bắt buộc là void.
Tại sao .Result hoặc .Wait() gây ra deadlock?
Trong ứng dụng có synchronization context (WinForms, WPF, ASP.NET cổ điển), .Result chặn thread giao diện hoặc thread xử lý request, trong khi phần tiếp nối (continuation) của phương thức được await lại đang chờ để chạy trên chính thread đó. Hai bên chờ nhau mãi mãi. Hãy dùng await xuyên suốt chuỗi lời gọi thay vì chặn, và dùng ConfigureAwait(false) trong code thư viện.
Main có thể là async trong C# không?
Có, từ C# 7.1: static async Task Main() hoặc static async Task<int> Main(). Top-level statement (C# 9) có thể dùng await trực tiếp. Trong code cũ, cách tương đương là gọi MainAsync().GetAwaiter().GetResult() từ một Main bình thường, cách này an toàn ở đó vì ứng dụng console không có synchronization context.