3
\$\begingroup\$

Often I have async functions that I want to be able to time out, this timeout is independent of the greater scope of the application. However the function should also take into consideration the greater scope and thus needs to receive the CancellationToken that will indicate when the application scope or some other higher-level scope cancels.

I haven't been able to find an easy/standard way to deal with this type of situation, so I decided to write a small extension method to create a new CancellationToken that will cancel once the original cancels but also after a timeout period.

I created the following to be able to easily/concisely add a token with timeout to any task accepting a cancellation token:

        public static CancellationToken NewWithTimeout(this CancellationToken original, int millisecondTimeout)
        {
            // EDIT: From comment, this part can be condensed into:
            var src = CancellationTokenSource.CreateLinkedTokenSource(original);
            src.CancelAfter(millisecondTimeout);
            // CancellationTokenSource src = new(millisecondTimeout);
            // original.Register(src.Cancel);
            return src.Token;
        }

        public static CancellationToken NewWithTimeout(this CancellationToken original, TimeSpan timeout)
        {
            // EDIT: From comment, this part can be condensed into:
            var src = CancellationTokenSource.CreateLinkedTokenSource(original);
            src.CancelAfter(timeout);
            // CancellationTokenSource src = new(timeout);
            // original.Register(src.Cancel);
            return src.Token;
        }

Then I also added the following to facilitate creating methods that can be timed out. This way the method will explicitly show it expects a timeout and it will internally have access to the timeout passed in to be used internally.

   public class CancellationTokenWithTimeout
    {
        public CancellationToken Original { get; set; }
        public CancellationToken Combined { get; set; }
        public TimeSpan Timeout { get; set; }

        public CancellationTokenWithTimeout(int millisecondTimeout, CancellationToken original)
        {
            CancellationTokenSource src = new(millisecondTimeout);
            original.Register(src.Cancel);
            Original = original;
            Combined = src.Token;
            Timeout = TimeSpan.FromMilliseconds(millisecondTimeout);
        }

        public CancellationTokenWithTimeout(TimeSpan timeout, CancellationToken original)
        {
            CancellationTokenSource src = new(timeout);
            original.Register(src.Cancel);
            Original = original;
            Combined = src.Token;
            Timeout = timeout;
        }
    }

    public static class ExtensionMethods
    {

        public static CancellationTokenWithTimeout AddTimeout(this CancellationToken original, int millisecondTimeout)
        {
            return new CancellationTokenWithTimeout(millisecondTimeout, original);
        }

        public static CancellationTokenWithTimeout AddTimeout(this CancellationToken original, TimeSpan timeout)
        {
            return new CancellationTokenWithTimeout(timeout, original);
        }

        public static CancellationToken NewWithTimeout(this CancellationToken original, int millisecondTimeout)
        {
            CancellationTokenSource src = new(millisecondTimeout);
            original.Register(src.Cancel);
            return src.Token;
        }

        public static CancellationToken NewWithTimeout(this CancellationToken original, TimeSpan timeout)
        {
            CancellationTokenSource src = new(timeout);
            original.Register(src.Cancel);
            return src.Token;
        }
    }

With this implementation you can now do the following:

    public class SomeWorker : BackgroundService
    {
        private readonly ILogger<SomeWorker> _logger;

        public SomeWorker(ILogger<SomeWorker> logger)
        {
            _logger = logger;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                _logger.LogInformation("SomeWorker running at: {time}", DateTimeOffset.Now);
                try
                {
                    var longTask = DoSomethingAsync("Hello", stoppingToken.NewWithTimeout(2000));
                    await Task.Delay(1000, stoppingToken);
                    _logger.LogInformation("One second has passed!");
                    await longTask;
                    _logger.LogInformation("Long task is done.");
                }catch (OperationCanceledException ex)
                {
                    if (stoppingToken.IsCancellationRequested)
                        _logger.LogInformation("We got cancelled!");
                    else
                        _logger.LogInformation("Task timed out!");

                }

                _logger.LogInformation("Another Test with explicit timeout!");

                try
                {
                    var longTask = DoSomethingAsyncWithTimeout("Hello", stoppingToken.AddTimeout(2000));
                    await Task.Delay(1000, stoppingToken);
                    _logger.LogInformation("One second has passed!");
                    await longTask;
                    _logger.LogInformation("Long task is done.");
                }
                catch (OperationCanceledException ex)
                {
                    if (stoppingToken.IsCancellationRequested)
                        _logger.LogInformation("We got cancelled!");
                    else
                        _logger.LogInformation("Task timed out! This must have come from the inner task...");
                }
                catch (TimeoutException timeout)
                {
                    _logger.LogInformation("Task timed out! Either from the task or the inner task");
                }

            }
        }
        public async Task<string> DoSomethingAsync(string param1, CancellationToken cancellationToken)
        {
            while (!cancellationToken.IsCancellationRequested)
            {
                (bool done, string result) = await SomeOtherAsync(cancellationToken.Combined);
                if (done) return result;
            }
            throw new TaskCanceledException();

        }

        public async Task<string> DoSomethingAsyncWithTimeout(string param1, CancellationTokenWithTimeout cancellationToken)
        {
            while (!cancellationToken.Combined.IsCancellationRequested)
            {
                (bool _, string intermediateresult) = await SomeOtherAsync(cancellationToken.Combined);
                (bool done, string result) = await SomeOtherAsyncWithTimeout(intermediateresult, cancellationToken.Original, cancellationToken.Timeout);
                if (done) return result;
            }
            if (cancellationToken.Original.IsCancellationRequested)
                throw new TaskCanceledException();
            else
                throw new TimeoutException();
        }

    }

I would like to know if there are any issues with this implementation and also if there's a similarly concise way of doing this without any custom extension. Or any improvements if this is a sensible thing to do.

\$\endgroup\$
6
  • \$\begingroup\$ You should check out CancellationTokenSource specifically the CreateLinkedToken. You can make a new CancellationToken from two tokens and you can use the CancelAfter method to make a token that auto cancels after a TimeSpan \$\endgroup\$ Mar 17, 2022 at 16:54
  • \$\begingroup\$ Ok, so then one can achieve pretty much the same as I'm doing by: CancellationTokenSource.CreateLinkedTokenSource(stoppingToken).CancelAfter(2000); instead of stoppingToken.NewWithTimeout(2000) I guess it's not too bad and doesn't require anything non-standard \$\endgroup\$
    – Adriaan
    Mar 17, 2022 at 17:05
  • \$\begingroup\$ I changed my implementation and using CancelAfter doesn't make it any prettier... still requires a temp variable with multiple lines to achieve the simple timeout behaviour (see update to the question). So it still warrants being wrapped in an extension method in my opinion. \$\endgroup\$
    – Adriaan
    Mar 17, 2022 at 17:18
  • \$\begingroup\$ @Adriaan Are you aware of the Polly's Timeout policy? \$\endgroup\$ Mar 25, 2022 at 7:47
  • 1
    \$\begingroup\$ @PeterCsala No, I was not aware of it, it looks like exactly what I wanted and way better than what I implemented! Please make that into an answer so I can accept it. I'll me moving my code to using that instead. \$\endgroup\$
    – Adriaan
    Mar 25, 2022 at 9:47

2 Answers 2

2
\$\begingroup\$

There is a .NET library called Polly, which defines a couple of generic resilience policies. The most well-known ones are Timeout, Retry and Circuit Breaker.

The policies are used as decorators. You define how a policy should behave and then apply it to any arbitrary code. The sync and async codes are handled differently so you have to be aware of this when you define a policy.

The Timeout policy can work in two modes: optimistic and pessimistic. The former one allows you to cancel the decorated method either by the user provided CancellationToken or by the timeout policy itself.

public IAsyncPolicy CreateTimeoutConstraint(TimeSpan threshold)
    => Policy.TimeoutAsync(threshold, TimeoutStrategy.Optimistic);

public async Task ExecuteMethodWithTimeConstraintAsync(CancellationToken userProvideToken)
{
    var timeoutPolicy = CreateTimeoutConstraint(TimeSpan.FromSeconds(2));
    await timeoutPolicy.ExecuteAsync(async (ct) => await SomeBackgroundTask(ct), userProvideToken);
}
\$\endgroup\$
2
  • 1
    \$\begingroup\$ This is really exactly what I was looking for. Thanks for pointing me to this amazing library! \$\endgroup\$
    – Adriaan
    Mar 25, 2022 at 12:54
  • \$\begingroup\$ Seems you keep answering my questions, you don't perhaps have a blog or some social media channel I can follow where you share you wisdom? :) \$\endgroup\$
    – Adriaan
    Mar 25, 2022 at 12:55
0
\$\begingroup\$

If creating an extension method should return CancellationTokenSource so it can be disposed. See StackOverflow question "When to dispose CancellationTokenSource?"

Example of how I typically add a timeout to an existing token.

// This token will cancell when timeelaspes or the stoppingToken is cancelled
using var timeOutTokenSource = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
timeOutTokenSource.CancelAfter(TimeSpan.FromSeconds(2));
// Pass the linked Token down into calling method
await DoSomethingAsync("hello", timeOutTokenSource.Token);

As far as the CancellationTokenWithTimeout I would make it use the extension methods in the constructor to make a new CancellationTokenTokenSource and also IDisposable to dispose of the resources. I personally never ran into a situation when I needed a class like CancellationTokenWithTimeout as usually just having one token passed in that handled to cancel based on other tokens was good enough.

If I'm looking to have a timeout we can accomplish the same thing without having to create a new token

// store task we want to wait
var doSomethingTask = DoSomethingAsync("hello", stoppingToken);
// Make a task that is Delayed based on timeout and return when either finish
await Task.WhenAny(Task.Delay(TimeSpan.FromSeconds(2), stoppingToken), doSomethingTask);

if (!doSomethingTask.IsCompleted)
{
    // We are here because Task.Delay completed this is out timeout code
}

You make still want to make a CancellationTokenSource that is linked and pass down even if using Task.Delay so you can cancel the token in the timeout code.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.