5

My understanding of the await keyword was that the code following the await qualified statement is running as the continuation of that statement once it is complete.

Hence the following two versions should produce the same output:

    public static Task Run(SemaphoreSlim sem)
    {
        TraceThreadCount();
        return sem.WaitAsync().ContinueWith(t =>
        {
            TraceThreadCount();
            sem.Release();
        });
    }

    public static async Task RunAsync(SemaphoreSlim sem)
    {
        TraceThreadCount();
        await sem.WaitAsync();
        TraceThreadCount();
        sem.Release();
    }

But they do not!

Here is the complete program:

using System;
using System.Threading;
using System.Threading.Tasks;

namespace CDE
{
    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                var sem = new SemaphoreSlim(10);
                var task = Run(sem);

                Trace("About to wait for Run.");

                task.Wait();

                Trace("--------------------------------------------------");
                task = RunAsync(sem);

                Trace("About to wait for RunAsync.");

                task.Wait();
            }
            catch (Exception exc)
            {
                Console.WriteLine(exc.Message);
            }
            Trace("Press any key ...");
            Console.ReadKey();
        }

        public static Task Run(SemaphoreSlim sem)
        {
            TraceThreadCount();
            return sem.WaitAsync().ContinueWith(t =>
            {
                TraceThreadCount();
                sem.Release();
            });
        }

        public static async Task RunAsync(SemaphoreSlim sem)
        {
            TraceThreadCount();
            await sem.WaitAsync();
            TraceThreadCount();
            sem.Release();
        }

        private static void Trace(string fmt, params object[] args)
        {
            var str = string.Format(fmt, args);
            Console.WriteLine("[{0}] {1}", Thread.CurrentThread.ManagedThreadId, str);
        }
        private static void TraceThreadCount()
        {
            int workerThreads;
            int completionPortThreads;
            ThreadPool.GetAvailableThreads(out workerThreads, out completionPortThreads);
            Trace("Available thread count: worker = {0}, completion port = {1}", workerThreads, completionPortThreads);
        }
    }
}

Here is the output:

[9] Available thread count: worker = 1023, completion port = 1000
[9] About to wait for Run.
[6] Available thread count: worker = 1021, completion port = 1000
[9] --------------------------------------------------
[9] Available thread count: worker = 1023, completion port = 1000
[9] Available thread count: worker = 1023, completion port = 1000
[9] About to wait for RunAsync.
[9] Press any key ...

What am I missing?

mark
  • 59,016
  • 79
  • 296
  • 580
  • You have one fairly important difference difference between the two methods, your first method is more equivalent to having `await sem.WaitAsync().ConfigureAwait(false);` in your second method. Once you get the code behaving the same by putting a delay in like the answers suggest try running your test program inside the `OnClick` of a button instead of in a console app. – Scott Chamberlain Sep 03 '14 at 22:28

2 Answers2

9

async-await optimizes for when the task you're awaiting on has already completed (which is the case when you have a semaphore set to 10 with only 1 thread using it). In that case the thread just carries on synchronously.

You can see that by adding an actual asynchronous operation to RunAsync and see how it changes the thread pool threads being used (which would be the behavior when your semaphore is empty and the caller actually needs to wait asynchronously):

public static async Task RunAsync(SemaphoreSlim sem)
{
    TraceThreadCount();
    await Task.Delay(1000);
    await sem.WaitAsync();
    TraceThreadCount();
    sem.Release();
}

You can also make this change to Run and have it execute the continuation synchronously and get the same results as in your RunAsync (thread count wise):

public static Task Run(SemaphoreSlim sem)
{
    TraceThreadCount();
    return sem.WaitAsync().ContinueWith(t =>
    {
        TraceThreadCount();
        sem.Release();
    }, TaskContinuationOptions.ExecuteSynchronously);
}

Output:

[1] Available thread count: worker = 1023, completion port = 1000  
[1] Available thread count: worker = 1023, completion port = 1000  
[1] About to wait for Run.  
[1] --------------------------------------------------  
[1] Available thread count: worker = 1023, completion port = 1000  
[1] Available thread count: worker = 1023, completion port = 1000  
[1] About to wait for RunAsync.  
[1] Press any key ...  

Important Note: When it's said that async-await acts as a continuation it's more of an analogy. There are several critical difference between these concepts, especially regarding SynchronizationContexts. async-await automagically preserves the current context (unless you specify ConfigureAwait(false)) so you can use it safely in environments where that matters (UI, ASP.Net, etc.). More about synchronization contexts here.

Also, await Task.Delay(1000); may be replaced with await Task.Yield(); to illustrate that the timing is irrelevant and just the fact that the method waits asynchronously matters. Task.Yield() is often useful in unit tests of asynchronous code.

Palec
  • 12,743
  • 8
  • 69
  • 138
i3arnon
  • 113,022
  • 33
  • 324
  • 344
  • 1
    If he was running in a environment with a `SynchronizationContext` wouldn't the `ExecuteSynchronously` make `Run` behave differently than `RunAsync` (I may be wrong, here. If I am please let me know, I want to learn) EDIT: I see your latest ninja-edit (a sub 5 minute edit) addresses that. – Scott Chamberlain Sep 03 '14 at 22:36
  • Actually there's a little difference in behavior. Async method will switch context to the original, so the second call of TraceThreadCount() will always print the same thread id as the first call. But the continuation version will print different results, depending on semaphore's state. In this case, semaphore is always open, so TraceThreadCount() will execute on the caller's context. But if we block semaphore for a while, the result will be different. – Eldar Dordzhiev Sep 03 '14 at 22:39
  • Couldn't `await Task.Delay(1000);` be replaced with `await Task.Yield();`? I think it would better illustrate the fact that the duration is not important, just the fact that the method really goes asynchronous. – Palec Jan 09 '22 at 08:28
  • 1
    @Palec it could, but it's esoteric and not commonly known. So that adds another concept to the explanation, making it more complex. – i3arnon Jan 09 '22 at 10:00
  • @Palec it could be a useful addition to the answer at the end, after the original question is answered simply (e.g. "Task.Delay can be replaced by just Task.Yield"). Feel free to add such a part to the answer. – i3arnon Jan 09 '22 at 10:01
0

They won't as when you're calling async method, it's starting immediately. So, as long as your semaphore is not locked, WaitAsync() won't even start and there'll be no context switching (it's kind of optimization, same is applied to the canceled tasks), so your async method will be synchronous.

Meanwhile continuation version will actually start continuation on the parallel thread.

Eldar Dordzhiev
  • 5,105
  • 2
  • 22
  • 26