May 5th, 2022

On awaiting a task with a timeout in C#

Say you have an awaitable object, and you want to await it, but with a timeout. How would you build that?

What you can do is use a when_any-like function in combination with a timeout coroutine. For C# this would be something like

await Task.WhenAny(
    DoSomethingAsync(),
    Task.Delay(TimeSpan.FromSeconds(1)));

The WhenAny method completes as soon as any of the passed-in tasks completes. It returns the winner, which you can use to detect whether the operation completed or timed out:

var somethingTask = DoSomethingAsync();
var winner = await Task.WhenAny(
    somethingTask,
    Task.Delay(TimeSpan.FromSeconds(1)));
if (winner == somethingTask)
{
    // hooray it worked
}
else
{
    // sad, it timed out
}

If the operation produced a result, you’ll have to create a timeout task that completes with the same result type, even if you never actually use that result.

static async Task<T>
DelayedDummyResultTask<T>(TimeSpan delay)
{
    await Task.Delay(delay);
    return default(T);
}

var somethingTask = GetSomethingAsync();
var winner = await Task.WhenAny(
    somethingTask,
    DelayedDummyResultTask<Something>(TimeSpan.FromSeconds(1)));
if (winner == somethingTask)
{
    // hooray it worked
}
else
{
    // sad, it timed out
}

The purpose of the Delayed­Dummy­Result­Task is not to produce a result, but rather to provide a delay.

We can wrap this up in a helper:

static async Task<(bool, Task<T>)>
TaskWithTimeout<T>(
    Task<T> task,
    TimeSpan timeout)
{
    var winner = await Task.WhenAny(
        task, DelayedDummyResultTask<T>(timeout));
    return (winner == task, winner);
}

var (succeeded, task) = await TaskWithTimeout(
    GetProgramAsync(), TimeSpan.FromSeconds(1));
if (succeeded) {
    UseIt(task.Result);
} else {
    // Timed out
}

The usage pattern here is still rather clunky, though.

One common pattern is to call the method, but abandon it and return some fallback value instead (typically false or null):

static async Task<T>
DelayedResultTask<T>(TimeSpan delay, T result = default(T))
{
    await Task.Delay(delay);
    return result;
}

static async Task<T>
TaskWithTimeoutAndFallback<T>(
    Task<T> task,
    TimeSpan timeout,
    T fallback = default(T))
{
    return (await Task.WhenAny(
        task, DelayedResultTask<T>(timeout, fallback))).Result;
}

This time, our delayed dummy result is no longer a dummy result. If the task times out, then the result of Task.WhenAny is the timeout task, and its result is what becomes the result of the Task­With­Timeout­AndFallback.

Another way of writing the above would be

static async Task<T>
TaskWithTimeoutAndFallback<T>(
    Task<T> task,
    TimeSpan timeout,
    T fallback = default(T))
{
    return await await Task.WhenAny(
        task, DelayedResultTask<T>(timeout, fallback));
}

which you might choose if only because it give you a rare opportunity to write await await.

You could call the function like this:

var something = TaskWithTimeoutAndFallback(
    GetSomethingAsync(), TimeSpan.FromSeconds(1));

The value in something is the result of Get­Something­Async() or null.

It might be that the fallback result is expensive to calculate. For example, it Get­Something­Async times out, maybe you want to query some alternate database to get the fallback value. So maybe we could have a version where the fallback value is generated lazily.

static async Task<T>
DelayedResultTask<T>(TimeSpan delay, Func<T> fallbackMaker)
{
    await Task.Delay(delay);
    return fallbackMaker();
}

static async Task<T>
TaskWithTimeoutAndFallback<T>(
    Task<T> task,
    TimeSpan timeout,
    Func<T> fallbackMaker)
{
    return await await Task.WhenAny(
        task, DelayedResultTask<T>(timeout, fallbackMaker));
}

var something = TaskWithTimeoutAndFallback(
    GetSomethingAsync(), TimeSpan.FromSeconds(1),
    () => LookupSomethingFromDatabase());

As a special case, you might want to raise a Timeout­Exception instead of a fallback value. You could do that by passing a lambda that just throws the Timeout­Exception instead of producing a fallback value.

var something = TaskWithTimeoutAndFallback(
    GetSomethingAsync(), TimeSpan.FromSeconds(1),
    () => throw TimeoutException());

This is probably a common enough pattern that we could provide a special helper for it.

static async Task<T>
DelayedTimeoutExceptionTask<T>(TimeSpan delay)
{
    await Task.Delay(delay);
    throw new TimeoutException();
}

static async Task<T>
TaskWithTimeoutAndException<T>(
    Task<T> task,
    TimeSpan timeout)
{
    return await await Task.WhenAny(
        task, DelayedTimeoutExceptionTask<T>(timeout));
}

// throws TimeoutException on timeout
var something = TaskWithTimeoutAndFallback(
    GetSomethingAsync(), TimeSpan.FromSeconds(1));

Note that in all of this, the task that timed out continues to run to completion. It’s just that we’re not paying attention to it any more. If you want to cancel the abandoned task, you need to hook up a task cancellation source when you create it, assuming that’s even possible.

In the special case where the Task came from a Windows Runtime asynchronous action or operation, you can hook up the cancellation token yourself:

var source = new CancellationTokenSource();
var something = TaskWithTimeoutAndFallback(
    o.GetSomethingAsync().AsTask(source.token),
    TimeSpan.FromSeconds(1));
source.Cancel();
source.Dispose();

// see what's in the "something"

If you prefer to exit with an exception, then you need to cancel the operation in your timeout handler:

var source = new CancellationTokenSource();
try {
    var something = TaskWithTimeoutAndException(
        o.GetSomethingAsync().AsTask(source.token),
        TimeSpan.FromSeconds(1));
} catch (TimeoutException) {
    source.Cancel();
} finally {
    source.Dispose();
}

That was a very long discussion, and I haven’t even gotten to the original purpose of writing about task cancellation with timeouts, which is to talk about how to do all of this in C++/WinRT. I’m tired, so we’ll pick this up next time.

Bonus reading: Crafting a Task.TimeoutAfter Method.

Topics
Code

Author

Raymond has been involved in the evolution of Windows for more than 30 years. In 2003, he began a Web site known as The Old New Thing which has grown in popularity far beyond his wildest imagination, a development which still gives him the heebie-jeebies. The Web site spawned a book, coincidentally also titled The Old New Thing (Addison Wesley 2007). He occasionally appears on the Windows Dev Docs Twitter account to tell stories which convey no useful information.

4 comments

Discussion is closed. Login to edit/delete existing comments.

  • 紅樓鍮 · Edited

    The C++ API has to be like this.
    <code>
    where
    <code>

    Funnily C++ has every weapon in its arsenal to write highly generic code like this, computing the exact needed discriminated union type from the input function type parameters, despite having ergonomically inferior discriminated unions than most "modern" languages, because you can't produce a discriminated union type on the fly in those "modern" languages like you can in C++ with .

    Read more
  • Michael Taylor

    In your first batch of code that isn't really a timeout to me. You're simply blocking until either the operation completes or some time has elapsed. The task you originally started is still going to run to completion. That seems wasteful of resources but I guess if you have that requirement.

    For the second batch of code though that seems like overkill. Maybe I'm missing something here but you don't need to create a custom function...

    Read more
  • switchdesktopwithfade@hotmail.com · Edited

    I might be wrong but I imagine the final version of this pattern would be:

    <code>

    A new cancellation token linked to the first parameter would be temporarily created inside this method just to represent this operation. Each delegate receives the linked CancellationTokenSource. When any of the delegates complete, the linked CancellationTokenSource is canceled to stop the other delegates. Any OperationCanceledExceptions are swallowed.

    Example:

    <code>

    EDIT: Changed TaskCompletionSource to CancellationTokenSource.

    Read more
    • anonymous · Edited

      this comment has been deleted.