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

6.10 — Mở rộng và đào sâu

Tóm tắt

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 PeriodicTimer cho 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ìnhTrung bình
SemaphoreSlimGiới hạn mức song songCao
CancellationTokenSource.CreateLinkedTokenSourceGộp nhiều token huỷTrung bình
TaskCompletionSourceTạo Task hoàn thành thủ côngThấ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ênChủ đềVì sao
CaoTimeProviderLàm test logic thời gian khả thi
CaoSemaphoreSlimGiới hạn song song là nhu cầu thường xuyên
CaoPeriodicTimerĐúng cho mọi BackgroundService
Trung bìnhChannel<T>Khi cần hàng đợi nội bộ
Trung bìnhIAsyncDisposableKhi đóng tài nguyên là I/O
ThấpValueTaskChỉ khi profiler chỉ ra, và có ba luật

Sau Module 6, bước tiếp theo là Module 7 — Dependency Injection.

6.10.8 — Rà lại code của bạn​

Danh sách rà soát async nâng cao

  • •Logic phụ thuộc thời gian dùng TimeProvider, không gọi DateTime.UtcNow trực tiếp.
  • •Test logic hết hạn không dùng Thread.Sleep.
  • •BackgroundService dùng PeriodicTimer thay vì Task.Delay trong vòng lặp.
  • •Chỉ dùng ValueTask khi có số liệu profiler.
  • •ValueTask chỉ await một lần, không gọi .Result trên nó.
  • •Lớp có thao tác đóng tốn I/O cài IAsyncDisposable.
  • •Phương thức async không có await nào thì trả Task.FromResult.

Bài tập áp dụng​

Bài 1 — Tiêm TimeProvider​

Chuyển một logic hết hạn trong dự án sang TimeProvider và viết test dùng FakeTimeProvider.

Tiêu chí hoàn thành: test của bạn kiểm tra được hành vi ở nhiều mốc thời gian mà không cần Thread.Sleep, và chạy trong vài mili giây.

Gợi ý và lời giải — Bài 1

Gợi ý. DateTime.UtcNow không giả lập được. Đó là lý do test về hết hạn thường phải Sleep, và vì vậy mà chúng chậm và hay hỏng vặt.

Lời giải — trước:

public class PhienDangNhapService
{
public bool ConHieuLuc(Phien p) => p.HetHanLuc > DateTime.UtcNow;

public Phien Tao(int nguoiDungId) =>
new(nguoiDungId, DateTime.UtcNow, DateTime.UtcNow.AddMinutes(15));
}
// Test phải làm thế này — chậm và không đáng tin
[Fact]
public async Task Phien_het_han_sau_15_phut()
{
var p = _service.Tao(1);
await Task.Delay(TimeSpan.FromMinutes(15)); // 15 PHÚT?
_service.ConHieuLuc(p).Should().BeFalse();
}

Sau:

public class PhienDangNhapService(TimeProvider thoiGian)
{
public bool ConHieuLuc(Phien p) => p.HetHanLuc > thoiGian.GetUtcNow();

public Phien Tao(int nguoiDungId)
{
var bayGio = thoiGian.GetUtcNow();
return new Phien(nguoiDungId, bayGio, bayGio.AddMinutes(15));
}
}
// Program.cs — đăng ký bản thật
builder.Services.AddSingleton(TimeProvider.System);
// Test — Microsoft.Extensions.TimeProvider.Testing
[Fact]
public void Phien_het_han_dung_sau_15_phut()
{
var thoiGian = new FakeTimeProvider(new DateTimeOffset(2026, 9, 25, 10, 0, 0, TimeSpan.Zero));
var service = new PhienDangNhapService(thoiGian);

var p = service.Tao(nguoiDungId: 1);

service.ConHieuLuc(p).Should().BeTrue();

thoiGian.Advance(TimeSpan.FromMinutes(14));
service.ConHieuLuc(p).Should().BeTrue("14 phút vẫn còn hiệu lực");

thoiGian.Advance(TimeSpan.FromMinutes(1));
service.ConHieuLuc(p).Should().BeFalse("đúng 15 phút là hết hạn");

thoiGian.Advance(TimeSpan.FromDays(365));
service.ConHieuLuc(p).Should().BeFalse();
}
Bốn mốc thời gian, chạy trong vài mili giây.
Kiểm tra được cả ranh giới 14 phút và 15 phút — điều không khả thi với Sleep.

Bốn thứ TimeProvider giả lập được, không chỉ GetUtcNow:

Thành viênDùng choFakeTimeProvider
GetUtcNow()Thời điểm hiện tạiAdvance() để tua
CreateTimer()PeriodicTimer, TimerTick theo Advance()
GetTimestamp()Đo khoảng thời gianTăng theo Advance()
LocalTimeZoneQuy đổi múi giờĐặt được trong test
// Test một BackgroundService chạy mỗi 5 phút — không phải chờ 5 phút
var thoiGian = new FakeTimeProvider();
var job = new DongBoJob(thoiGian, _kho);
_ = job.StartAsync(CancellationToken.None);

thoiGian.Advance(TimeSpan.FromMinutes(5));
await Task.Yield();

_kho.Received(1).DongBoAsync(Arg.Any<CancellationToken>());

Ba chỗ khác cũng nên tiêm thay vì gọi trực tiếp — cùng lý do:

DateTime.UtcNow      ->  TimeProvider
Guid.NewGuid() -> IGuidProvider (tự viết, hoặc truyền vào)
Random.Shared.Next() -> Random có hạt cố định trong test
Environment.MachineName -> một interface nhỏ, nếu logic phụ thuộc nó
Điểm chung: chúng trả về giá trị KHÁC NHAU mỗi lần gọi.
-> test không lặp lại được -> test hỏng vặt -> đội mất niềm tin vào test

Và một điều cần chú ý khi chuyển đổi:

grep -rn "DateTime\.\(Now\|UtcNow\)\|DateTimeOffset\.\(Now\|UtcNow\)" \
--include="*.cs" src/ | grep -v "/Tests/" | wc -l
47
Đừng chuyển hết 47 chỗ cùng lúc. Thứ tự nên làm:
1. Chỗ có logic hết hạn, hạn mức, lịch biểu — nơi test đang phải Sleep
2. Chỗ ghi mốc thời gian vào database — để test kiểm tra được giá trị
3. Phần còn lại (ghi log, đo hiệu năng) — không cần, và chuyển thì thừa

Chặn chỗ mới bằng phân tích tĩnh:

<!-- .editorconfig -->
dotnet_diagnostic.CA1305.severity = warning

Hoặc một quy tắc riêng bằng kiến trúc test:

[Fact]
public void Domain_khong_duoc_goi_DateTime_UtcNow_truc_tiep()
{
var viPham = Directory.GetFiles("src/Crm.Domain", "*.cs", SearchOption.AllDirectories)
.Where(f => File.ReadAllText(f).Contains("DateTime.UtcNow"))
.ToList();

viPham.Should().BeEmpty("domain phải nhận thời gian từ ngoài để test được");
}

Bài 2 — So sánh chu kỳ​

Chạy hai vòng lặp — một dùng Task.Delay, một dùng PeriodicTimer — với công việc tốn 2 giây, và ghi mốc thời gian sau 10 vòng.

Tiêu chí hoàn thành: bạn đo được độ trôi tích luỹ của Task.Delay, và biết PeriodicTimer xử lý thế nào khi công việc chạy lâu hơn chu kỳ.

Gợi ý và lời giải — Bài 2

Gợi ý. Với Task.Delay, mỗi vòng tốn "thời gian công việc + thời gian chờ". Cộng dồn 10 vòng thì sai lệch thành bao nhiêu?

Lời giải:

const int SoVong = 10;
var chuKy = TimeSpan.FromMilliseconds(500);
const int CongViecMs = 200;

// A — Task.Delay
var sw = Stopwatch.StartNew();
for (int i = 0; i < SoVong; i++)
{
await Task.Delay(CongViecMs); // "công việc"
await Task.Delay(chuKy); // chờ chu kỳ
}
var tDelay = sw.Elapsed.TotalMilliseconds;

// B — PeriodicTimer
sw.Restart();
using (var timer = new PeriodicTimer(chuKy))
{
for (int i = 0; i < SoVong; i++)
{
await timer.WaitForNextTickAsync();
await Task.Delay(CongViecMs); // "công việc"
}
}
var tTimer = sw.Elapsed.TotalMilliseconds;

Kết quả chạy trên .NET 9.0.203:

  Task.Delay     :  7.007 ms cho 10 vòng (lý tưởng 5.000 ms)
PeriodicTimer : 5.201 ms cho 10 vòng
trôi : 2.007 ms / 201 ms
Thực tếTrôiTrôi mỗi vòng
Task.Delay7.007 ms2.007 ms~200 ms
PeriodicTimer5.201 ms201 ms~20 ms

Độ trôi của Task.Delay đúng bằng thời gian công việc, cộng dồn mỗi vòng:

Mỗi vòng = 200 ms công việc + 500 ms chờ = 700 ms
10 vòng = 7.000 ms, thay vì 5.000 ms

-> trôi 200 ms MỖI VÒNG, tích luỹ tuyến tính
Quy đổi ra job chạy hằng ngày:
chu kỳ 1 phút, công việc 10 giây
-> mỗi vòng trôi 10 giây
-> sau 24 giờ: 1.440 vòng × 10 giây = 4 giờ trôi
-> job "mỗi phút" thực tế chạy 1.234 lần thay vì 1.440

Và với job báo cáo chạy "lúc 0 giờ mỗi ngày", độ trôi này nghĩa là sau vài tuần nó chạy vào buổi sáng.

PeriodicTimer đo từ ĐIỂM BẮT ĐẦU của chu kỳ trước, nên công việc nằm trong chu kỳ:

t=0     tick -> làm việc 200 ms
t=500 tick -> làm việc 200 ms
t=1000 tick -> ...

-> chu kỳ giữ nguyên bất kể công việc mất bao lâu (miễn là dưới chu kỳ)
-> 201 ms trôi còn lại là chi phí lập lịch của hệ điều hành

Và đây là câu hỏi quan trọng: công việc chạy LÂU HƠN chu kỳ thì sao?

using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500));
while (await timer.WaitForNextTickAsync(ct))
{
await Task.Delay(1200, ct); // công việc 1,2 giây > chu kỳ 0,5 giây
}
PeriodicTimer BỎ QUA các tick bị lỡ — nó không dồn chúng lại.

t=0 tick -> làm việc tới t=1200
t=500 tick bị lỡ (bỏ qua)
t=1000 tick bị lỡ (bỏ qua)
t=1200 WaitForNextTickAsync trả về NGAY -> làm việc tới t=2400
Hành vi này gần như luôn là điều bạn muốn:
- không bao giờ có hai lần chạy chồng lên nhau
- không tích luỹ một hàng đợi công việc quá hạn
- hệ thống chậm lại thì tần suất giảm, thay vì sụp đổ

So với System.Threading.Timer:

// Timer cũ: callback chạy CHỒNG LÊN NHAU nếu công việc lâu hơn chu kỳ
var timer = new Timer(_ => LamViecLau(), null, 0, 500);
-> ba lần chạy song song sau 1,5 giây
-> tranh chấp tài nguyên, và thường là nguồn của lỗi khó hiểu

Cách dùng đúng trong một BackgroundService:

public class DongBoJob(TimeProvider thoiGian, ILogger<DongBoJob> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5), thoiGian);

while (await timer.WaitForNextTickAsync(ct))
{
try
{
await DongBoAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break; // dừng bình thường khi ứng dụng tắt
}
catch (Exception ex)
{
log.LogError(ex, "Lỗi đồng bộ, sẽ thử lại ở chu kỳ sau");
}
}
}
}

Ba chi tiết trong đoạn này:

1. try/catch BÊN TRONG vòng lặp
-> một lần lỗi không giết cả job
-> không có nó, ngoại lệ thoát ra khỏi ExecuteAsync và job dừng HẲN,
thường là im lặng

2. PeriodicTimer nhận TimeProvider (từ .NET 8)
-> test tua được thời gian, không phải chờ 5 phút

3. OperationCanceledException tách riêng
-> dừng ứng dụng không bị ghi log thành lỗi

Bài 3 — Đo ValueTask​

Benchmark một phương thức trả về từ cache 95% số lần, với Task và với ValueTask.

Tiêu chí hoàn thành: bạn thấy khác biệt rõ ở cột cấp phát, và biết ba quy tắc bắt buộc khi dùng ValueTask.

Gợi ý và lời giải — Bài 3

Gợi ý. Task.FromResult vẫn cấp phát một đối tượng Task. ValueTask trả giá trị trực tiếp, không cấp phát gì.

Lời giải:

private bool TrongCache() => (++_i % 20) != 0;     // 95% số lần

public Task<string> LayTaskAsync(int k) =>
TrongCache() ? Task.FromResult(_cache[k]) : DocChamTaskAsync(k);

public ValueTask<string> LayValueTaskAsync(int k) =>
TrongCache() ? new ValueTask<string>(_cache[k]) : new ValueTask<string>(DocChamTaskAsync(k));
[Benchmark(Baseline = true, Description = "Task")]
public async Task<int> DungTask()
{
int n = 0;
for (int i = 0; i < 100; i++) n += (await LayTaskAsync(i)).Length;
return n;
}

Kết quả đo trên .NET 9.0.203, 100 lời gọi mỗi vòng:

| Method    |      Mean |    Error | Ratio |   Gen0 | Allocated | Alloc Ratio |
|---------- |----------:|---------:|------:|-------:|----------:|------------:|
| Task | 10,538 us | 2,636 us | 1.00 | 0.3967 | 7.473 B | 1.00 |
| ValueTask | 7,406 us | 3,051 us | 0.70 | 0.0305 | 648 B | 0.09 |
TaskValueTask
Thời gian10,538 µs7,406 µs (0,70×)
Cấp phát7.473 B648 B (0,09×)
Gen-0 GC0,39670,0305

Cột cấp phát là cột quan trọng: giảm 11,5 lần.

7.473 B cho 100 lời gọi = khoảng 75 B mỗi lời gọi
-> đúng bằng kích thước một đối tượng Task<string>

648 B còn lại của ValueTask là 5 lời gọi thật sự bất đồng bộ
(5% trong 100) -> mỗi cái vẫn cần một Task bên dưới
Quy đổi ra quy mô thật:
một endpoint gọi cache 20 lần, 500 request/giây
-> 20 × 500 × 75 B = 750 KB/giây với Task
-> gần 0 với ValueTask

Không lớn, nhưng nó là áp lực GC LIÊN TỤC, và nó cộng vào
mọi nguồn cấp phát khác trong cùng tiến trình.

Nhưng chỉ dùng ValueTask khi thoả cả ba điều kiện:

1. Phương thức trả về ĐỒNG BỘ trong PHẦN LỚN trường hợp
2. Nó nằm trên đường đi nóng — gọi hàng nghìn lần mỗi giây
3. Bạn đã ĐO và thấy cấp phát là vấn đề
Thiếu điều kiện 1 -> ValueTask CHẬM HƠN Task, vì nó là struct lớn hơn
và vẫn phải bọc một Task bên trong

Và ba quy tắc bắt buộc khi dùng:

1. Chỉ await MỘT LẦN.

// SAI
ValueTask<string> vt = LayValueTaskAsync(1);
var a = await vt;
var b = await vt; // InvalidOperationException hoặc kết quả sai
Task await lại bao nhiêu lần cũng được — nó giữ kết quả.
ValueTask có thể được TÁI SỬ DỤNG bởi nguồn tạo ra nó sau lần await đầu.

2. Không await song song.

// SAI
var vt = LayValueTaskAsync(1);
await Task.WhenAll(vt.AsTask(), vt.AsTask());

3. Muốn giữ lại thì chuyển sang Task trước.

// ĐÚNG — cần dùng nhiều lần
Task<string> t = LayValueTaskAsync(1).AsTask();
var a = await t;
var b = await t;
AsTask() cấp phát một Task -> mất đúng phần lợi ích vừa đo được.
Nên nếu chỗ gọi thường xuyên cần AsTask(), hãy trả về Task ngay từ đầu.

Cách dùng an toàn nhất — và cũng là cách phổ biến nhất:

var giaTri = await LayValueTaskAsync(key);     // await NGAY tại chỗ gọi
await ngay tại chỗ -> không giữ tham chiếu -> không vi phạm quy tắc nào
Đây là 95% cách ValueTask được dùng trong thực tế.

Và một lưu ý về Error trong bảng đo trên:

Task      : Mean 10,538 µs, Error 2,636 µs -> 25% của Mean
ValueTask : Mean 7,406 µs, Error 3,051 µs -> 41% của Mean
Hai khoảng tin cậy CHỒNG nhau -> kết luận "nhanh hơn 30%" chưa chắc chắn.
Nhưng cột Allocated thì không có sai số: 7.473 B so với 648 B là phép ĐẾM.

-> đây lại là tình huống nên quyết định bằng Allocated, không bằng Mean.

Tự kiểm tra​

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

TimeProvider giải quyết vấn đề gì?

Nó làm thời gian thành một dependency tiêm được, nên test logic hết hạn, TTL và lịch chạy không cần Thread.Sleep. Test chạy trong micro giây và kiểm tra chính xác mốc thời gian thay vì xấp xỉ.

ValueTask nhanh hơn Task trong trường hợp nào?

Khi phương thức được gọi rất nhiều lần và thường trả về đồng bộ, ví dụ đọc từ cache. Task là object trên heap, còn ValueTask là struct nên trường hợp đồng bộ không cấp phát gì.

Ba luật khi dùng ValueTask là gì?

Chỉ await một lần, không gọi .Result hay GetAwaiter().GetResult, và nếu cần dùng nhiều lần thì chuyển sang Task bằng AsTask. Vi phạm gây hành vi không xác định vì ValueTask có thể tái sử dụng đối tượng nội bộ.

Vì sao lỗi dùng sai ValueTask nguy hiểm?

Vì nó không xác định: có thể chạy đúng trong test và dưới tải thấp, rồi sai dưới tải cao khi đối tượng nội bộ thực sự được tái sử dụng.

PeriodicTimer hơn gì so với Task.Delay trong vòng lặp?

Với Task.Delay, thời gian xử lý cộng vào chu kỳ nên độ trễ tích luỹ và lịch chạy trôi dần. PeriodicTimer giữ chu kỳ ổn định bất kể việc xử lý tốn bao lâu, và xử lý huỷ gọn hơn.

ConfigureAwait(false) có cần trong ASP.NET Core không?

Gần như không, vì ASP.NET Core 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, nơi context đó tồn tại.

Kết luận​

Ba điều đáng nhớ nhất:

  1. TimeProvider làm test logic thời gian khả thi — mục đáng áp dụng ngay nhất.
  2. ValueTask có ba luật, và vi phạm gây lỗi không xác định.
  3. PeriodicTimer giữ chu kỳ ổn định, Task.Delay thì trôi dần.

Tham khảo​

Điều hướng​