13

After dealing with C#'s async/await pattern for a while now, I suddenly came to realization that I don't really know how to explain what happens in the following code:

async void MyThread()
{
    while (!_quit)
    {
        await GetWorkAsync();
    }
}

GetWorkAsync() is assumed to return an awaitable Task which may or may not cause a thread switch when the continuation is executed.

I wouldn't be confused if the await wasn't inside a loop. I'd naturally expect that the rest of the method (i.e. continuation) would potentially execute on another thread, which is fine.

However, inside a loop, the concept of "the rest of the method" gets a bit foggy to me.

What happens to "the rest of the loop" if the thread is switched on continuation vs. if it isn't switched? On which thread is the next iteration of the loop executed?

My observations show (not conclusively verified) that each iteration starts on the same thread (the original one) while the continuation executes on another. Can this really be? If yes, is this then a degree of unexpected parallelism that needs to be accounted for vis-a-vis thread-safety of the GetWorkAsync method?

UPDATE: My question is not a duplicate, as suggested by some. The while (!_quit) { ... } code pattern is merely a simplification of my actual code. In reality, my thread is a long-lived loop that processes its input queue of work items on regular intervals (every 5 seconds by default). The actual quit condition check is also not a simple field check as suggested by the sample code, but rather an event handle check.

3

2 Answers 2

8

You can actually check it out at Try Roslyn. Your await method gets rewritten into void IAsyncStateMachine.MoveNext() on the generated async class.

What you'll see is something like this:

            if (this.state != 0)
                goto label_2;
            //set up the state machine here
            label_1:
            taskAwaiter.GetResult();
            taskAwaiter = default(TaskAwaiter);
            label_2:
            if (!OuterClass._quit)
            {
               taskAwaiter = GetWorkAsync().GetAwaiter();
               //state machine stuff here
            }
            goto label_1;

Basically, it doesn't matter which thread you're on; the state machine can resume properly by replacing your loop with an equivalent if/goto structure.

Having said that, async methods don't necessarily execute on a different thread. See Eric Lippert's explanation "It's not magic" to explain how you can have working async/await on only one thread.

3
  • 2
    I seem to be underestimating the extent of rewriting that compiler does on my async code. In essence, there is no "loop" after the rewrite! That was the missing part for me. Awesome and thank you for the 'Try Roslyn' link as well!
    – aoven
    Feb 19, 2017 at 14:48
  • GOTO is the original loop construct. Let's not forget that.
    – user251748
    Feb 27, 2018 at 17:09
  • that "try roslyn" link made understanding a lot more better Aug 15, 2020 at 15:54
2

Firstly, Servy has written some code in an answer to a similar question, upon which this answer is based:

https://stackoverflow.com/questions/22049339/how-to-create-a-cancellable-task-loop

Servy's answer includes a similar ContinueWith() loop using TPL constructs without explicit use of the async and await keywords; so to answer your question, consider what your code might look when your loop is unrolled using ContinueWith()

    private static Task GetWorkWhileNotQuit()
    {
        var tcs = new TaskCompletionSource<bool>();

        Task previous = Task.FromResult(_quit);
        Action<Task> continuation = null;
        continuation = t =>
        {
            if (!_quit)
            {
                previous = previous.ContinueWith(_ => GetWorkAsync())
                    .Unwrap()
                    .ContinueWith(_ => previous.ContinueWith(continuation));
            }
            else
            {
                tcs.SetResult(_quit);
            }
        };
        previous.ContinueWith(continuation);
        return tcs.Task;
    }

This takes some time to wrap your head around, but in summary:

  • continuation represents a closure for the "current iteration"
  • previous represents the Task containing the state of the "previous iteration" (i.e. it knows when the 'iteration' is finished and is used for starting the next one..)
  • Assuming GetWorkAsync() returns a Task, that means ContinueWith(_ => GetWorkAsync()) will return a Task<Task> hence the call to Unwrap() to get the 'inner task' (i.e. the actual result of GetWorkAsync()).

So:

  1. Initially there is no previous iteration, so it is simply assigned a value of Task.FromResult(_quit) - its state starts out as Task.Completed == true.
  2. The continuation is run for the first time using previous.ContinueWith(continuation)
  3. the continuation closure updates previous to reflect the completion state of _ => GetWorkAsync()
  4. When _ => GetWorkAsync() is completed, it "continues with" _previous.ContinueWith(continuation) - i.e. calling the continuation lambda again
    • Obviously at this point, previous has been updated with the state of _ => GetWorkAsync() so the continuation lambda is called when GetWorkAsync() returns.

The continuation lambda always checks the state of _quit so, if _quit == false then there are no more continuations, and the TaskCompletionSource gets set to the value of _quit, and everything is completed.

As for your observation regarding the continuation being executed in a different thread, that is not something which the async/await keyword would do for you, as per this blog "Tasks are (still) not threads and async is not parallel". - https://blogs.msdn.microsoft.com/benwilli/2015/09/10/tasks-are-still-not-threads-and-async-is-not-parallel/

I'd suggest it is indeed worth taking a closer look at your GetWorkAsync() method with regards to threading and thread safety. If your diagnostics are revealing that it has been executing on a different thread as a consequence of your repeated async/await code, then something within or related to that method must be causing a new thread to be created elsewhere. (If this is unexpected, perhaps there's a .ConfigureAwait somewhere?)

2
  • 2
    The code I've shown is (very) simplified. Inside GetWorkAsync() there are multiple further awaits. Some of them access the database and network, which means true I/O. As I understand, the thread switch is a natural consequence (albeit not required) of such awaits, because the initial thread doesn't establish any synchronization context that would govern where the continuations should execute. So they execute on a thread pool thread. Is my reasoning wrong?
    – aoven
    Feb 19, 2017 at 15:29
  • @aoven Good point - I didn't consider the different kinds of SynchronizationContext - that is certainly important since .ContinueWith() uses the SynchronizationContext to dispatch the continuation; it would indeed explain the behaviour you're seeing if await is called on a ThreadPool thread or an ASP.NET thread. A continuation could certainly be dispatched to a different thread in those cases. On the other hand, calling await on a single-threaded context such as a WPF Dispatcher or Winforms context should be enough to ensure the continuation happens on the original. thread Feb 20, 2017 at 10:43

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.