Skip to main content
ANVISoftware Solutions
Lesson 54 of 62Advanced17 min

Cancellation

By the end of this lesson

Let long-running work be stopped cleanly with a cancellation token.

Work does not always need to finish. Someone closes the browser tab before the report they asked for is built. A nightly import is still running when the maintenance window closes. A price lookup has taken eight seconds and the caller has already stopped caring.

C# has one mechanism for all of these. A CancellationToken is a small value you pass into an operation. The operation looks at it. If the token says a cancellation has been requested, the operation stops and says so.

The important word is looks. A token does not stop anything by itself. It carries a message, and code that never reads the message runs happily to the end.

Importing supplier orders, with the token passed all the way down
C#
public class OrderImportService
{
    private readonly HttpClient _httpClient;
    private readonly IOrderStore _orders;

    public OrderImportService(HttpClient httpClient, IOrderStore orders)
    {
        _httpClient = httpClient;
        _orders = orders;
    }

    public async Task<int> ImportAsync(
        IReadOnlyList<string> supplierIds,
        CancellationToken cancellationToken)
    {
        int imported = 0;

        foreach (string supplierId in supplierIds)
        {
            // Check before taking on another unit of work.
            cancellationToken.ThrowIfCancellationRequested();

            using HttpResponseMessage response = await _httpClient.GetAsync(
                $"suppliers/{supplierId}/orders",
                cancellationToken);

            response.EnsureSuccessStatusCode();

            List<Order>? batch = await response.Content
                .ReadFromJsonAsync<List<Order>>(cancellationToken);

            if (batch is null)
            {
                continue;
            }

            await _orders.SaveManyAsync(batch, cancellationToken);
            imported += batch.Count;
        }

        return imported;
    }
}
  • The token is an ordinary parameter. Convention puts it last and names it cancellationToken, which is worth following because tooling and reviewers both look for it there.
  • ThrowIfCancellationRequested is the loop's check. It does nothing when no cancellation has been asked for, and throws OperationCanceledException when one has. One check per supplier means the import stops within roughly one supplier's worth of work instead of at the very end.
  • Passing the token into GetAsync matters more than it appears to. Without it, a cancellation during a slow network call goes unnoticed until the call returns on its own, which might be thirty seconds later. With it, the request itself is abandoned.
  • The token also goes into ReadFromJsonAsync and into SaveManyAsync. Anything that accepts a token should be given one. A single method in the chain that drops it becomes the place where cancellation quietly stops working.
  • using on the response means it is disposed when the method exits, including when cancellation throws on the next iteration. Cancellation is an exception path, so your resource cleanup has to hold up on that path too.
  • The count is returned only on the success path. If cancellation throws, the caller gets no number, which is honest — the import did not finish.

Where the token you pass around actually comes from:

A parameter handed to you
Most of the time you do not create a token. You receive one and pass it on. Library methods, controller actions and background services are all given a token by the code above them.
HttpContext.RequestAborted
In ASP.NET Core this token is cancelled when the client disconnects. Add a CancellationToken parameter to a controller action and the framework binds this token to it without any wiring on your part.
CancellationTokenSource
The object that owns a token and is allowed to cancel it. You create one when you are the party deciding to stop — a Cancel button, a shutdown signal, a supervising loop. Hand out source.Token and keep the source to yourself.
CancelAfter and the timeout constructor
A source can cancel itself after a delay. This is how you say "give up after ten seconds" without writing any timing logic of your own.
CancellationTokenSource.CreateLinkedTokenSource
Combines tokens so the work stops when any one of them is cancelled. A time budget running out and the caller hanging up are both reasons to stop, and the code doing the work should not have to know which happened.
CancellationToken.None
An explicit "this cannot be cancelled". Reach for it deliberately, for instance when a cleanup step has to finish — not as a quick way to fill in a parameter you have not thought about.
A time budget, and dealing with the outcome at the boundary
C#
[HttpPost("imports")]
public async Task<IActionResult> RunImport(
    [FromBody] ImportRequest request,
    CancellationToken cancellationToken)
{
    // Stop if the caller disconnects, or after 30 seconds, whichever comes first.
    using CancellationTokenSource budget =
        CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    budget.CancelAfter(TimeSpan.FromSeconds(30));

    try
    {
        int imported = await _import.ImportAsync(request.SupplierIds, budget.Token);

        return Ok(new ImportResult(imported));
    }
    catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
    {
        // The client gave up. Nobody is waiting for a response.
        _logger.LogInformation("Import abandoned by the caller.");

        return new StatusCodeResult(499);
    }
    catch (OperationCanceledException)
    {
        // Our own budget ran out. That is worth knowing about.
        _logger.LogWarning("Import exceeded its 30 second budget and was stopped.");

        return StatusCode(StatusCodes.Status504GatewayTimeout);
    }
}
  • The action declares a CancellationToken parameter, so ASP.NET Core supplies the token tied to the client connection. No attribute and no service lookup is needed.
  • CreateLinkedTokenSource wraps that token in a new source. Cancelling the connection cancels budget.Token, and so does CancelAfter once thirty seconds pass. ImportAsync only has to watch one token.
  • Both catch blocks are for the same exception type, separated by a when filter. That filter is how you tell a client who left apart from a deadline you imposed on yourself.
  • Neither case is a bug, and that is the point of writing them separately. OperationCanceledException here means the system did what it was told. Logging it at error level teaches everyone to ignore your error log.
  • The 499 status code is a convention for "client closed the request", not part of the HTTP standard. It costs nothing since the client has gone, and it keeps these out of your 500 count.
  • Disposing the source matters. A linked source holds a registration on the token it was built from, and CancelAfter starts a timer. Both are released by the using declaration.

Summary

  • A CancellationToken carries a request to stop; it has no power to stop anything on its own
  • Cooperation is the contract: check the token regularly and pass it into every call that accepts one
  • Tokens usually arrive as a parameter — create a source only when you are the one deciding to stop, and use CancelAfter for a time budget
  • OperationCanceledException is the expected outcome of a cancellation, not a fault to alert on
  • Choose your check points so that stopping leaves your data in a state that still makes sense

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

Think about it

A background service imports 50,000 rows. It accepts the stopping token the host gives it, calls ThrowIfCancellationRequested once before the loop, then processes every row. Each row takes about 40 milliseconds.

On deployment the host asks the service to stop and, after its shutdown timeout, kills the process anyway. Logs show rows half-written. The token was accepted and checked. Why did none of that help?

Show solution

The check is in the wrong place. Checking once before the loop means the only moment cancellation can be observed is a moment before any work has been done. After that the service is committed to all 50,000 rows, which at 40 milliseconds each is over half an hour.

Shutdown timeouts are short — a handful of seconds by default. When the service does not return in time, the host stops waiting and the process ends mid-row. That is where the half-written data comes from.

The fix is to move the check inside the loop, so each iteration is a decision point, and to pass the token into the database calls as well. Then a stop request is honoured within one row rather than one run.

There is a second, deeper decision here. Stopping between rows is only safe if a partly finished import is meaningful. If it is not, the import needs a record of how far it got so the next run can resume, or a transaction per batch so an abandoned batch leaves nothing behind. Cancellation tells you when to stop; it does not tell you what a safe stop looks like.

Try it yourself

Try it yourself

You have a method that fetches a price from a supplier API. It accepts a CancellationToken and passes it to HttpClient. The supplier occasionally takes 45 seconds to answer, and your own callers cannot wait more than 5.

Add a 5 second budget to this one call without changing the caller's token, and decide what the method should do when the budget runs out rather than when the caller cancels.

Show solution

A linked source is the tool. It combines the caller's token with your own deadline, so the HTTP call watches a single token and does not need to know which condition fired.

Distinguishing the two cases needs the when filter, because both arrive as OperationCanceledException. Checking the caller's token tells you whether the caller left or your deadline expired.

Why return null for the timeout rather than letting it throw? Because a slow supplier is a normal, expected condition for this method, and the caller can fall back to a cached price. A caller that disconnected, by contrast, wants nothing from you, so rethrowing is correct — there is no one to hand a result to.

One detail that is easy to miss: the source must be disposed. CancelAfter registers a timer, and a linked source registers a callback on the token it wraps. Leaving these behind in a method called thousands of times per minute is a real leak.

C#
public async Task<decimal?> TryGetPriceAsync(
    string sku,
    CancellationToken cancellationToken)
{
    using CancellationTokenSource budget =
        CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

    budget.CancelAfter(TimeSpan.FromSeconds(5));

    try
    {
        string body = await _httpClient.GetStringAsync(
            $"prices/{sku}",
            budget.Token);

        return decimal.Parse(body);
    }
    catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
    {
        // Our 5 second budget expired. The caller is still there and can use a fallback.
        _logger.LogWarning("Price lookup for {Sku} timed out.", sku);

        return null;
    }
}

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A method accepts a CancellationToken, checks it at the start, then awaits an HTTP call without passing the token to it. The call takes 40 seconds. Cancellation is requested after 2 seconds. What happens?
Your error dashboard is full of OperationCanceledException entries from a search endpoint. What is the most likely explanation?

Saved in this browser only.