Search code examples
c#asynchronousasync-awaittask

In C# async/await, does delay length affect when control go back to the caller?


The following code works as expected - both services start at the same time.

List<Service> services = [ new Service(), new Service() ];

foreach (Service service in services)
{
    Task task = service.StartAsync();
}

Console.ReadLine();

class Service
{
    public async Task StartAsync()
    {
        await Console.Out.WriteLineAsync($"{DateTime.Now.ToString("HH:mm:ss")}\tStarted");

        await Task.Delay(TimeSpan.FromSeconds(1));

        //Simulate some synchronous work.
        Thread.Sleep(10_000);

        await Console.Out.WriteLineAsync($"{DateTime.Now.ToString("HH:mm:ss")}\tFinished");
    }
}

Output is:

20:32:25     Started
20:32:25     Started
20:32:35     Finished
20:32:35     Finished

But when you change the delay from TimeSpan.FromSeconds(1) to TimeSpan.FromMicroseconds(1), the output is:

20:40:10     Started
20:40:20     Finished
20:40:20     Started
20:40:30     Finished

Why does changing the delay affect behavior? Is there a race condition going on?


Solution

  • But when you change the delay from TimeSpan.FromSeconds(1) to TimeSpan.FromMicroseconds(1)...

    The .NET timers don't have the ability to be triggered in sub-millisecond timespans. Any timespan smaller than one millisecond is evaporated to zero:

    Task task = Task.Delay(TimeSpan.FromMicroseconds(999));
    Console.WriteLine(ReferenceEquals(task, Task.CompletedTask)); // True
    

    Online demo.

    So the await Task.Delay(TimeSpan.FromMicroseconds(1)); becomes await Task.CompletedTask;, which is a no-op. The execution flow continues synchronously. No scheduling takes place.

    The await Console.Out.WriteLineAsync is also synchronous, because the Console.Out doesn't override the default implementations of the asynchronous TextWriter APIs, that are all implemented synchronously. The documentation of the Console.Out doesn't say this, but the documentation of the Console.In does:

    Read operations on the standard input stream execute synchronously. That is, they block until the specified read operation has completed. This is true even if an asynchronous method, such as ReadLineAsync, is called on the TextReader object returned by the In property.

    You can also verify it experimentally:

    Task task = Console.Out.WriteLineAsync();
    Console.WriteLine(ReferenceEquals(task, Task.CompletedTask)); // True
    

    The result is that your StartAsync method becomes completely synchronous. Apart from its name and signature, there is nothing asynchronous in it. Everything inside it runs synchronously on the current thread. If you want to ensure that the execution of the services will be parallelized, you can use the Task.Run like this:

    foreach (Service service in services)
    {
        Task task = Task.Run(() => service.StartAsync());
    }
    

    This works because the Task.Run offloads the invocation of the function delegate to the ThreadPool.


    In case you don't have control on how the StartAsync is called, you can convert the StartAsync to a facade that just calls a private StartAsyncCore method, offloading the call to the ThreadPool:

    public Task StartAsync() => Task.Run(() => StartAsyncCore());
    
    private async Task StartAsyncCore()
    {
        // Implementation, that can be completely synchronous.
    }
    

    Exposing asynchronous wrappers for synchronous methods is inadvisable in general, but in this case you have no other option.