DEV Community

Cover image for Mastering Task Cancellation in C#: A Practical Guide with Linked Tokens
Simone Riggi
Simone Riggi

Posted on

Mastering Task Cancellation in C#: A Practical Guide with Linked Tokens

Over the past few days, I worked on a microservice that heavily relied on asynchronous tasks, synchronized code sections, and monitoring jobs to keep track of the task status. While doing this, I struggled a bit to fully understand how the cancellation token pattern works in C#. In this article, I’ll share what I learned.


Async Programming in C

Async programming in C# is powerful—but as with great power comes great responsibility.

Long-running tasks can quickly become resource hogs if you don’t give them a way to stop when they’re no longer needed. That’s exactly where cancellation tokens come in.

In this guide, we’ll explore:

  • How to use CancellationToken

  • How to combine multiple cancellation sources with CreateLinkedTokenSource()

  • A practical demo: a fake file download manager with workers that can be cancelled by the user or automatically when they timeout


Why Cancellation Matters

Imagine starting a big file download—or any task that risks running forever in the background. Now imagine the user closes the app or decides to cancel. Without cancellation support, your tasks will continue running until completion, wasting CPU and memory.

Cancellation tokens provide a cooperative mechanism to signal when a task should stop.


A First Taste of Cancellation

Our demo will have the following requirements:

  1. Start 5 tasks simulating file downloads

  2. Each download should take roughly 5–10 seconds

  3. Show progress in the console

  4. Allow the user to cancel all downloads by pressing 'c'

  5. Support a timeout simulation on one of the tasks that cancels itself

  6. Print a success message if all tasks complete

static void Main(string[] args)
{
    var externalCancellation = new CancellationTokenSource();
    var app = new Program();

    Task.Run(() =>
    {
        Console.WriteLine("Press 'c' to cancel within 3 seconds after work begins.");
        Console.WriteLine("Or let the task time out by doing nothing.");
        if (Console.ReadKey(true).KeyChar == 'c')
            externalCancellation.Cancel();
    });

    // Wait 1 second before starting the main work
    Thread.Sleep(1000);

    Task[] tasks = new Task[5];
    for (int i = 0; i < 5; i++)
    {
        int workerIndex = i; // capture the current value
        tasks[i] = Task.Run(() =>
        {
            var timerTimeout = workerIndex == 1 ? 2000 : (int?)null;
            var worker = new BackgroundDownloadWorker(workerIndex, timerTimeout);
            worker.DoWork(externalCancellation.Token);
        });
    }

    try
    {
        Task.WhenAll(tasks).Wait();
        Console.WriteLine("Main: All workers completed successfully.");
    }
    catch (AggregateException ae)
    {
        foreach (var e in ae.InnerExceptions)
        {
            if (e is OperationCanceledException)
            {
                if (externalCancellation.IsCancellationRequested)
                    Console.WriteLine("Main: Operation cancelled by user.");
                else
                    Console.WriteLine("Main: Operation cancelled due to timeout or other reasons.");
            }
        }
    }

    Console.WriteLine("Press a key to exit");
    Console.ReadKey();
    externalCancellation.Dispose();
}

Enter fullscreen mode Exit fullscreen mode

Key Takeaways from the Main Program:

  • A separate task monitors for user cancellation via key press.

  • externalCancellation is used to cancel all running tasks when the user presses 'c'.

  • Each worker executes a simulated file download, with a unique index for console logging.

  • When cancellation occurs, an OperationCanceledException is thrown and caught, distinguishing between user-triggered and internal cancellations.


BackgroundDownloadWorker

class BackgroundDownloadWorker
{
    private CancellationTokenSource internalCancellation = new CancellationTokenSource();
    private CancellationToken internalCancellationToken;
    private int _workerIndex;
    private Timer _timer;

    public BackgroundDownloadWorker(int index, int? timerTimeout = null)
    {
        _workerIndex = index + 1;
        if (timerTimeout.HasValue)
            _timer = new Timer(TimeoutAction, null, timerTimeout.Value, Timeout.Infinite);
    }

    public void DoWork(CancellationToken externalCancellationToken)
    {
        internalCancellationToken = internalCancellation.Token;
        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(internalCancellationToken, externalCancellationToken);

        try
        {
            DoInternalWork(linkedCts.Token);
        }
        catch (OperationCanceledException)
        {
            if (internalCancellationToken.IsCancellationRequested)
                Console.WriteLine($"Worker {_workerIndex}:: download timeout");

            if (externalCancellationToken.IsCancellationRequested)
                Console.WriteLine($"Worker {_workerIndex}:: download cancelled by user");
        }
    }

    private void DoInternalWork(CancellationToken cancellationToken)
    {
        int secondsOfWork = _workerIndex switch
        {
            1 => 5000,
            2 => 8000,
            3 => 10000,
            _ => 7000
        };

        Console.WriteLine($"Worker {_workerIndex}:: downloading");

        for (int i = 0; i < secondsOfWork; i += 1000)
        {
            cancellationToken.ThrowIfCancellationRequested();
            Console.WriteLine($"Worker {_workerIndex}:: Working... {i / 1000 + 1} seconds elapsed.");
            Thread.Sleep(1000);
        }

        Console.WriteLine($"Worker {_workerIndex}:: Download completed successfully.");
    }

    private void TimeoutAction(object state)
    {
        Console.WriteLine($"Worker {_workerIndex}:: timeout expired");
        internalCancellation.Cancel();
        _timer.Dispose();
    }
}
Enter fullscreen mode Exit fullscreen mode

1. Fields

private CancellationTokenSource internalCancellation = new CancellationTokenSource();
private CancellationToken internalCancellationToken;
private int _workerIndex;
private Timer _timer;
Enter fullscreen mode Exit fullscreen mode
  • internalCancellation: A CancellationTokenSource that allows this worker to cancel itself, e.g., on timeout.

  • internalCancellationToken: Holds the CancellationToken from internalCancellation, to be used for checking if the task was canceled internally.

  • _workerIndex: Stores the worker’s index, mainly for logging purposes.

  • _timer: A System.Threading.Timer to enforce a timeout on the work

2. Constructor

public BackgroundDownloadWorker(int index, int? timerTimeout = null)
{
    _workerIndex = index + 1;
    if (timerTimeout.HasValue)
        _timer = new Timer(TimeoutAction, null, timerTimeout.Value, Timeout.Infinite);
}
Enter fullscreen mode Exit fullscreen mode
  • index: Used to differentiate workers.

  • timerTimeout: Optional parameter that sets a timeout in milliseconds.

  • If a timeout is provided, a timer is created, which will call _TimeoutAction _when the timeout expires.

3. Method

public void DoWork(CancellationToken externalCancellationToken)
{
    internalCancellationToken = internalCancellation.Token;
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(internalCancellationToken, externalCancellationToken);

    try
    {
        DoInternalWork(linkedCts.Token);
    }
    catch (OperationCanceledException)
    {
        if (internalCancellationToken.IsCancellationRequested)
            Console.WriteLine($"Worker {_workerIndex}:: download timeout");

        if (externalCancellationToken.IsCancellationRequested)
            Console.WriteLine($"Worker {_workerIndex}:: download cancelled by user");
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Accepts an external cancellation token, e.g., user requested cancellation.

  • Combines internal and external tokens into a linked token. This allows cancellation to occur from either source.

  • Calls DoInternalWork, which simulates the download.

  • Catches OperationCanceledException and logs the reason for cancellation:

    • Internal → timeout
    • External → user cancellation

Combining Multiple Cancellation Sources

Sometimes cancellation can come from multiple sources:

  • External cancellation – user input, signals, etc.

  • Internal cancellation – timeout, watchdog, or business logic

C# provides a neat solution:

using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(internalCancellationToken, externalCancellationToken);
Enter fullscreen mode Exit fullscreen mode

If either token is cancelled, the linked token triggers cancellation for the task.

4. DoInternalWork Method

private void DoInternalWork(CancellationToken cancellationToken)
{
    int secondsOfWork = _workerIndex switch
    {
        1 => 5000,
        2 => 8000,
        3 => 10000,
        _ => 7000
    };

    Console.WriteLine($"Worker {_workerIndex}:: downloading");

    for (int i = 0; i < secondsOfWork; i += 1000)
    {
        cancellationToken.ThrowIfCancellationRequested();
        Console.WriteLine($"Worker {_workerIndex}:: Working... {i / 1000 + 1} seconds elapsed.");
        Thread.Sleep(1000);
    }

    Console.WriteLine($"Worker {_workerIndex}:: Download completed successfully.");
}

Enter fullscreen mode Exit fullscreen mode
  • Simulates a download task by looping and sleeping 1 second at a time.

  • secondsOfWork is a rough duration for the task, based on _workerIndex.

  • Checks cancellationToken every second. If cancellation is requested, throws OperationCanceledException.

  • Logs progress to the console.

5. TimeoutAction Method

private void TimeoutAction(object state)
{
    Console.WriteLine($"Worker {_workerIndex}:: timeout expired");
    internalCancellation.Cancel();
    _timer.Dispose();
}

Enter fullscreen mode Exit fullscreen mode
  • called when the timer expires.

  • Cancels the worker using internal cancellation.

  • Disposes the timer to clean up resources.


Test Cases

To see the cancellation pattern in action, you can test two main scenarios:

1️⃣ User Cancels with 'c'

Scenario: The user decides to stop all downloads before they complete.

Steps:

  1. Run the program.

  2. When prompted, press 'c' within a few seconds.

  3. Observe the console logs.

Expected Behavior:

  • Each worker (1,3) prints a cancellation message
  • Worker (2) cancelled because of timeout expired

User cancels with 'c'


2️⃣ Let the Program Run Without Interruption

Scenario: The program is allowed to complete normally.
Steps:

  1. Run the program.

  2. Do not press 'c'.

  3. Wait for all workers to finish.

Expected Behavior:

  • Workers (1,3) complete their downloads successfully
  • Worker (2) cancelled because of timeout expired

Let the program run


Conclusion

In this article, we explored:

  • How to use CancellationToken to stop long-running tasks

  • How to link multiple cancellation sources (user input + internal timeout)

  • A practical demo with multiple background workers

This pattern is extremely useful in real applications:

  • Cancelling HTTP requests when users navigate away

  • Stopping database queries after a timeout

  • Managing parallel background jobs gracefully

Cancellation makes your applications more responsive, resource-friendly, and robust.

Top comments (0)