Chuyển tới nội dung chính

6.3 — 1. Task và async/await

Tóm tắt

Task là 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: await một Task khô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á async biế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ỗi await là 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/catch bao 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 Task là gì và vì sao await khô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 void và biết ngoại lệ duy nhất được phép dùng.
  • Chọn giữa Task và ValueTask theo tiêu chí rõ ràng.
  • Biết khi nào bỏ được async/await và khi nào tuyệt đối không.

Nội dung bài học​

6.3.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ện

Mộ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:

await khô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:

  1. Lệnh được gửi xuống hệ điều hành.
  2. Phương thức trả về ngay; thread quay lại thread pool và phục vụ việc khác.
  3. 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.2.

6.3.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 await có thể chạy trên thread khác. Đừng giả định thread không đổi, và đừng dùng ThreadLocal hay [ThreadStatic] qua ranh giới await.

6.3.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ệ đi thẳng lên SynchronizationContext — trong ASP.NET Core nghĩa là 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.3.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
  1. Chỉ await được một lần.
  2. Không await đồng thời từ nhiều chỗ.
  3. Không gọi .Result khi 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.3.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 (SqlException ex) { throw new DataAccessException(ex); }
}

// 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.3.6 — Quy ước chữ ký​

public Task<Order?> GetOrderAsync(int id, CancellationToken ct = default)

Ba quy ước nên theo:

  • Hậu tố Async cho phương thức trả Task, để phân biệt ngay khi đọc.
  • CancellationToken là tham số cuối. Chi tiết ở bài 6.5.
  • Trả Task<T>, không trả void.

6.3.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
-1Chư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:

  1. 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 async có 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ức async.

  2. 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 switch và một bộ dựng Task. Khi await gặ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ả".

  3. MoveNext là thứ bạn thấy trong dấu vết ngăn xếp. Khi đọc log lỗi và gặp at 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ức ProcessAsync, 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.

static async void GayLoi()
{
await Task.Delay(100);
throw new InvalidOperationException("Lỗi từ async void");
}

try
{
GayLoi(); // 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
}

Kết quả: khối catch không chạy. Ngoại lệ được ném lên SynchronizationContext hiện tại, và trong ứng dụng console hay ASP.NET Core, điều đó có nghĩa là tiến trình bị kết thúc:

Unhandled exception. System.InvalidOperationException: Lỗi từ async void
at GayLoi()

Vì sao. So sánh hai chữ ký:

async Task  GayLoiTask()  { await Task.Delay(100); throw new Exception(); }
async void GayLoiVoid() { await Task.Delay(100); throw new Exception(); }
  • Với async Task, ngoại lệ được lưu vào đối tượng Task. Khi bạn await task đó, ngoại lệ được ném lại tại điểm await — nơi khối try/catch của bạn đang bao quanh.
  • Với async void, không có Task nà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 đượcKhô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:

  1. 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.
  2. 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() { } // đúng

Cá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.

public class Res : IDisposable
{
public bool IsDisposed;
public void Dispose() => IsDisposed = true;
public string Read() => IsDisposed ? throw new ObjectDisposedException(nameof(Res)) : "ok";
}

static Task<string> UsingTrap()
{
using var r = new Res();
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}"); }

Kết quả đo thật trên .NET 9:

bẫy using -> ObjectDisposedException

Thứ tự chính xác của các sự kiện:

1. Tạo Res
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ém

Tà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 Res();
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ọiCó — tiết kiệm một máy trạng thái
using hoặc await usingKhông
try/catch hoặc try/finallyKhông — ngoại lệ sẽ không bị bắt
lockKhô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> Sai()
{
try { return GoiApiAsync(); }
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 48 nano-giây theo số đo ở bài 6.2. Đổ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​

Câu hỏi thường gặp

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 đi thẳng lên SynchronizationContext 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:

  1. Task là lời hứa, không phải thread. await một thao tác I/O không giữ thread nào.
  2. async void là lỗi trong backend. Ngoại lệ trong nó làm sập tiến trình và try/catch không cứu được.
  3. Bỏ await chỉ an toàn khi không có using và không có try/catch. Khi nghi ngờ, cứ để nguyên.

Tham khảo​

Điều hướng​

Bài liên quan​