6.7 — 5. Async Patterns
Năm mẫu giải năm bài toán mà async Task trần không giải được. IAsyncEnumerable<T> để trả dữ liệu dần thay vì nạp hết vào List — đổi bộ nhớ từ O(n) sang O(1). Async factory vì constructor không thể async, và .Result trong constructor là deadlock. IAsyncDisposable để dọn dẹp có I/O. Khử trùng lặp bằng Task trong cache — lưu Task<T> chứ không lưu T, để 50 request cùng lúc chỉ gọi database một lần. Và fire-and-forget, thứ gần như luôn bị làm sai.
Mục tiêu bài học
Sau bài này bạn có thể:
- Chuyển một endpoint trả
List<T>lớn sangIAsyncEnumerable<T>và biết khi nào không nên. - Viết async factory đúng cách, kể cả khi có DI.
- Dùng
IAsyncDisposablevàawait using. - Chống cache stampede bằng cách cache
Task<T>. - Chạy việc nền mà không mất exception và không bị cắt giữa chừng.
Nội dung bài học
6.7.1 — IAsyncEnumerable<T>: trả dần thay vì nạp hết
// TRẢ HẾT: 500.000 bản ghi nằm trong RAM trước khi ghi được dòng đầu tiên
public async Task<List<Customer>> GetAllAsync(CancellationToken ct)
=> await _db.Customers.Where(c => c.IsActive).ToListAsync(ct);
// TRẢ DẦN: mỗi lúc chỉ giữ một bản ghi
public IAsyncEnumerable<Customer> StreamAsync(CancellationToken ct)
=> _db.Customers.Where(c => c.IsActive).AsAsyncEnumerable();
Phía tiêu thụ:
await using var writer = new StreamWriter(path);
await writer.WriteLineAsync("Id,Name,Email");
await foreach (var c in StreamAsync(ct))
await writer.WriteLineAsync($"{c.Id},{c.Name},{c.Email}");
Bộ nhớ đi từ tỷ lệ với số bản ghi xuống còn hằng số, và byte đầu tiên ra sớm hơn rất nhiều.
Khi tự viết bằng yield return, CancellationToken cần thuộc tính [EnumeratorCancellation]:
public async IAsyncEnumerable<Customer> StreamAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var c in _db.Customers.AsAsyncEnumerable().WithCancellation(ct))
{
if (c.IsActive) yield return c;
}
}
Thiếu [EnumeratorCancellation] thì token bị bỏ qua im lặng — không lỗi biên dịch, chỉ là huỷ không bao giờ có tác dụng.
Ba trường hợp không nên dùng:
- Kết quả nhỏ (vài trăm bản ghi) —
Listđơn giản hơn và nhanh hơn. - Cần
Counthoặc cần duyệt hai lần —IAsyncEnumerablechỉ đi một chiều, một lần. - Connection phải mở suốt quá trình duyệt. Nếu người tiêu thụ chậm, bạn giữ connection database rất lâu.
Trong ASP.NET Core, trả thẳng IAsyncEnumerable<T> từ controller sẽ được stream thành JSON — nhưng nếu có exception xảy ra giữa chừng, HTTP status đã gửi 200 rồi và client nhận JSON cụt.
6.7.2 — Async factory: constructor không thể async
// SAI — .Result trong constructor la deadlock cho san
public ReportEngine(int templateId)
{
_template = _repo.LoadAsync(templateId).Result;
}
Constructor phải trả về đối tượng đã dựng xong, nên nó không thể async. Mẫu chuẩn là constructor private + static factory:
public sealed class ReportEngine
{
private readonly ReportTemplate _template;
private readonly IRenderer _renderer;
private ReportEngine(ReportTemplate template, IRenderer renderer)
=> (_template, _renderer) = (template, renderer);
public static async Task<ReportEngine> CreateAsync(
int templateId, ITemplateRepository repo, IRenderer renderer, CancellationToken ct = default)
{
var template = await repo.LoadAsync(templateId, ct)
?? throw new InvalidOperationException($"Không tìm thấy template {templateId}");
return new ReportEngine(template, renderer);
}
}
private constructor là phần quan trọng: nó khiến không thể tạo đối tượng ở trạng thái chưa khởi tạo.
Với DI, đừng đưa CreateAsync vào container. Đăng ký factory thay vì đối tượng:
builder.Services.AddScoped<IReportEngineFactory, ReportEngineFactory>();
Rồi await _factory.CreateAsync(templateId, ct) tại nơi cần. Chi tiết về vòng đời ở Module 7 — Dependency Injection.
6.7.3 — IAsyncDisposable và await using
public sealed class MessageProcessor : IAsyncDisposable
{
private readonly IConnection _connection;
public async ValueTask DisposeAsync()
{
await _connection.CloseAsync(); // đóng kết nối có I/O
await _connection.DisposeAsync();
}
}
// Sử dụng
await using var processor = new MessageProcessor(connection);
IDisposable bắt bạn phải block trong Dispose() nếu việc dọn dẹp có I/O — đúng cái bạn đang cố tránh. IAsyncDisposable giải quyết chuyện đó.
Hai lưu ý:
- Nếu lớp cài cả hai interface, người dùng
using(khôngawait) sẽ gọi bản đồng bộ. Trong ASP.NET Core, DI container gọiDisposeAsynckhi có. await using var x = ...giải phóng ở cuối scope;await using (var x = ...) { }giải phóng ở cuối khối. Dùng dạng khối khi cần giải phóng sớm.