4

While using the .NET async/await API I ran into a curiosity: a loop was ignoring a delay used as a timeout, until I added a short delay inside the loop. How does this work? Not the most intuitive behavior!

Full program:

using System;
using System.Threading.Tasks;

public class Program
{
    public static void Main(String[] args)
    {
        Task.Run(async () =>
        {
            await Task.WhenAny(Loop(), Task.Delay(TimeSpan.FromSeconds(1)));
            Console.WriteLine("Timed out!");
        })
        .Wait();
    }

    public static async Task Loop()
    {
        while(true)
        {
            // Commenting this out makes the code loop indefinitely!
            await Task.Delay(TimeSpan.FromMilliseconds(1));

            // This doesn't matter.
            await DoWork();
        }
    }

    public static async Task DoWork()
    {
        await Task.CompletedTask;
    }
}

Background

The actual program has while(!done) but due to a bug done is never set to true. The loop makes many await calls. The Task.WhenAny call is in a unit test to prevent Loop() from hanging. If I introduce a bug on purpose most of the time the test indeed times out, but sometimes it still just hangs.

Suggested workaround that doesn't require Task.Delay in Loop()

bool completedOnTime = Task.Run(() => Loop()).Wait(TimeSpan.FromSeconds(1));

This will start a new thread executing the Loop() method.

Related questions

When would I use Task.Yield()?

Konrad Jamrozik
  • 3,254
  • 5
  • 29
  • 59
  • 1
    Something looks off, have you considered using a `CancellationToken` to stop your `Task Loop()` when your timeout task completes? The code smells funny here, because your `Task.Loop()` will never stop and I don't see a clear way implemented to stop it. – Svek May 20 '17 at 04:39
  • You are right, to actually stop it a cancellation token in WhenAny is required. The problem is, however, we will never have a chance to issue a cancellation request. This is because the await WhenAny will never return. – Konrad Jamrozik May 20 '17 at 05:09
  • 1
    @KonradJamrozik actually the problem is even further up, it is `Loop` that is never returning, not `WaitAny`, if you moved that call to a `var loopTask = Loop(); await Task.WhenAny(loopTask, Task.Delay(TimeSpan.FromSeconds(1)));` you can see it in the debugger. – Scott Chamberlain May 20 '17 at 05:37

2 Answers2

6

when you await a Task it first checks to see if the task is complete, if it is complete it just continues the execution and never returns to the caller. Because of this the call to await DoWork(); will never cause you to return to the calling method, it will just synchronously continue on in the method.

When you remove the delay you now have the equivalent of having

public static async Task Loop()
{
    while(true)
    {
    }
}

so the loop will loop forever without ever giving control back to the caller. In situations like this where you don't know if you will be returning to the caller or not and you want to guarantee you don't loop forever you could rewrite your code as

public static async Task Loop()
{
    while(true)
    {
        var workTask = DoWork();
        if(workTask.GetAwaiter().IsCompleted) //This IsCompleted property is the thing that determines if the code will be synchronous.
            await Task.Yield(); //If we where syncronous force a return here via the yield.
        await workTask; //We still await the task here in case where where not complete, also to observe any exceptions.
    }
}
Scott Chamberlain
  • 124,994
  • 33
  • 282
  • 431
4

Your current Loop() task will loop forever with your while(true) condition:

public static async Task Loop()
{
    while(true) { } // this iteration will never end.
                    // as noted by Scott Chamberlain's answer, the caller will
                    // never regain control of this task
}

You should consider passing in a CancellationToken to break your loop.

public static async Task Loop(CancellationTokenSource cts)
{
    while (cts != null && !cts.IsCancellationRequested)
    {
        // your work you want to keep iterating until cancelled
    }
}

I've borrowed from this answer to help explain, which also agrees with my suggestion:

When the first task completes, consider whether to cancel the remaining tasks. If the other tasks are not canceled but are also never awaited, then they are abandoned. Abandoned tasks will run to completion, and their results will be ignored. Any exceptions from those abandoned tasks will also be ignored.

Additional resources: Crafting a Task.TimeoutAfter Extension Method

Community
  • 1
  • 1
Svek
  • 12,350
  • 6
  • 38
  • 69
  • @Svek I think canceling tasks that may have side effects (console or file writing, or some other visible effect) is not a good idea. – Paul Stelian May 20 '17 at 05:35
  • Assume the loop condition is actually "while(!done)" but the "done" is never set to true due to a bug. The WhenAny timeout is used in a test to prevent the test from hanging in case of a bug. ln such scenario adding cancellation token checks would amount to polluting production code with test code :( – Konrad Jamrozik May 20 '17 at 06:17
  • @PaulStelian there should not be any "side effects'. The token is checked each iteration, in addition it would not interrupt any statements within the `Loop` method. – Svek May 20 '17 at 06:19
  • @KonradJamrozik it's still the same code if you want to introduce `done` to the condition, you'd do something like `while (cts != null && !cts.IsCancellationRequested && !done)`. My undestanding is that you wanted to break the iteration from looping infinitely. – Svek May 20 '17 at 06:24
  • 1
    @Svek but the cancellation token source is added only to protect against bugs. Not a clean design. l would had nothing against an assertion but l find it hard to justify and explain the cts is only there "just in case", not a part of required business logic. – Konrad Jamrozik May 20 '17 at 06:29
  • 1
    @KonradJamrozik if your intent is not to stop the iteration, please help me understand what you are trying to accomplish? You could simulate a "long running process" using `Thread.Sleep()`, but your question states the method is called `Loop` not `SampleLongProcess`, I hope I am making sense. – Svek May 20 '17 at 06:29
  • 1
    @KonradJamrozik - I see your point about using the `cts` as it could be understood as the wrong thing to use to break the iteration. Well noted... You could just as easily throw in your own argument such as `done` instead of passing in `cts`. It's a bit hard to come up with a complete answer to a loosely-ambiguous case. – Svek May 20 '17 at 06:31
  • @Svek I added some background to the question. Some "safety kill-switch" for the loop looks like a good idea, but I think it could be made simpler than with `cts`. Also please note that technically speaking I am asking `why` does it loop, not `how to fix it` ;-) – Konrad Jamrozik May 21 '17 at 19:08