Skip to content

6.9 - Đọc thêm: TimeProvider, PeriodicTimer và đo ValueTask ​

Tóm tắt: Ba chủ đề nối tiếp Module 6, xếp theo mức nên dùng. TimeProvider 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 trong vài mili-giây thay vì phải Thread.Sleep. PeriodicTimer cho vòng lặp định kỳ trong BackgroundService giữ đúng chu kỳ, không trôi dần như Task.Delay (đo trên .NET 10: 5,2 giây so với 7,1 giây cho 10 vòng 500 ms). ValueTask chỉ đáng dùng khi đã đo: bài tập 3 đo bằng BenchmarkDotNet cho thấy nó giảm cấp phát 11 lần nhưng thời gian chạy không khác biệt rõ.

Mục tiêu bài học ​

Sau bài này bạn có thể:

  • Dùng TimeProvider và FakeTimeProvider để test logic phụ thuộc thời gian.
  • Dùng PeriodicTimer cho vòng lặp định kỳ, xử lý đúng khi bị huỷ và khi công việc lâu hơn chu kỳ.
  • Đo một quyết định Task hay ValueTask bằng BenchmarkDotNet và đọc đúng cột cấp phát.
  • Chặn việc gọi thẳng DateTime.UtcNow bằng phân tích tĩnh.

Cần biết trước: viết test xUnit (bài 0.4); BackgroundService và CancellationToken (bài 6.8, bài 6.4); ValueTask (bài 6.2). Bài này là phần đọc thêm, không bắt buộc để làm Project 1.

Nội dung bài học ​

6.9.1 — TimeProvider: dùng ngay ​

Điều kiện áp dụng: ngay khi có logic phụ thuộc thời gian.

csharp
// 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

TimeProvider (có sẵn từ .NET 8) là một lớp trừu tượng cho "đồng hồ". Bản thật là TimeProvider.System; bản giả FakeTimeProvider (gói Microsoft.Extensions.TimeProvider.Testing) cho phép test "tua" thời gian:

csharp
// Tiêm TimeProvider
public sealed class TokenService(TimeProvider timeProvider)
{
    public bool IsExpired(Token token)
        => timeProvider.GetUtcNow() > token.ExpiresAtUtc;
}

// Đăng ký trong Program.cs
builder.Services.AddSingleton(TimeProvider.System);
csharp
// 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));

Test chạy trong vài mili-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, PeriodicTimer và CancellationTokenSource có hẹn giờ (các API này nhận một TimeProvider), nên logic retry và lịch chạy cũng test được (bài 16.9).

6.9.2 — PeriodicTimer ​

Điều kiện áp dụng: vòng lặp chạy định kỳ trong BackgroundService.

csharp
// 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, miễn là ProcessAsync xong trong dưới 5 giây
}

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 đếm chu kỳ từ lúc tạo timer, nên công việc nằm trong chu kỳ. Tick đầu tiên đến sau một chu kỳ, không phải ngay lúc tạo.

Huỷ và dừng. WaitForNextTickAsync có hai cách kết thúc khác nhau (đã thử trên .NET 10):

Tình huốngKết quả
CancellationToken truyền vào bị huỷNém OperationCanceledException
Timer bị Dispose()Trả về false, vòng while thoát tự nhiên

Nghĩa là trong BackgroundService, khi ứng dụng tắt (token bị huỷ), vòng lặp kết thúc bằng ngoại lệ. Bạn có thể để ngoại lệ đó thoát ra khỏi ExecuteAsync (host coi OperationCanceledException lúc đang tắt là dừng bình thường), hoặc bọc vòng lặp trong try/catch (OperationCanceledException) when (ct.IsCancellationRequested) để kết thúc gọn, như ví dụ ở bài tập 2.

Công việc lâu hơn chu kỳ. PeriodicTimer không dồn các tick bị lỡ: nếu đã lỡ, lần WaitForNextTickAsync kế tiếp trả về ngay một lần, rồi quay lại nhịp bình thường. Không bao giờ có hai lần chạy chồng lên nhau.

6.9.3 — ValueTask: đo rồi mới dùng ​

Bài 6.2 đã dạy ValueTask và ba quy tắc của nó: chỉ await một lần, không await đồng thời từ nhiều chỗ, không gọi .Result khi chưa xong. Nhắc lại lý do: ValueTask có thể bọc một đối tượng được tái sử dụng sau lần await đầu, nên await lần hai có thể đọc trạng thái của một thao tác khác. Lỗi đó không xác định: có thể chạy đúng trong test và sai dưới tải cao.

Mục này chỉ thêm một điều: cách đo để quyết định. Bài 6.1 đo bằng Stopwatch cho thấy chi phí thời gian; bài tập 3 dưới đây dùng BenchmarkDotNet (bài 19.2) để đo cả cấp phát bộ nhớ, thứ thường là lý do thật sự để chọn ValueTask.

6.9.4 — Những chủ đề khác và nơi đã học ​

Chủ đềNội dungHọc ở
IAsyncDisposable, await usingDọn dẹp có I/Obài 6.6
Máy trạng thái asyncĐọc MoveNext() trong stack tracebài 6.2
Channel<T>Hàng đợi producer-consumer trong tiến trìnhbài 6.8
SemaphoreSlim, Parallel.ForEachAsyncGiới hạn mức song songbài 6.5
CancellationTokenSource.CreateLinkedTokenSourceGộp nhiều token huỷbài 6.4
ConfigureAwait(false)Chỉ cần trong thư việnbài 6.3
TaskCompletionSource<T>Tự tạo một Task rồi hoàn tất nó bằng tay, dùng khi bọc API kiểu callback cũtài liệu Microsoft

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

6.9.5 — 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 định kỳ dùng PeriodicTimer thay vì Task.Delay trong vòng lặp.
  • Vòng lặp PeriodicTimer xử lý OperationCanceledException khi token bị huỷ.
  • Chỉ dùng ValueTask khi có số liệu benchmark, và chỉ await nó một lần.

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

Bài 1 — Tiêm TimeProvider ​

Chuyển một logic hết hạn phiên đăng nhập sang TimeProvider, viết test dùng FakeTimeProvider cho nhiều mốc thời gian, và test một BackgroundService chạy mỗi 5 phút mà không phải chờ 5 phút.

Tiêu chí hoàn thành: dotnet test xanh, test kiểm tra được hành vi ở nhiều mốc thời gian (kể cả ranh giới đúng 15 phút) mà không có Thread.Sleep, và cả bộ test chạy dưới một 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:

csharp
public class SessionService
{
    public bool IsValid(Session s) => s.ExpiresAt > DateTimeOffset.UtcNow;

    public Session Create(int userId) =>
        new(userId, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(15));
}

Muốn test hết hạn thì phải await Task.Delay(TimeSpan.FromMinutes(15)) trong test: không ai chạy nổi.

Sau. Tạo project test bằng dotnet new xunit, cài Microsoft.Extensions.TimeProvider.Testing và Microsoft.Extensions.Hosting.Abstractions (cho BackgroundService). Code (để chung trong project test cho gọn; dự án thật thì nằm ở project chính):

csharp
using Microsoft.Extensions.Hosting;

public record Session(int UserId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt);

public class SessionService(TimeProvider time)
{
    public bool IsValid(Session s) => s.ExpiresAt > time.GetUtcNow();

    public Session Create(int userId)
    {
        var now = time.GetUtcNow();
        return new Session(userId, now, now.AddMinutes(15));
    }
}

public interface ISyncer { Task SyncAsync(CancellationToken ct); }

public class SyncJob(TimeProvider time, ISyncer syncer) : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken stoppingToken) => RunAsync(stoppingToken);

    // Tách vòng lặp ra để test gọi thẳng: timer được tạo ngay trên luồng gọi, trước await đầu tiên
    public async Task RunAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5), time);   // PeriodicTimer nhận TimeProvider
        try
        {
            while (await timer.WaitForNextTickAsync(stoppingToken))
                await syncer.SyncAsync(stoppingToken);
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
    }
}

Test:

csharp
using Microsoft.Extensions.Time.Testing;

public class SessionTests
{
    [Fact]
    public void Session_ExpiresExactlyAfter15Minutes()
    {
        var time = new FakeTimeProvider(new DateTimeOffset(2026, 9, 25, 10, 0, 0, TimeSpan.Zero));
        var service = new SessionService(time);
        var session = service.Create(userId: 1);

        Assert.True(service.IsValid(session));
        time.Advance(TimeSpan.FromMinutes(14));
        Assert.True(service.IsValid(session));      // 14 phút: còn hiệu lực
        time.Advance(TimeSpan.FromMinutes(1));
        Assert.False(service.IsValid(session));     // đúng 15 phút: hết hạn
        time.Advance(TimeSpan.FromDays(365));
        Assert.False(service.IsValid(session));
    }

    private sealed class CountingSyncer : ISyncer
    {
        public readonly SemaphoreSlim Called = new(0);
        public int Count;
        public Task SyncAsync(CancellationToken ct) { Interlocked.Increment(ref Count); Called.Release(); return Task.CompletedTask; }
    }

    [Fact]
    public async Task SyncJob_RunsEveryFiveMinutes_WithoutWaiting()
    {
        var time = new FakeTimeProvider();
        var syncer = new CountingSyncer();
        using var job = new SyncJob(time, syncer);
        using var cts = new CancellationTokenSource();
        var running = job.RunAsync(cts.Token);

        time.Advance(TimeSpan.FromMinutes(4));
        Assert.False(await syncer.Called.WaitAsync(TimeSpan.FromMilliseconds(200)));  // chưa tới 5 phút

        time.Advance(TimeSpan.FromMinutes(1));
        Assert.True(await syncer.Called.WaitAsync(TimeSpan.FromSeconds(5)));        // tick đầu tiên

        time.Advance(TimeSpan.FromMinutes(5));
        Assert.True(await syncer.Called.WaitAsync(TimeSpan.FromSeconds(5)));        // tick thứ hai
        Assert.Equal(2, syncer.Count);

        cts.Cancel();
        await running;                                                               // dừng gọn, không ném
    }
}

Kết quả trên .NET 10: Passed! - Failed: 0, Passed: 2, cả bộ test chạy khoảng 0,23 giây (phần lớn là 200 ms chờ để khẳng định job chưa chạy ở phút thứ 4).

Ba chi tiết đáng chú ý:

  1. Ranh giới đúng 15 phút được kiểm tra chính xác, điều không làm được với Sleep.
  2. SemaphoreSlim làm tín hiệu. Sau Advance, phần code sau WaitForNextTickAsync chạy trên một thread khác, nên test phải chờ tín hiệu (có giới hạn thời gian) thay vì kiểm tra ngay.
  3. Gọi thẳng RunAsync thay vì StartAsync. Từ .NET 10, StartAsync chạy ExecuteAsync trên thread nền (bài 6.8), nên timer có thể chưa được tạo khi test gọi Advance lần đầu. Phiên bản đầu tiên của test này dùng StartAsync và thỉnh thoảng hỏng vì đúng lý do đó.

Chặn chỗ mới gọi thẳng đồng hồ hệ thống. Cài gói Microsoft.CodeAnalysis.BannedApiAnalyzers, tạo file BannedSymbols.txt ở thư mục project:

text
P:System.DateTime.Now;Dùng TimeProvider.GetLocalNow() để test được
P:System.DateTime.UtcNow;Dùng TimeProvider.GetUtcNow() để test được
P:System.DateTimeOffset.UtcNow;Dùng TimeProvider.GetUtcNow() để test được

và khai báo nó trong .csproj:

xml
<ItemGroup>
  <AdditionalFiles Include="BannedSymbols.txt" />
</ItemGroup>

Build lại, mọi chỗ gọi thẳng bị báo RS0030 (đã thử):

text
warning RS0030: The symbol 'DateTime.UtcNow' is banned in this project: Dùng TimeProvider.GetUtcNow() để test được

Chuyển dần, không cần chuyển hết cùng lúc: trước tiên là chỗ có logic hết hạn, hạn mức, lịch chạy (nơi test đang phải Sleep); sau đó là chỗ ghi mốc thời gian vào database; phần ghi log và đo hiệu năng thì không cần.

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" 200 ms và chu kỳ 500 ms, đo thời gian 10 vòng. Rồi thử công việc 1.200 ms, lâu hơn chu kỳ.

Tiêu chí hoàn thành: bạn đo được độ trôi tích luỹ của Task.Delay, và mô tả được 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:

csharp
using System.Diagnostics;

const int Rounds = 10;
var period = TimeSpan.FromMilliseconds(500);
const int WorkMs = 200;

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

// B — PeriodicTimer
sw.Restart();
var ticks = new List<double>();
using (var timer = new PeriodicTimer(period))
{
    for (int i = 0; i < Rounds; i++)
    {
        await timer.WaitForNextTickAsync();
        ticks.Add(sw.Elapsed.TotalMilliseconds);
        await Task.Delay(WorkMs);    // "công việc"
    }
}
var tTimer = sw.Elapsed.TotalMilliseconds;
Console.WriteLine($"Task.Delay    : {tDelay,6:F0} ms cho {Rounds} vòng (lý tưởng 5000 ms)");
Console.WriteLine($"PeriodicTimer : {tTimer,6:F0} ms cho {Rounds} vòng, tick thứ 10 lúc {ticks[^1]:F0} ms");

// C — công việc lâu hơn chu kỳ
sw.Restart();
using (var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500)))
{
    for (int i = 0; i < 3; i++)
    {
        await timer.WaitForNextTickAsync();
        Console.WriteLine($"  tick lúc {sw.ElapsedMilliseconds,5} ms -> làm việc 1200 ms");
        await Task.Delay(1200);
    }
}

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

text
Task.Delay    :   7139 ms cho 10 vòng (lý tưởng 5000 ms)
PeriodicTimer :   5208 ms cho 10 vòng, tick thứ 10 lúc 5006 ms
  tick lúc   503 ms -> làm việc 1200 ms
  tick lúc  1717 ms -> làm việc 1200 ms
  tick lúc  2929 ms -> làm việc 1200 ms

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

text
Mỗi vòng = 200 ms công việc + 500 ms chờ = 700 ms
10 vòng  = khoảng 7000 ms, thay vì 5000 ms

-> trôi khoảng 200 ms MỖI VÒNG, tích luỹ tuyến tính

Phần dư 139 ms là do mỗi Task.Delay trên Windows thường dài hơn một chút so với số yêu cầu.

PeriodicTimer không trôi. Tick thứ 10 đến lúc 5006 ms, đúng 10 × 500 ms (lệch khoảng 6 ms). Tổng 5208 ms gồm 5006 ms chờ đủ 10 tick cộng 200 ms công việc của vòng cuối, chạy sau tick thứ 10. Không có độ trôi tích luỹ nào.

text
Quy đổi ra job chạy hằng ngày:
    chu kỳ 1 phút, công việc 10 giây, viết bằng Task.Delay
    -> mỗi vòng thật sự mất 70 giây
    -> 24 giờ chỉ chạy được khoảng 1.234 lần thay vì 1.440 lần

Công việc lâu hơn chu kỳ (phần C). Tick đầu tới lúc 503 ms. Công việc kéo dài tới khoảng 1.703 ms, trong lúc đó các tick ở 1.000 và 1.500 bị lỡ. Lần chờ kế tiếp trả về ngay (1.717 ms) rồi lại làm việc 1.200 ms. Các tick lỡ không bị dồn lại:

text
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, callback chạy trên thread pool theo nhịp cố định bất kể lần trước đã xong chưa, nên công việc dài hơn chu kỳ sẽ có nhiều lần chạy chồng lên nhau.

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

csharp
public class SyncJob(TimeProvider time, ILogger<SyncJob> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5), time);
        try
        {
            while (await timer.WaitForNextTickAsync(stoppingToken))   // token huỷ -> ném OCE
            {
                try
                {
                    await SyncAsync(stoppingToken);
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    logger.LogError(ex, "Lỗi đồng bộ, sẽ thử lại ở chu kỳ sau");
                }
            }
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // ứng dụng đang tắt: dừng bình thường, không ghi log lỗi
        }
    }

    private Task SyncAsync(CancellationToken ct) => Task.CompletedTask;   // việc đồng bộ thật ở đây
}

Ba chi tiết:

  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 khỏi ExecuteAsync và với cấu hình mặc định cả ứng dụng dừng (bài 6.8).
  2. try/catch bên ngoài vòng lặp cho OperationCanceledException, vì WaitForNextTickAsync ném khi token bị huỷ chứ không trả false.
  3. PeriodicTimer nhận TimeProvider (từ .NET 8), nên test tua được thời gian như bài 1.

Bài 3 — Đo ValueTask bằng BenchmarkDotNet ​

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 có bảng kết quả của BenchmarkDotNet, chỉ ra được cột nào cho thấy khác biệt rõ, cột nào không, và vì sao.

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. new ValueTask<T>(value) chỉ là một struct, không cấp phát gì. Thuộc tính [MemoryDiagnoser] làm BenchmarkDotNet đo thêm cột cấp phát.

Lời giải. dotnet new console, cài gói BenchmarkDotNet, rồi:

csharp
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<CacheBenchmark>();

[MemoryDiagnoser]
public class CacheBenchmark
{
    private readonly string[] _cache = Enumerable.Range(0, 100).Select(i => $"value-{i}").ToArray();
    private int _i;

    private bool InCache() => (++_i % 20) != 0;     // 95% số lần có sẵn trong cache

    private async Task<string> LoadSlowAsync(int k)
    {
        await Task.Yield();                         // 5% còn lại: bất đồng bộ thật
        return _cache[k];
    }

    public Task<string> GetTaskAsync(int k) =>
        InCache() ? Task.FromResult(_cache[k]) : LoadSlowAsync(k);

    public ValueTask<string> GetValueTaskAsync(int k) =>
        InCache() ? new ValueTask<string>(_cache[k]) : new ValueTask<string>(LoadSlowAsync(k));

    [Benchmark(Baseline = true, Description = "Task")]
    public async Task<int> UseTask()
    {
        int n = 0;
        for (int i = 0; i < 100; i++) n += (await GetTaskAsync(i)).Length;
        return n;
    }

    [Benchmark(Description = "ValueTask")]
    public async Task<int> UseValueTask()
    {
        int n = 0;
        for (int i = 0; i < 100; i++) n += (await GetValueTaskAsync(i)).Length;
        return n;
    }
}

Chạy bằng dotnet run -c Release (BenchmarkDotNet từ chối chạy ở Debug).

Kết quả trên .NET 10, BenchmarkDotNet 0.15.8, 100 lời gọi mỗi lần đo:

text
| Method    | Mean     | Error     | StdDev    | Median   | Ratio | Gen0   | Allocated | Alloc Ratio |
|---------- |---------:|----------:|----------:|---------:|------:|-------:|----------:|------------:|
| Task      | 3.617 us | 0.0642 us | 0.0501 us | 3.629 us |  1.00 | 0.7973 |    7472 B |        1.00 |
| ValueTask | 4.148 us | 0.4528 us | 1.3065 us | 3.605 us |  1.15 | 0.0687 |     648 B |        0.09 |

(us là micro-giây; BenchmarkDotNet dùng dấu chấm thập phân.)

Cột Allocated là cột có khác biệt rõ: giảm 11,5 lần.

text
7472 B cho 100 lời gọi, khoảng 75 B mỗi lời gọi
-> đúng cỡ một đối tượng Task<string> cho mỗi lần trả từ cache

648 B còn lại của ValueTask là 5 lời gọi thật sự bất đồng bộ
(5% của 100) -> mỗi cái vẫn cần một Task bên dưới

Cột Mean thì không kết luận được. ValueTask có Mean cao hơn nhưng Median thấp hơn, và StdDev của nó lớn (1,3 µs so với 0,05 µs): vài lần đo bị nhiễu kéo trung bình lên. Hai khoảng sai số chồng nhau, nên không thể nói bên nào nhanh hơn. Cột Allocated thì là phép đếm, không có sai số.

text
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 cấp phát thêm với Task
    -> gần 0 với ValueTask

Không lớn, nhưng là áp lực GC liên tục, cộng vào mọi nguồn cấp phát khác trong cùng tiến trình.

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 chạy 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 thì ValueTask không tiết kiệm gì, vì nó vẫn phải bọc một Task bên trong. Và nếu chỗ gọi thường phải .AsTask() để dùng nhiều lần, thì AsTask() cấp phát lại đúng phần vừa tiết kiệm: khi đó hãy trả về Task ngay từ đầu.

Cách dùng an toàn nhất, và cũng phổ biến nhất: await ngay tại chỗ gọi (var value = await GetValueTaskAsync(key);). Không giữ biến ValueTask thì không vi phạm quy tắc nào.

Tự kiểm tra ​

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. FakeTimeProvider cho phép tua thời gian bằng Advance, test chạy trong vài mili-giây và kiểm tra chính xác ranh giới.

Khi token bị huỷ, PeriodicTimer.WaitForNextTickAsync trả false hay ném?

Ném OperationCanceledException. Nó chỉ trả false khi timer bị Dispose. Vì vậy trong BackgroundService phải để ngoại lệ đó thoát ra (host coi là dừng bình thường) hoặc bắt bằng catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested).

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 lịch chạy trôi dần (đo được 7,1 giây thay vì 5 giây cho 10 vòng). PeriodicTimer đếm chu kỳ từ lúc tạo, nên tick thứ 10 đến đúng khoảng 5 giây. Khi công việc lâu hơn chu kỳ, nó bỏ qua các tick lỡ chứ không dồn lại, nên không có hai lần chạy chồng nhau.

Benchmark Task và ValueTask: cột nào đáng tin để quyết định?

Cột Allocated, vì đó là phép đếm byte cấp phát, không có sai số. Cột Mean bị nhiễu: trong số đo của bài, ValueTask có Mean cao hơn nhưng Median thấp hơn và StdDev rất lớn, nên không kết luận được về thời gian. Allocated thì giảm từ 7472 B xuống 648 B.

Làm sao ngăn người khác gọi thẳng DateTime.UtcNow trong code mới?

Dùng gói Microsoft.CodeAnalysis.BannedApiAnalyzers với file BannedSymbols.txt liệt kê P:System.DateTime.UtcNow và các thuộc tính tương tự. Mọi chỗ gọi bị báo RS0030 khi build, có thể nâng thành lỗ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. PeriodicTimer giữ chu kỳ ổn định và ném OperationCanceledException khi bị huỷ.
  3. ValueTask là quyết định dựa trên số đo cấp phát, không phải trên cảm giác "nhanh hơn".

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.