6.10 — Mở rộng và đào sâu
Những chủ đề async nối tiếp Module 6, xếp theo mức độ nên dùng. Mục đáng áp dụng ngay và bị bỏ qua nhiều nhất là TimeProvider: nó làm thời gian trở thành một dependency tiêm được, nên bạn test được logic hết hạn, TTL và lịch chạy mà không phải Thread.Sleep — thứ biến bộ test thành thứ không ai muốn chạy. Mục dễ bị dùng sai nhất là ValueTask: nó nhanh hơn Task trong một trường hợp cụ th ể, nhưng dùng nó sai cách — await hai lần, hoặc gọi .Result trên nó — gây lỗi không xác định, tức có thể chạy đúng trong test và sai trên production.
Mục tiêu bài học
Sau bài này bạn có thể:
- Dùng
TimeProviderđể test logic phụ thuộc thời gian. - Biết khi nào
ValueTaskđáng dùng và luật sử dụng nó. - Dùng
PeriodicTimercho vòng lặp định kỳ. - Hiểu async state machine đủ để đọc stack trace.
Nội dung bài học
6.10.1 — TimeProvider: dùng ngay
Điều kiện kích hoạt: ngay khi có logic phụ thuộc thời gian.
// Khó test — thời gian không kiểm soát được
public bool IsExpired() => DateTime.UtcNow > ExpiresAtUtc;
// Test phải Thread.Sleep — chậm và không đáng tin
// Tiêm TimeProvider
public sealed class TokenService(TimeProvider timeProvider)
{
public bool IsExpired(Token token)
=> timeProvider.GetUtcNow() > token.ExpiresAtUtc;
}
// Đăng ký
builder.Services.AddSingleton(TimeProvider.System);
// Test — kiểm soát thời gian hoàn toàn, chạy tức thì
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 3, 15, 10, 0, 0, TimeSpan.Zero));
var service = new TokenService(fakeTime);
var token = new Token { ExpiresAtUtc = fakeTime.GetUtcNow().AddMinutes(15) };
Assert.False(service.IsExpired(token));
fakeTime.Advance(TimeSpan.FromMinutes(16)); // "tua" thời gian
Assert.True(service.IsExpired(token));
Gói Microsoft.Extensions.TimeProvider.Testing cung cấp FakeTimeProvider. Test chạy trong micro giây thay vì 16 phút, và nó kiểm tra chính xác mốc hết hạn thay vì xấp xỉ.
TimeProvider cũng thay được Task.Delay và Timer, nên logic retry và lịch chạy cũng test được (bài 16.10).
6.10.2 — ValueTask: cẩn thận
Điều kiện kích hoạt: phương thức async được gọi rất nhiều lần và thường trả về đồng bộ.
// Trường hợp điển hình: cache hit thì không cần async thật
public async ValueTask<Lead?> GetAsync(LeadId id, CancellationToken ct)
{
if (_cache.TryGetValue(id, out Lead? cached))
return cached; // trả về NGAY, không cấp phát Task
return await _db.Leads.FindAsync([id], ct);
}
Task là một object trên heap. Nếu 95% lời gọi trả về ngay từ cache, bạn cấp phát 95% số Task một cách vô ích. ValueTask là struct nên trường hợp đồng bộ không cấp phát gì.
Ba luật bắt buộc khi dùng ValueTask:
// 1. Chỉ await MỘT lần
var vt = GetAsync(id, ct);
var a = await vt;
var b = await vt; // HÀNH VI KHÔNG XÁC ĐỊNH
// 2. KHÔNG gọi .Result hay .GetAwaiter().GetResult()
var lead = GetAsync(id, ct).Result; // có thể sai
// 3. Muốn dùng nhiều lần thì chuyển sang Task
var task = GetAsync(id, ct).AsTask();
var a = await task;
var b = await task; // OK
Lý do: ValueTask có thể tái sử dụng đối tượng nội bộ sau khi được await một lần. await lần hai có thể đọc trạng thái của một thao tác khác đã tái sử dụng cùng ô nhớ.
Điều nguy hiểm là lỗi này không xác định — nó có thể chạy đúng trong test, đúng dưới tải thấp, và sai dưới tải cao.
Khuyến nghị: dùng Task làm mặc định. Chỉ chuyển sang ValueTask khi profiler chỉ ra cấp phát Task là vấn đề thật ở một hot path cụ thể.
6.10.3 — PeriodicTimer
Điều kiện kích hoạt: vòng lặp chạy định kỳ trong BackgroundService.
// Cách cũ — độ trễ TÍCH LUỸ
while (!ct.IsCancellationRequested)
{
await ProcessAsync(ct); // tốn 2 giây
await Task.Delay(TimeSpan.FromSeconds(5), ct);
// Chu kỳ thực tế: 7 giây, không phải 5
}
// PeriodicTimer — chu kỳ ỔN ĐỊNH
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(ct))
{
await ProcessAsync(ct);
// Chu kỳ: 5 giây, bất kể ProcessAsync tốn bao lâu
}
Khác biệt tích luỹ: với Task.Delay, thời gian xử lý cộng vào chu kỳ, nên sau một giờ lịch chạy đã trôi rất xa so với dự kiến.
PeriodicTimer cũng xử lý huỷ gọn hơn: WaitForNextTickAsync trả về false khi bị huỷ, nên vòng lặp thoát tự nhiên mà không cần try/catch cho OperationCanceledException (bài 17.8).
Lưu ý: nếu XuLyAsync tốn lâu hơn chu kỳ, PeriodicTimer bỏ qua các tick đã lỡ chứ không dồn lại — đó là hành vi bạn muốn, vì dồn lại sẽ tạo ra một đợt xử lý ồ ạt.
6.10.4 — IAsyncDisposable
Điều kiện kích hoạt: lớp giữ tài nguyên mà việc đóng tốn thời gian.
public sealed class MessagePublisher : IAsyncDisposable
{
private readonly IConnection _connection;
public async ValueTask DisposeAsync()
{
await _connection.CloseAsync(); // đóng kết nối là thao tác I/O
await _connection.DisposeAsync();
}
}
await using var publisher = new MessagePublisher(...);
await using thay cho using. Nếu lớp cài cả hai interface, await using sẽ gọi DisposeAsync.
Đừng cài IAsyncDisposable nếu việc dọn dẹp là đồng bộ — IDisposable đơn giản hơn và đủ dùng.
6.10.5 — Async state machine
Điều kiện kích hoạt: hiểu để đọc stack trace, không cần tự viết gì.
Trình biên dịch biến mỗi phương thức async thành một máy trạng thái:
public async Task<Lead> GetAsync(LeadId id)
{
var lead = await _db.Leads.FindAsync(id); // diem dung 1
var activities = await _db.Activities...; // diem dung 2
return lead;
}
Trở thành một struct có:
- Trường lưu trạng thái hiện tại (đang ở điểm dừng nào)
- Trường lưu biến cục bộ (lead, activities)
- Phương thức MoveNext() chạy tiếp từ điểm dừng
Hai hệ quả thực tế:
1. Stack trace khó đọc hơn. Bạn sẽ thấy MoveNext() trong stack thay vì tên phương thức gốc. Từ .NET 6, stack trace của async đã được cải thiện đáng kể, nhưng vẫn khác với đồng bộ.
2. Mỗi await có chi phí nhỏ. Không đáng kể trong code nghiệp vụ, nhưng trong vòng lặp chạy hàng triệu lần thì đo được. Nếu một phương thức async không bao giờ thực sự chờ, cân nhắc bỏ async và trả về Task.FromResult:
// Không cần async — không có await nào
public Task<int> GetCountAsync() => Task.FromResult(_cache.Count);
6.10.6 — Những chủ đề khác
| Chủ đề | Nội dung | Ưu tiên |
|---|---|---|
Channel<T> | Hàng đợi producer-consumer trong tiến trình | Trung bình |
SemaphoreSlim | Giới hạn mức song song | Cao |
CancellationTokenSource.CreateLinkedTokenSource | Gộp nhiều token huỷ | Trung bình |
TaskCompletionSource | Tạo Task hoàn thành thủ công | Thấp |
ConfigureAwait(false) | Bỏ qua context đồng bộ hoá | Thấp trong ASP.NET Core |
ConfigureAwait(false) đáng nói riêng: trong ASP.NET Core nó gần như không cần, vì framework này không có synchronization context. Nó vẫn quan trọng khi viết thư viện dùng được ở cả ứng dụng desktop (ConfigureAwait FAQ và bài 6.4).
6.10.7 — Thứ tự nên học
| Ưu tiên | Chủ đề | Vì sao |
|---|---|---|
| Cao | TimeProvider | Làm test logic thời gian khả thi |
| Cao | SemaphoreSlim | Giới hạn song song là nhu cầu thường xuyên |
| Cao | PeriodicTimer | Đúng cho mọi BackgroundService |
| Trung bình | Channel<T> | Khi cần hàng đợi nội bộ |
| Trung bình | IAsyncDisposable | Khi đóng tài nguyên là I/O |
| Thấp | ValueTask | Chỉ khi profiler chỉ ra, và có ba luật |
Sau Module 6, bước tiếp theo là Module 7 — Dependency Injection.