Practical .NET · Lesson 9

Timers and cancellation

A CancellationToken asks running work to stop.

Cancellation is a request that cooperating work observes

CancellationTokenSource owns the ability to request cancellation. Its token lets cooperating operations observe that request without giving every listener authority to cancel unrelated work. Calling Cancel does not forcibly destroy a method at an arbitrary instruction. Code must check the token or pass it to an operation that understands cancellation, then leave its work according to the agreed contract. This separation makes cleanup and completion behavior possible to reason about.

The example is a finite demonstration built from an apparently unbounded loop. It counts completed tick starts and requests cancellation after the third delay has completed. The token is checked at the top of each visit and also passed into Task.Delay. These are two observation points with different roles: one protects the synchronous portion between iterations, while the other can interrupt a pending supported wait if cancellation is requested during that wait. Neither requires creating a new operating-system thread.

A cancellation request reaches a token-aware operation, which observes it and ends through cancellation.View full size ↗
Cancellation is a request that participating code must observe.
Read the diagram

An owner calls Cancel on a CancellationTokenSource. Its tokens now report that cancellation has been requested.

A participating operation checks the token or passes it to a token-aware API. ThrowIfCancellationRequested throws OperationCanceledException when the request is present.

An async operation that propagates the cancellation exception completes its task as canceled; an awaiting caller can handle OperationCanceledException. Code that catches and suppresses it may instead complete successfully.

Cancel does not forcibly terminate a thread. An operation that ignores the token can continue. A completed operation is not retroactively undone by a later cancellation request.

Illustration created with ImageGen for SharpRune.

Locate the exact observation after the third tick

Initially the source is not canceled and ticks is zero. The top check succeeds, ticks becomes one, and the first line is tick 1. After its short delay completes, the equality test does not cancel yet. The second iteration similarly prints tick 2. The third prints tick 3, awaits its delay, and then calls Cancel because the counter is three. At that moment the third delay has already finished; the original code does not cancel it retroactively.

Control returns to the top of the loop. ThrowIfCancellationRequested now throws OperationCanceledException before the counter can increment to four. The catch prints stopped after 3 ticks. The unchanged output is therefore three tick lines followed by that final summary. using var cts disposes the source when the surrounding scope ends. Disposal releases owned resources; it is not the event that requests cancellation, and it must not be described as the reason this particular loop stops.

        cts.Token.ThrowIfCancellationRequested();
        ticks++;
        Console.WriteLine($"tick {ticks}");
        await Task.Delay(10, cts.Token);
        if (ticks == 3) cts.Cancel();

A delay is not a precise periodic clock

Task.Delay requests a minimum waiting interval, while scheduling can make resumption later. Three ticks do not prove a precise thirty-millisecond schedule. Also do not remove the top check and assume Cancel magically ends while true; without a later token-aware observation, synchronous work could continue. A token source remains canceled after the request and is not reset for reuse. Use a new source for a new operation, and distinguish cooperative token cancellation from SharpRune's stronger Stop boundary that terminates execution.

Change the deterministic stop count, then cancel before work

First change the equality condition from three to five. Predict tick lines one through five and a summary saying stopped after five ticks. Hint: cancellation is requested after the fifth wait and observed before a sixth increment. Keep the delay value unchanged so the experiment isolates the count rule. Do not turn this into a CancelAfter timing test if you need an exact tick-count expectation; elapsed scheduling introduces another variable.

Next request cancellation immediately before entering the try block. Predict no tick lines and a summary saying stopped after zero ticks. Hint: the first top-of-loop check observes the request before any increment. Restore the original program and move the third-tick request before its delay in a separate experiment. Explain why the printed count may remain three while the exception is now observed through awaiting the canceled delay rather than through the following loop check.

Name the owner and every observation point

Identify who owns the source, which code only receives a token, where the request occurs, and where it becomes an exception in the unchanged sample. This timeline is more useful than saying cancellation stops the loop automatically. In a larger operation, pass the token into supported waits and I/O, define the scope of the cancellation request, and preserve the distinction between successful completion, cooperative cancellation, and an unrelated failure.

  • Cancel is called after the third delay completes.
  • The next top check prevents a fourth tick.
  • The original summary is stopped after 3 ticks.
  • Disposing the source is different from requesting cancellation.

A working example

using var cts = new CancellationTokenSource();
var ticks = 0;

try
{
    while (true)
    {
        cts.Token.ThrowIfCancellationRequested();
        ticks++;
        Console.WriteLine($"tick {ticks}");
        await Task.Delay(10, cts.Token);
        if (ticks == 3) cts.Cancel();
    }
}
catch (OperationCanceledException)
{
    Console.WriteLine($"stopped after {ticks} ticks");
}

Expected output

tick 1
tick 2
tick 3
stopped after 3 ticks
Try this
Cancel is a request. The loop has to check the token.

Further reading