6.2 - Task và async/await
Tóm tắt:
Tasklà lời hứa về một kết quả sẽ có trong tương lai, không phải một thread. Đây là điểm hay bị hiểu sai nhất:awaitmộtTaskkhông tạo thread nào cả — với I/O thật sự, không có thread nào đang chờ, chỉ có một thông báo được đăng ký với hệ điều hành. Từ khoáasyncbiến phương thức thành một máy trạng thái do trình biên dịch sinh ra, và mỗiawaitlà một điểm mà phương thức có thể tạm dừng rồi tiếp tục. Bài này cũng nói kỹ vềasync void— thứ duy nhất trong C# mà ngoại lệ bên trong nó làm sập cả tiến trình thay vì đi lên chỗ gọi, vàtry/catchbao quanh không bắt được.
Mục tiêu bài học
Sau bài này bạn có thể:
- Giải thích
Tasklà gì và vì saoawaitkhông tạo thread. - Mô tả máy trạng thái mà trình biên dịch sinh ra và hai hệ quả của nó.
- Nêu ba vấn đề của
async voidvà biết ngoại lệ duy nhất được phép dùng. - Chọn giữa
TaskvàValueTasktheo tiêu chí rõ ràng. - Biết khi nào bỏ được
async/awaitvà khi nào tuyệt đối không.
Cần biết trước: bài 6.1 (thread, thread pool, vì sao cần async); try/catch và using (Module 4). Vài ví dụ dùng _db.Orders của EF Core như ở bài 6.1; EF Core được dạy ở bài 8.10.
Nội dung bài học
6.2.1 — Task là lời hứa, không phải thread
Task task = DoSomethingAsync(); // lời hứa: "sẽ xong"
Task<int> t2 = GetCountAsync(); // lời hứa: "sẽ có một int"
int count = await t2; // chờ lời hứa được thực hiệnMột Task kết thúc ở một trong ba trạng thái: hoàn thành, thất bại (mang theo ngoại lệ), hoặc bị huỷ.
Điểm quan trọng nhất:
awaitkhông tạo thread. Với I/O thật, không có thread nào đang chờ cả.
Khi bạn await một truy vấn database:
- Lệnh được gửi xuống hệ điều hành.
- Phương thức trả về ngay; thread quay lại thread pool và phục vụ việc khác.
- Khi dữ liệu về, hệ điều hành thông báo, và một thread bất kỳ từ pool chạy tiếp phần còn lại.
Không có "thread chờ" nào tồn tại giữa bước 2 và 3. Đó là toàn bộ lý do async giúp được ở bài 6.1.
6.2.2 — async/await là máy trạng thái
public async Task<Order?> GetOrderAsync(int id, CancellationToken ct)
{
var order = await _db.Orders.FindAsync([id], ct); // điểm tạm dừng 1
if (order is null) return null;
order.Lines = await _lines.GetAsync(id, ct); // điểm tạm dừng 2
return order;
}Trình biên dịch biến phương thức trên thành một struct máy trạng thái với các trạng thái -1, 0, 1. Mỗi await là một điểm máy có thể dừng và quay lại sau.


Hai hệ quả thực tế:
- Biến cục bộ sống qua
awaitđược nâng thành field của máy trạng thái, nên chúng nằm trên heap. Đây chính là chi phí cấp phát của async. - Code sau
awaitcó thể chạy trên thread khác. Đừng giả định thread không đổi, và đừng dùngThreadLocalhay[ThreadStatic]qua ranh giớiawait.
6.2.3 — async void: thứ duy nhất nên tránh tuyệt đối
// NGUY HIỂM
public async void ProcessOrder(int id)
{
await _service.ProcessAsync(id);
}Ba vấn đề, và cái thứ hai nghiêm trọng nhất:
1. Không await được. Người gọi không biết nó xong chưa, không đợi được.
2. Ngoại lệ làm sập tiến trình. Với async Task, ngoại lệ được lưu vào Task và ném lại tại chỗ await. Với async void không có Task nào để lưu, nên ngoại lệ được ném lại trên SynchronizationContext lúc gọi (cơ chế này nói kỹ ở bài 6.3). Console và ASP.NET Core không có context đó, nên ngoại lệ bị ném trên một thread của thread pool, không ai bắt, và tiến trình chết.
try
{
ProcessOrder(42); // async void
}
catch (Exception)
{
// KHÔNG BAO GIỜ chạy tới đây — ngoại lệ đã đi đường khác
}3. Không kiểm thử được. Bài test không có gì để await nên nó kết thúc trước khi công việc xong.
Ngoại lệ duy nhất được phép: event handler của UI framework, nơi chữ ký do framework quy định — và ngay cả khi đó vẫn phải tự bắt mọi ngoại lệ bên trong.
private async void Button_Click(object sender, EventArgs e)
{
try { await DoWorkAsync(); }
catch (Exception ex) { ShowError(ex); }
}Trong backend, không có trường hợp nào cần async void. Thấy nó là thấy lỗi.
6.2.4 — Task hay ValueTask
// Task — luôn cấp phát một object trên heap
public async Task<int> GetCountAsync() { }
// ValueTask — không cấp phát khi kết quả đã có sẵn
public async ValueTask<int> GetCountAsync()
{
if (_cache.TryGetValue("count", out int cached))
return cached; // đường nhanh: 0 cấp phát
return await _db.Orders.CountAsync(); // đường chậm: như Task
}ValueTask chỉ đáng dùng khi phần lớn lời gọi trả về ngay — điển hình là hàm có cache. Nếu gần như luôn phải đi I/O thật, nó không tiết kiệm gì mà còn phức tạp hơn.
Ba hạn chế phải nhớ:
ValueTask<int> vt = GetCountAsync();
int a = await vt;
int b = await vt; // SAI — chỉ await được MỘT lần
var t = vt.AsTask(); // cần dùng nhiều lần thì chuyển sang Task trước- Chỉ
awaitđược một lần. - Không
awaitđồng thời từ nhiều chỗ. - Không gọi
.Resultkhi chưa hoàn thành.
Quy tắc mặc định: dùng Task. Chỉ chuyển sang ValueTask khi đã đo và thấy cấp phát là nút cổ chai thật.
6.2.5 — Khi nào bỏ được async/await
// Có async/await — sinh máy trạng thái
public async Task<Order?> GetAsync(int id, CancellationToken ct)
=> await _repo.GetAsync(id, ct);
// Không có — trả thẳng Task, không sinh máy trạng thái
public Task<Order?> GetAsync(int id, CancellationToken ct)
=> _repo.GetAsync(id, ct);Cách thứ hai nhẹ hơn. Nhưng ba trường hợp không được bỏ:
// 1. Có using — bỏ await thì db bị giải phóng TRƯỚC khi truy vấn xong
public async Task<Order?> GetAsync(int id, CancellationToken ct)
{
using var db = _factory.CreateDbContext();
return await db.Orders.FindAsync([id], ct);
}
// 2. Có try/catch — bỏ await thì ngoại lệ không bị bắt ở đây
public async Task<Order?> GetAsync(int id, CancellationToken ct)
{
try { return await _repo.GetAsync(id, ct); }
catch (DbException ex) { throw new DataAccessException(ex); } // System.Data.Common.DbException
}
// 3. Cần dùng kết quả trước khi trả về
public async Task<int> CountAsync(CancellationToken ct)
{
var orders = await _repo.GetAllAsync(ct);
return orders.Count;
}Trường hợp 1 là cái bẫy thật sự: bỏ await thì using giải phóng db ngay khi phương thức trả về Task, trong khi truy vấn còn đang chạy — và bạn nhận ObjectDisposedException ở một chỗ hoàn toàn khác, rất khó lần.
Trừ khi đang tối ưu một đường chạy rất nóng, cứ để async/await. An toàn hơn, và stack trace khi lỗi cũng đọc được hơn.
6.2.6 — Quy ước chữ ký
public Task<Order?> GetOrderAsync(int id, CancellationToken ct = default)Ba quy ước nên theo:
- Hậu tố
Asynccho phương thức trảTask, để phân biệt ngay khi đọc. CancellationTokenlà tham số cuối. Chi tiết ở bài 6.4.- Trả
Task<T>, không trảvoid.
6.2.7 — Rà lại code của bạn
Danh sách rà soát Task và async/await
- Không có async void nào trong toàn bộ code backend.
- Mọi phương thức async đều có hậu tố Async trong tên.
- CancellationToken là tham số cuối của mọi phương thức async có I/O.
- Không giả định thread không đổi sau một await.
- Không dùng ThreadLocal hay ThreadStatic qua ranh giới await.
- Chỉ dùng ValueTask ở nơi đã đo và thấy cấp phát là nút cổ chai.
- Không bỏ await trong phương thức có using hoặc try/catch.
Bài tập áp dụng
Bài 1 — Nhìn vào máy trạng thái
Viết một phương thức async có hai await, build ở chế độ Release, mở .dll bằng ILSpy và tìm struct máy trạng thái. Đếm số trạng thái.
Tiêu chí hoàn thành: bạn chỉ ra được trường <>1__state và giải thích ý nghĩa của các giá trị nó nhận.
Gợi ý và lời giải — Bài 1
Gợi ý. Không cần cài ILSpy. Trang sharplab.io hiển thị ngay mã mà trình biên dịch sinh ra — chọn chế độ C# ở menu bên phải để xem bản C# tương đương đã được giải đường.
Lời giải — mã nguồn:
public async Task<int> ProcessAsync(int id, CancellationToken ct)
{
var customer = await GetCustomerAsync(id, ct); // await thứ nhất
var orders = await GetOrdersAsync(customer.Id, ct); // await thứ hai
return orders.Count;
}Thứ trình biên dịch sinh ra, rút gọn:
[CompilerGenerated]
private struct <ProcessAsync>d__0 : IAsyncStateMachine
{
public int <>1__state; // trạng thái hiện tại
public AsyncTaskMethodBuilder<int> <>t__builder;
public int id; // tham số được nâng thành trường
public CancellationToken ct;
private Customer <customer>5__2; // biến cục bộ cũng vậy
private TaskAwaiter<Customer> <>u__1;
private void MoveNext()
{
switch (<>1__state)
{
case -1: /* chưa bắt đầu */ break;
case 0: /* quay lại sau await thứ nhất */ break;
case 1: /* quay lại sau await thứ hai */ break;
}
// ...
}
}Các giá trị của <>1__state:
| Giá trị | Nghĩa |
|---|---|
-1 | Chưa chạy, hoặc đang chạy đồng bộ |
0 | Đang tạm dừng ở await thứ nhất |
1 | Đang tạm dừng ở await thứ hai |
-2 | Đã hoàn tất |
Với n lệnh await, máy trạng thái có n trạng thái tạm dừng, cộng hai trạng thái đặc biệt cho lúc bắt đầu và lúc kết thúc.
Ba điều đọc ra được, quan trọng hơn bản thân cấu trúc:
Biến cục bộ trở thành trường của struct. Chúng phải sống sót qua các lần tạm dừng, nên không thể nằm trên ngăn xếp. Đây là lý do phương thức
asynccó nhiều biến cục bộ tốn nhiều bộ nhớ hơn phương thức thường — và là lý do nên tránh giữ mảng lớn làm biến cục bộ trong phương thứcasync.Không có luồng nào trong cấu trúc này. Toàn bộ máy trạng thái chỉ là một
switchvà một bộ dựngTask. Khiawaitgặp một thao tác chưa xong, nó đăng ký một hàm gọi lại rồi trả về. Không có luồng nào ngồi chờ — đây chính là bằng chứng cụ thể cho câu "không có luồng nào cả".MoveNextlà thứ bạn thấy trong dấu vết ngăn xếp. Khi đọc log lỗi và gặpat Crm.Services.OrderService.<ProcessAsync>d__0.MoveNext(), giờ bạn biết đó là gì: máy trạng thái của phương thứcProcessAsync, chứ không phải một lỗi lạ.
Bài thử đáng làm thêm. Thêm một vòng foreach có await bên trong và xem số trạng thái tăng thế nào. Bạn sẽ thấy trình biên dịch sinh ra logic phức tạp hơn nhiều để xử lý việc quay lại giữa chừng một vòng lặp.
Bài 2 — Chứng minh async void không bắt được ngoại lệ
Viết một async void ném ngoại lệ, gọi nó trong khối try/catch, xác nhận catch không chạy và ghi lại hành vi của tiến trình.
Tiêu chí hoàn thành: bạn nêu được ngoại lệ chính đáng duy nhất cho việc dùng async void.
Gợi ý và lời giải — Bài 2
Gợi ý. try/catch bắt được ngoại lệ truyền qua ngăn xếp gọi. Một phương thức async void trả về ngay lập tức tại await đầu tiên — vậy lúc ngoại lệ xảy ra, ngăn xếp gọi còn khối try đó không?
Lời giải.
try
{
ThrowFromAsyncVoid(); // trả về ngay tại await đầu tiên
await Task.Delay(500); // đợi để ngoại lệ kịp xảy ra
Console.WriteLine("Không có gì bị bắt");
}
catch (Exception ex)
{
Console.WriteLine($"Bắt được: {ex.Message}"); // KHÔNG BAO GIỜ CHẠY
}
static async void ThrowFromAsyncVoid()
{
await Task.Delay(100);
throw new InvalidOperationException("Lỗi từ async void");
}Kết quả chạy trên .NET 10: cả hai dòng Console.WriteLine đều không chạy. Console không có SynchronizationContext, nên ngoại lệ bị ném trên một thread của thread pool và tiến trình bị kết thúc sau khoảng 100 ms:
Unhandled exception. System.InvalidOperationException: Lỗi từ async void
at Program.<<Main>$>g__ThrowFromAsyncVoid|0_0() in ...\Program.cs:line 15
at System.Threading.Tasks.Task.<>c.<ThrowAsync>b__124_1(Object state)
at System.Threading.ThreadPoolWorkQueue.Dispatch()
at System.Threading.PortableThreadPool.WorkerThread.WorkerThreadStart()Dòng ThreadPoolWorkQueue.Dispatch cho thấy ngoại lệ bị ném lại trên thread pool, hoàn toàn ngoài khối try của bạn.
Vì sao. So sánh hai chữ ký:
async Task FailTaskAsync() { await Task.Delay(100); throw new Exception(); }
async void FailVoidAsync() { await Task.Delay(100); throw new Exception(); }- Với
async Task, ngoại lệ được lưu vào đối tượngTask. Khi bạnawaittask đó, ngoại lệ được ném lại tại điểmawait— nơi khốitry/catchcủa bạn đang bao quanh. - Với
async void, không cóTasknào để lưu ngoại lệ vào. Runtime không còn lựa chọn nào ngoài việc đẩy nó lên như một ngoại lệ chưa xử lý.
Ba vấn đề của async void:
| Vấn đề | Hậu quả |
|---|---|
| Không bắt được ngoại lệ | Tiến trình chết |
Không await được | Không biết khi nào xong, không kiểm thử được |
| Không biết đã hoàn tất chưa | Ứng dụng có thể tắt khi công việc còn dở |
Ngoại lệ chính đáng duy nhất: trình xử lý sự kiện.
// Bắt buộc phải là void vì chữ ký của EventHandler yêu cầu vậy
private async void Button_Click(object sender, EventArgs e)
{
try
{
await SaveDataAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Lỗi khi lưu");
ShowError(ex.Message);
}
}Hai điều bắt buộc khi buộc phải dùng async void:
- Bọc toàn bộ thân hàm trong
try/catch. Đây không phải khuyến nghị mà là bắt buộc — không có nó, một lỗi nhỏ làm sập cả ứng dụng. - Không để ngoại lệ nào thoát ra. Kể cả trong khối
catch.
Trong ASP.NET Core thì sao. Gần như không bao giờ cần async void, vì mọi điểm vào đều nhận Task. Nếu bạn thấy async void trong code backend, gần như chắc chắn đó là lỗi — thường là do quên gõ Task:
public async void ProcessAsync() { } // gõ thiếu, trình biên dịch chấp nhận
public async Task ProcessAsync() { } // đúngCách phòng. Bật quy tắc phân tích và biến thành lỗi:
<WarningsAsErrors>$(WarningsAsErrors);VSTHRD100</WarningsAsErrors>Quy tắc VSTHRD100 của gói Microsoft.VisualStudio.Threading.Analyzers cảnh báo mọi async void không phải trình xử lý sự kiện.
Bài 3 — Tái hiện bẫy using với Task
Viết phương thức có using var db = ... rồi trả thẳng Task không await. Gọi nó và ghi lại ngoại lệ cùng vị trí nó xuất hiện.
Tiêu chí hoàn thành: bạn giải thích được chính xác thứ tự giữa việc giải phóng tài nguyên và việc tác vụ hoàn tất.
Gợi ý và lời giải — Bài 3
Gợi ý. Bỏ await trước khi trả về Task là một tối ưu nhỏ hợp lệ — trong một số trường hợp. Câu hỏi là: khi nào thì khối using kết thúc?
Lời giải.
// Resource.cs
public class Resource : IDisposable
{
public bool IsDisposed;
public void Dispose() => IsDisposed = true;
public string Read() => IsDisposed ? throw new ObjectDisposedException(nameof(Resource)) : "ok";
}// Program.cs
static Task<string> UsingTrap()
{
using var r = new Resource();
return Task.Delay(30).ContinueWith(_ => r.Read()); // KHÔNG await
}
try { Console.WriteLine(await UsingTrap()); }
catch (Exception ex) { Console.WriteLine($"bẫy using -> {ex.GetType().Name}"); }Dán vào một file Program.cs thì khối Program.cs đặt lên đầu, khối khai báo đặt xuống cuối (C# bắt buộc, nếu không sẽ báo lỗi CS8803).
ContinueWith đăng ký một đoạn code chạy sau khi Task.Delay xong, giống phần sau await nhưng không giữ phương thức lại chờ.
Kết quả chạy trên .NET 10:
bẫy using -> ObjectDisposedExceptionThứ tự chính xác của các sự kiện:
1. Tạo Resource
2. Task.Delay(30) khởi động, trả về một Task CHƯA hoàn tất
3. return -> khối using KẾT THÚC -> Dispose() chạy NGAY
4. Phương thức trả về Task cho bên gọi
5. 30 mili-giây sau, Task.Delay hoàn tất
6. ContinueWith chạy, gọi r.Read() -> r ĐÃ bị giải phóng -> némTài nguyên bị giải phóng ở bước 3, nhưng công việc dùng nó chỉ chạy ở bước 6. using không biết gì về việc Task chưa xong — nó chỉ thấy phương thức đã trả về.
Sửa bằng cách thêm await:
static async Task<string> Correct()
{
using var r = new Resource();
await Task.Delay(30);
return r.Read(); // using chỉ kết thúc SAU dòng này
}Thêm await biến phương thức thành máy trạng thái, và khối using chỉ kết thúc khi máy trạng thái chạy tới cuối.
Khi nào bỏ await là hợp lệ. Chỉ khi phương thức không có using, try/catch, hay lock bao quanh:
// HỢP LỆ — chỉ chuyển tiếp, không có tài nguyên nào cần giải phóng
public Task<Customer?> GetAsync(int id, CancellationToken ct)
=> _repo.GetByIdAsync(id, ct);Bảng quyết định:
| Trong phương thức có | Bỏ await được? |
|---|---|
| Chỉ chuyển tiếp lời gọi | Có — tiết kiệm một máy trạng thái |
using hoặc await using | Không |
try/catch hoặc try/finally | Không — ngoại lệ sẽ không bị bắt |
lock | Không |
| Xử lý kết quả trả về | Không |
Cái giá của việc bỏ await khi có try/catch. Cùng một cơ chế, hậu quả khác:
// SAI — catch không bao giờ chạy
static Task<string> GetWithFallbackAsync()
{
try { return CallApiAsync(); }
catch (HttpRequestException) { return Task.FromResult("mặc định"); }
}Phương thức trả về ngay, ngoại lệ xảy ra sau đó và nằm trong Task — nhưng khối try đã kết thúc từ lâu.
Lợi ích của việc bỏ await có đáng không. Nó tiết kiệm đúng một máy trạng thái, tức khoảng 17 nano-giây theo số đo trên .NET 10 ở bài 6.1. Đổi lại là một lớp lỗi khó tìm. Khuyến nghị: giữ await cho tới khi đo được rằng nó là nút thắt — và với code backend thông thường, nó sẽ không bao giờ là nút thắt.
Một đánh đổi nữa ít người để ý: bỏ await làm dấu vết ngăn xếp mất một khung, khiến việc chẩn đoán lỗi khó hơn.
Tự kiểm tra
await có tạo thread mới không?
Không. Với I/O thật thì không có thread nào đang chờ cả: lệnh được gửi xuống hệ điều hành, phương thức trả về ngay và thread quay lại pool, rồi khi dữ liệu về thì một thread bất kỳ từ pool chạy tiếp phần còn lại. Task là lời hứa về một kết quả, không phải một thread.
async biến phương thức thành gì?
Thành một máy trạng thái do trình biên dịch sinh ra, với mỗi await là một điểm có thể tạm dừng rồi tiếp tục. Hai hệ quả: biến cục bộ sống qua await được nâng thành field nên nằm trên heap, và code sau await có thể chạy trên thread khác.
Vì sao async void nguy hiểm?
Ba lý do. Không await được nên người gọi không biết nó xong chưa. Ngoại lệ không có Task nào để lưu nên bị ném lại ngoài luồng gọi (trên SynchronizationContext, hoặc trên thread pool khi không có context) và làm sập tiến trình, kể cả khi lời gọi nằm trong try catch. Và không kiểm thử được vì bài test kết thúc trước khi công việc xong.
Khi nào được phép dùng async void?
Chỉ với event handler của UI framework, nơi chữ ký do framework quy định, và ngay cả khi đó vẫn phải tự bắt mọi ngoại lệ bên trong. Trong backend thì không có trường hợp nào cần async void; thấy nó là thấy lỗi.
ValueTask dùng khi nào và có hạn chế gì?
Chỉ khi phần lớn lời gọi trả về ngay, điển hình là hàm có cache, vì nó tránh được việc cấp phát trên heap ở đường nhanh. Ba hạn chế: chỉ await được một lần, không await đồng thời từ nhiều chỗ, và không gọi Result khi chưa hoàn thành. Mặc định vẫn nên dùng Task.
Khi nào KHÔNG được bỏ async await để trả thẳng Task?
Ba trường hợp. Khi có using, vì bỏ await thì đối tượng bị giải phóng trước lúc truy vấn xong và bạn nhận ObjectDisposedException ở một chỗ hoàn toàn khác. Khi có try catch, vì ngoại lệ sẽ không bị bắt ở đây nữa. Và khi cần dùng kết quả trước khi trả về.
Kết luận
Ba điều đáng nhớ nhất:
Tasklà lời hứa, không phải thread.awaitmột thao tác I/O không giữ thread nào.async voidlà lỗi trong backend. Ngoại lệ trong nó làm sập tiến trình vàtry/catchkhông cứu được.- Bỏ
awaitchỉ an toàn khi không cóusingvà không cótry/catch. Khi nghi ngờ, cứ để nguyên.
Tham khảo
- Task asynchronous programming model
- await operator
- Asynchronous programming patterns
- ASP.NET Core best practices
Điều hướng
- Bài trước: 6.1 — Tại sao cần Async?
- Bài tiếp theo: 6.3 — ConfigureAwait
- Về module: Trang mục lục
Bài liên quan
- Gọi .Result khi nào thì deadlock, khi nào thì không? — Gọi .Result hay .Wait() trên một Task treo cứng ứng dụng WPF và ASP.NET Framework, nhưng chạy bình thường trong console và ASP.NET Core.