2

The following program executes both WriteLines, even though the task is never awaited:

var nonAwaitedTask = Foo();
Thread.Sleep(10);

static async Task Foo()
{
    Console.WriteLine("before delay");
    await Task.Delay(1);
    Console.WriteLine("after delay");
}

When removing Thread.Sleep the second WriteLine is not executed.

Is this a race condition even though only the main thread should be involved? When and why does the (main) thread continue execution of the async code (MoveNext)?


I could see multiple scenarios where the WriteLine could be executed, e.g.:

  • if await Task.Delay(0) was used (awaiter.IsCompleted is immediately true and Foo would not yield).
  • if await Task.Delay(1).ConfigureAwait(false) was used (a different thread could run the code while the main thread is blocking on Thread.Sleep)
  • if we await (e.g. a Task.Delay(10)) in Main (instead of blocking on Thread.Sleep) which would allow Foo to run concurrently.

I understand that the latter two would be race-conditions and that the first compiler optimisation might not be guaranteed (in short, just await your tasks). However, my mental model where only one thread is involved and where this thread could only continue execution on well defined (await) points does not seem to hold.

Any explanations are highly appreciated!

dsybot
  • 31
  • 4
  • 1
    Set a breakpoint on the second Write line and notice that it is running on another thread. That's because the task is running in the multithreaded apartment, so it can resume on any background thread. If you mark your Main function as [STAThread] then it will run only in that thread. – Raymond Chen Apr 04 '23 at 14:17
  • Thanks Raymond Chen! I can still observe the same behaviour with `[STAThread]` though. – dsybot Apr 04 '23 at 15:03

2 Answers2

3

If this is all your program then you can call it kind of race condition between the process being closed and Foo being completed. You can rewrite the app to:

var nonAwaitedTask = Foo();
Console.ReadKey();

// ...

With the same effect as Thread.Sleep(10);.

Task.Delay(1); is so called "truly async" operation and since it seems you are running a console app with no synchronization context involved (hence ConfigureAwait will have no effect) so the continuation (Console.WriteLine("after delay");) will be scheduled on any thread pool thread. await-ing does not affect the task completion it affects the calling method being able to wait/observe it.

var nonAwaitedTask = Foo(); is basically a fire-and-forget task.

Read more:

Guru Stron
  • 102,774
  • 10
  • 95
  • 132
  • Thanks Guru Stron! My confusion comes from the fact that the main thread would seemingly never have the chance to continue execution (it is "occupied" for the whole program as all calls are blocking), and i was under the impression that no other thread could run the code (e.g. without .ConfigureAwait(false)). – dsybot Apr 04 '23 at 14:41
  • @dsybot As I wrote in the answer (and it is covered in some of the links) `ConfigureAwait` affects working with the synchronization context (which can affect what thread is used for continuation). Not all apps have it, by default there is no synchronization context is not present in console apps, or modern ASP.NET Core (but UI apps like WPF or WinForms have). – Guru Stron Apr 04 '23 at 14:44
  • @dsybot also check [Don't Block on Async Code](https://blog.stephencleary.com/2012/07/dont-block-on-async-code.html) and [Don't Block in Asynchronous Code](https://blog.stephencleary.com/2012/12/dont-block-in-asynchronous-code.html) – Guru Stron Apr 04 '23 at 14:55
  • 1
    Thanks again Guru Stron! I missed the part about the (missing) synchronisation context; in the code I'm working in it exists, I tried to extract some code I investigated into a console app and was surprised by the behaviour. When multiple threads are allowed, there is not really a question and it behaves as expected! – dsybot Apr 04 '23 at 15:10
  • @dsybot UI apps allow multiple threads, the thing is that only one (the UI one) is allowed to modify the GUI. So question where continuation is executed becomes an important one (hence the sync context and `ConfigureAwait`). P.S. if any of the answers works for you - feel free to mark it as accepted one. – Guru Stron Apr 04 '23 at 15:13
  • 1
    Thanks Guru Stron! I was running in an app with synchronisation context, though my sample code here had none: I wasn't aware of that which led to this question and all the "different scenarios" bullet points! Since there is no thread context here, it all makes sense though! Upvoted both, answers, highly appreciated! – dsybot Apr 04 '23 at 15:35
0

I'm guessing you are running in a console program. This is somewhat different compared to a UI program

  1. If you use a regular void main you risk the program exiting before all tasks have completed. You can use 'async main', i.e. static async Task Main(string[] args) to avoid this.
  2. There is no UI context that ensure everything run on a single thread. Instead anything after an await will run on some arbitrary threadpool thread.

Is this a race condition even though only the main thread should be involved?

Yes, if you use await in a console program there will be multiple threads involved, unless you do something to override the scheduler.

I would recommend using the term 'ui thread', since that is less ambiguous than 'main thread'. In a console program the main thread is not that different from an arbitrary thread pool thread.

When and why does the (main) thread continue execution of the async code (MoveNext)?

The compiler will rewrite Foo so that it returns the task at the first await. At that point the caller is free to continue with other stuff unless it awaits the returned task.

In the end I would recommend using an async main method, and ensure that all tasks are awaited. That should help a bit at avoiding some of the threading issues.

JonasH
  • 28,608
  • 2
  • 10
  • 23
  • `This is somewhat different compared to a UI program` Thanks JonasH, this was key! So for UI apps (or e.g. Unity apps) the second print would not be executed? If there is no thread context in console apps, the observed behaviour clicks! – dsybot Apr 04 '23 at 15:06
  • @dsybot In a UI app "before delay" will print, then the UI thread will sleep for 10 seconds, and only after that it should print "after delay". – JonasH Apr 04 '23 at 15:10
  • Thanks again JonasH! I did a bad job translating the code I was looking at into a sample (the equivalent would have been that the app exited like the console app), and i was surprised to see that behaviour in the console sample! Your answer really helped me! – dsybot Apr 04 '23 at 15:29
  • Upvoted your answer, not sure why it still shows 0 upvotes! – dsybot Apr 04 '23 at 15:35