Mert Özen Further With Every Line
Backend Asynchronous Programming in C# — #16

C# Async and .NET 8: TimeProvider, ConfigureAwaitOptions, Parallel.ForAsync and CancelAsync

Mert Özen Nov 7, 2026 10 min 5 views
C# Async and .NET 8: TimeProvider, ConfigureAwaitOptions, Parallel.ForAsync and CancelAsync

On .NET 6 we wrote our own IClock; on .NET 8, TimeProvider and FakeTimeProvider make a 15-minute wait take 0 ms in a test. SuppressThrowing, which waits without throwing, and its Task<T> trap, ForceYielding, Parallel.ForAsync, which takes an index range directly, and CancelAsync, which runs callbacks without blocking the caller.

Version note: This post covers the asynchronous programming additions in .NET 8; the code was built and run on the .NET 10 SDK targeting C# 12 / .NET 8, and the "before" examples targeting C# 10 / .NET 6.

Guess First

The warehouse sync throws an exception. We await the task with an option that arrived in .NET 8, and there's no try/catch anywhere around it. Does the program crash? If it doesn't, what shows up on screen?

Task sync = Task.Run(() => throw new InvalidOperationException("Warehouse offline"));

await sync.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);

Console.WriteLine($"Still running. Task status: {sync.Status}");
Console.WriteLine($"Error: {sync.Exception?.InnerException?.Message}");

ConfigureAwaitOptions

The program didn't crash:

Still running. Task status: Faulted
Error: Warehouse offline

SuppressThrowing means "wait for the task to finish, but don't throw if it failed". The task stayed faulted and its error sits in the Exception property; await just didn't rethrow it. In C# 10/.NET 6 we expressed the same intent with try { await sync; } catch { }. That's noisy, and it pays the cost of throwing and catching an exception. The typical use is work whose result you don't care about but whose completion you must wait for: waiting for background tasks to stop at shutdown, waiting for a canceled job to clean up.

Before .NET 8, ConfigureAwait took only a bool. The new overload takes a flags enum: None matches the old ConfigureAwait(false), and ContinueOnCapturedContext matches the old ConfigureAwait(true). The other two are new:

Task<decimal> price = Task.FromResult(37.90m);

Console.WriteLine($"Before:                 thread {Environment.CurrentManagedThreadId}");
await price.ConfigureAwait(ConfigureAwaitOptions.None);
Console.WriteLine($"After None:             thread {Environment.CurrentManagedThreadId}");
await price.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
Console.WriteLine($"After ForceYielding:    thread {Environment.CurrentManagedThreadId}");

try
{
    await price.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
catch (ArgumentOutOfRangeException ex)
{
    Console.WriteLine($"SuppressThrowing on Task<T>: {ex.GetType().Name}");
    Console.WriteLine(ex.Message);
}
Before:                 thread 1
After None:             thread 1
After ForceYielding:    thread 4
SuppressThrowing on Task<T>: ArgumentOutOfRangeException
Task<TResult>.ConfigureAwait does not support ConfigureAwaitOptions.SuppressThrowing. To suppress throwing, instead cast the Task<TResult> to its base class Task and await that with SuppressThrowing. (Parameter 'options')

When you await a task that has already completed, the code continues on the same thread without waiting at all; both of the first two lines are on thread 1. ForceYielding queues the continuation to the thread pool even if the task is done; on .NET 6 we used to put an await Task.Yield() in between for that. It's useful when you want a method that does a long synchronous setup to return to its caller right away. The last line matters too: SuppressThrowing doesn't work with the generic Task<T>, because a failed task has no result to return. The runtime threw an ArgumentOutOfRangeException, and the compiler had already warned us:

Program.cs(11,32): warning CA2261: The ConfigureAwaitOptions.SuppressThrowing option is only supported with the non-generic Task (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2261)

As the message suggests, if you don't care about the result, cast the task to Task first and await that.

Testing Time on .NET 6

The last exercise of the previous post ended with this question: how do you test time-dependent code without actually waiting? On .NET 6 the framework had no answer. Every project wrote its own abstraction:

var clock = new FakeClock(new DateTimeOffset(2026, 11, 7, 9, 0, 0, TimeSpan.Zero));
var sale = new FlashSale(clock, endsAt: new DateTimeOffset(2026, 11, 7, 12, 0, 0, TimeSpan.Zero));

Console.WriteLine($"09:00 active: {sale.IsActive}");
clock.UtcNow = clock.UtcNow.AddHours(4);
Console.WriteLine($"13:00 active: {sale.IsActive}");

// every project wrote its own version of this
public interface IClock
{
    DateTimeOffset UtcNow { get; }
}

public sealed class SystemClock : IClock
{
    public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
}

public sealed class FakeClock : IClock
{
    public FakeClock(DateTimeOffset start) => UtcNow = start;

    public DateTimeOffset UtcNow { get; set; }
}

public sealed class FlashSale
{
    private readonly IClock _clock;
    private readonly DateTimeOffset _endsAt;

    public FlashSale(IClock clock, DateTimeOffset endsAt)
    {
        _clock = clock;
        _endsAt = endsAt;
    }

    public bool IsActive => _clock.UtcNow < _endsAt;
}
09:00 active: True
13:00 active: False

That was enough for asking what time it is. But Task.Delay, CancellationTokenSource.CancelAfter, Timer and PeriodicTimer used the real clock directly; to test code that says "empty the cart after 15 minutes" you either waited 15 minutes or wrote a much bigger interface that abstracted the waiting too. On top of that, every library had its own IClock, and they didn't talk to each other.

TimeProvider (.NET 8)

.NET 8 added an abstract TimeProvider class to the System namespace and gave the time-related APIs overloads that take it:

using System.Diagnostics;

TimeProvider time = TimeProvider.System;

Console.WriteLine($"UTC now has offset: {time.GetUtcNow().Offset}");

long start = time.GetTimestamp();
await Task.Delay(TimeSpan.FromMilliseconds(120), time);
Console.WriteLine($"Delay through TimeProvider waited at least 120 ms: {time.GetElapsedTime(start).TotalMilliseconds >= 120}");

using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50), time);
try
{
    await Task.Delay(Timeout.InfiniteTimeSpan, cts.Token);
}
catch (OperationCanceledException)
{
    Console.WriteLine("CancellationTokenSource with TimeProvider fired");
}

using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(30), time);
int ticks = 0;
while (ticks < 3 && await timer.WaitForNextTickAsync())
    ticks++;
Console.WriteLine($"PeriodicTimer with TimeProvider ticked {ticks} times");

Console.WriteLine($"Stopwatch.Frequency == TimestampFrequency: {Stopwatch.Frequency == time.TimestampFrequency}");
UTC now has offset: 00:00:00
Delay through TimeProvider waited at least 120 ms: True
CancellationTokenSource with TimeProvider fired
PeriodicTimer with TimeProvider ticked 3 times
Stopwatch.Frequency == TimestampFrequency: True

TimeProvider.System is the real clock. GetUtcNow() and GetLocalNow() give you the current time, and GetTimestamp() and GetElapsedTime() give you elapsed time with the same precision as Stopwatch. The real value is that waiting goes through this abstraction too: Task.Delay(TimeSpan, TimeProvider), WaitAsync(TimeSpan, TimeProvider), new CancellationTokenSource(TimeSpan, TimeProvider) and new PeriodicTimer(TimeSpan, TimeProvider). If you need your own timer, CreateTimer returns an ITimer. The only thing you have to do in your code is use the TimeProvider you receive in the constructor instead of DateTimeOffset.UtcNow; in dependency injection you register it with services.AddSingleton(TimeProvider.System).

Fast-Forwarding Time in Tests with FakeTimeProvider

On the test side there's FakeTimeProvider in Microsoft's Microsoft.Extensions.TimeProvider.Testing package. You start the clock, and you move it forward:

using System.Diagnostics;
using Microsoft.Extensions.Time.Testing;

var time = new FakeTimeProvider(new DateTimeOffset(2026, 11, 7, 9, 0, 0, TimeSpan.Zero));

var sale = new FlashSale(time, endsAt: new DateTimeOffset(2026, 11, 7, 12, 0, 0, TimeSpan.Zero));
Console.WriteLine($"{time.GetUtcNow():HH:mm} sale active: {sale.IsActive}");
time.Advance(TimeSpan.FromHours(4));
Console.WriteLine($"{time.GetUtcNow():HH:mm} sale active: {sale.IsActive}");

var stopwatch = Stopwatch.StartNew();
var carts = new CartReservations(time);
Task release = carts.HoldAsync("cart-42", TimeSpan.FromMinutes(15), CancellationToken.None);

time.Advance(TimeSpan.FromMinutes(14));
Console.WriteLine($"+14 min: released = {release.IsCompleted}");
time.Advance(TimeSpan.FromMinutes(1));
await release;
Console.WriteLine($"+15 min: released = {release.IsCompleted}, real time spent: {stopwatch.ElapsedMilliseconds} ms");

public sealed class FlashSale
{
    private readonly TimeProvider _time;
    private readonly DateTimeOffset _endsAt;

    public FlashSale(TimeProvider time, DateTimeOffset endsAt)
    {
        _time = time;
        _endsAt = endsAt;
    }

    public bool IsActive => _time.GetUtcNow() < _endsAt;
}

public sealed class CartReservations(TimeProvider time)
{
    public async Task HoldAsync(string cartId, TimeSpan holdFor, CancellationToken cancellationToken)
    {
        await Task.Delay(holdFor, time, cancellationToken);
        Console.WriteLine($"{time.GetUtcNow():HH:mm} hold expired for {cartId}, stock returned");
    }
}
09:00 sale active: True
13:00 sale active: False
+14 min: released = False
13:15 hold expired for cart-42, stock returned
+15 min: released = True, real time spent: 0 ms

FlashSale now depends on the framework's type instead of its own IClock. The 15-minute Task.Delay in CartReservations goes through the same provider, so it completed when we moved the clock with Advance: at minute 14 the cart was still held, at minute 15 it was released, and the real time this took was 0 ms. Notice that the "hold expired" line was printed before the "+15 min" line; Advance fires the expired timers inside its own call. The (TimeProvider time) in the declaration of CartReservations is a C# 12 primary constructor; we cover it separately in the C# Versions series.

If you're staying on a project that targets .NET 6 or .NET Framework, the Microsoft.Bcl.TimeProvider package brings the same TimeProvider type. The new overloads come as extension methods there: time.Delay(delay) instead of Task.Delay(delay, time), and time.CreateCancellationTokenSource(delay) instead of new CancellationTokenSource(delay, time).

Parallel.ForAsync (.NET 8)

We saw Parallel.ForEachAsync in the series' parallelism post. If what you had was a range of indexes rather than a collection, on .NET 6 you first had to turn it into a sequence with Enumerable.Range. .NET 8 added the async counterpart of Parallel.For:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

int pagesFetched = 0;
var options = new ParallelOptions { MaxDegreeOfParallelism = 4 };

// .NET 6: an index range had to be turned into a sequence first
await Parallel.ForEachAsync(Enumerable.Range(1, 20), options, async (page, ct) =>
{
    await FetchCatalogPageAsync(page, ct);
});
Console.WriteLine($"ForEachAsync over Enumerable.Range: {Interlocked.Exchange(ref pagesFetched, 0)} pages");

// .NET 8: Parallel.ForAsync takes the range directly
await Parallel.ForAsync(1, 21, options, async (page, ct) =>
{
    await FetchCatalogPageAsync(page, ct);
});
Console.WriteLine($"ForAsync(1, 21): {pagesFetched} pages");

// works with any integer type: five 1 GB chunks of an export file, indexed by long
long bytesScheduled = 0;
await Parallel.ForAsync(0L, 5L, (chunk, ct) =>
{
    Interlocked.Add(ref bytesScheduled, 1_000_000_000L);
    return ValueTask.CompletedTask;
});
Console.WriteLine($"Chunks scheduled: {bytesScheduled:N0} bytes");

async Task FetchCatalogPageAsync(int page, CancellationToken cancellationToken)
{
    await Task.Delay(20, cancellationToken); // GET /catalog?page={page}
    Interlocked.Increment(ref pagesFetched);
}
ForEachAsync over Enumerable.Range: 20 pages
ForAsync(1, 21): 20 pages
Chunks scheduled: 5,000,000,000 bytes

The behavior is the same as ForEachAsync: the default concurrency limit is Environment.ProcessorCount, the body returns a ValueTask, and the token comes into the body. The difference is the signature: two numbers, the start inclusive and the end exclusive. The method is generic with a constraint on IBinaryInteger<T>, so it isn't limited to int; the last example ran with long. It's a use, inside the BCL, of the generic math we saw in the collections series' version post.

CancelAsync (.NET 8)

CancellationTokenSource.Cancel() runs every callback registered on the token synchronously, on the calling thread. If one of the callbacks is slow, the code that cancels waits just as long:

using System.Diagnostics;

await Measure("Cancel()", cts => { cts.Cancel(); return Task.CompletedTask; });
await Measure("CancelAsync()", cts => cts.CancelAsync());

static async Task Measure(string label, Func<CancellationTokenSource, Task> cancel)
{
    using var cts = new CancellationTokenSource();
    // a slow callback, e.g. flushing a half-written export file
    cts.Token.Register(() => Thread.Sleep(300));

    var stopwatch = Stopwatch.StartNew();
    Task cancellation = cancel(cts);
    long returnedAfter = stopwatch.ElapsedMilliseconds;
    bool requested = cts.IsCancellationRequested;
    await cancellation;

    Console.WriteLine($"{label,-14} returned after ~{returnedAfter / 100 * 100} ms, IsCancellationRequested: {requested}, callbacks done after ~{stopwatch.ElapsedMilliseconds / 100 * 100} ms");
}
Cancel()       returned after ~300 ms, IsCancellationRequested: True, callbacks done after ~300 ms
CancelAsync()  returned after ~0 ms, IsCancellationRequested: True, callbacks done after ~300 ms

Cancel() returned only after the callback finished, 300 ms later. CancelAsync() moved the token into the canceled state immediately (IsCancellationRequested was true right away), ran the callbacks in the background and handed you a task that tells you when they're done. If you trigger cancellation from inside a lock or from the UI thread, or if you don't know who registered which callback, writing await cts.CancelAsync() is the safer choice now. Even when you don't want to wait for it to finish, observe the task somewhere; exceptions thrown by the callbacks end up there.

What to Know When Upgrading

Everything in this post is a library API; setting TargetFramework to net8.0 is enough, independent of the language version. The SDK uses C# 12 by default for that target. Think about two things together when upgrading. First, if your existing code has its own IClock, you don't have to delete it overnight; write new code against TimeProvider first, and let the old interface live alongside it for a while through a thin adapter that forwards to it. Second, support dates: .NET 8 is LTS, but its support ends on November 10, 2026. All the APIs in this post exist as they are in .NET 10 (LTS, supported until November 14, 2028); if you're planning an upgrade today, your target should be .NET 10, not .NET 8.

Common Pitfalls

Using SuppressThrowing with Task<T>. We saw it above: the build warns (CA2261) and the runtime throws. Don't ignore the warning.

Using TimeProvider halfway. If one part of a class calls _time.GetUtcNow() while another uses DateTime.UtcNow, or a CancelAfter creates its token without a TimeProvider, the clock splits in two in tests: part of it moves forward, part of it stays in real time, and the test sometimes passes and sometimes fails. Tie every line that touches time to the same provider.

Assuming FakeTimeProvider makes everything synchronous. Advance fires expired timers, but if the continuation is code that waits for real I/O or the thread pool, the work may not be finished when Advance returns. In tests, await the relevant task after Advance; don't rely on IsCompleted.

Forgetting the task returned by CancelAsync. A fire-and-forget CancelAsync leaves exceptions from the callbacks somewhere nobody will see them; every fire-and-forget warning from the series' async void post applies here too.

A Small Experiment

★☆☆ Change the PeriodicTimer loop from the previous post to take a TimeProvider, and fire three ticks with FakeTimeProvider.Advance without waiting in real time at all.

★★☆ Add a TimeProvider parameter to RetryAsync from the previous post. Write a test for a scenario that fails three times and succeeds on the fourth attempt, without waiting for the close to 800 ms of backoff in total. How do you make the jitter deterministic?

★★★ Write a StopAsync that waits at most 2 seconds for five background tasks to finish when the application shuts down: use Task.WhenAll, WaitAsync(TimeSpan, TimeProvider) and ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing). If one task throws and another never finishes, what would you want to see in the logs?

In the next post we move on to .NET 9 and .NET 10: Task.WhenEach, which lets you process tasks in the order they finish, the System.Threading.Lock type that comes with C# 13's lock statement, and async LINQ, which moved into the framework in .NET 10.