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?
But when you change the delay from
TimeSpan.FromSeconds(1)
toTimeSpan.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
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 theTextReader
object returned by theIn
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.