3

I think I've read around 20 async/await articles, multiple questions on SO and I still have lots of gaps in understanding how it works, especially when multiple threads are involved and when its all done using one thread.

I wrote myself this piece of code to test one scenario:

static async Task Main(string[] args)
        {
            var longTask = DoSomethingLong();

            for (int i = 0; i < 1000; i++)
                Console.Write(".");

            await longTask;
        }

        static async Task DoSomethingLong()
        {
            await Task.Delay(10);
            for (int i = 0; i < 1000; i++)
                Console.Write("<");
        }

I'm explicitly letting my Main continue with writing the dots to the console while the execution is blocked on delay inside DoSomethingLong. I see that after delay ends, the dots writing and < sign writing start interfering with each other.

I just can't figure out: is it two threads that are doing this work simultaneously? One writes the dots and the other writes the other characters? Or is it somehow one thread but switching between those contexts? I would say it's the first option but still I am not sure. When can I expect an additional thread to be created when using async/await? This is still not clear to me.

Also is it always the same thread that will execute Mai, reach the await and then come back to write the dots?

agnieszka
  • 14,897
  • 30
  • 95
  • 113
  • In my opinion this is two thread. The first one (main one ?) Starts the second one when you called for an async function execution. – Pomme De Terre Aug 10 '19 at 09:09
  • Are you sure the dots interfer with the `<`? Logically (and when running it), they don't. – Haytam Aug 10 '19 at 09:12
  • @Haytam I'm sure. Try running it several times, this yields different results, but you can see that sometimes they can interfere. – agnieszka Aug 10 '19 at 09:17
  • My answer was completely wrong, although I still can't make them overlap.. Maybe it depends on if it can print those 1000 dots in less than 10 milliseconds. – Haytam Aug 10 '19 at 09:19
  • In short what happens is Main runs on thread 1 (number doesn’t matter), calls DoSomethingLong which behaves like a normal method until it hits await, control returns to Main which prints it dots. Meanwhile Task.Delay finishes (which may or may not use a thread as tasks don’t relate to threads, see [There Is No Thread](https://blog.stephencleary.com/2013/11/there-is-no-thread.html)), the rest of DoSomethingLong is a continuation of Task.Delay which now runs on a random threadpool thread 2. `await longtask` now waits on task 1 until DoSomethingLong finishes. In short there are at least 2 threads – ckuri Aug 10 '19 at 09:24
  • 1
    Perhaps everyone could stop insisting that async/await is relying on threads, tat would be a good start. Here's a good description https://stackoverflow.com/questions/37419572/if-async-await-doesnt-create-any-additional-threads-then-how-does-it-make-appl also here https://blog.stephencleary.com/2013/11/there-is-no-thread.html – DavidG Aug 10 '19 at 09:26
  • 2
    Those explain the situation where a single code path runs and waits for some operation. They don't explain this situation where two paths can run since the first one does *not* wait for the second to finish before continuing itself. The cases are very different. – Sami Kuhmonen Aug 10 '19 at 09:35
  • @DavidG moreover (I read this article before) it talks about a situation when I/O-bound operation is executed and there is no thread doing the I/O - which is correct. – agnieszka Aug 10 '19 at 09:44
  • Be aware that the answers below are only valid for Console apps. – H H Aug 12 '19 at 11:57

2 Answers2

2
var longTask = DoSomethingLong();

This line creates a Task. This is a different task compared to a task created by Task.Run(). This is a task based on a state machine, auto-generated by the C# compiler. Here is what the compiler generates (copy-pasted from sharplab.io):

using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security;
using System.Security.Permissions;
using System.Threading.Tasks;

[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default
    | DebuggableAttribute.DebuggingModes.DisableOptimizations
    | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints
    | DebuggableAttribute.DebuggingModes.EnableEditAndContinue)]
[assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)]
[assembly: AssemblyVersion("0.0.0.0")]
[module: UnverifiableCode]
public class Program
{
    [CompilerGenerated]
    private sealed class <DoSomethingLong>d__0 : IAsyncStateMachine
    {
        public int <>1__state;

        public AsyncTaskMethodBuilder <>t__builder;

        private int <i>5__1;

        private TaskAwaiter <>u__1;

        private void MoveNext()
        {
            int num = <>1__state;
            try
            {
                TaskAwaiter awaiter;
                if (num != 0)
                {
                    awaiter = Task.Delay(10).GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        num = (<>1__state = 0);
                        <>u__1 = awaiter;
                        <DoSomethingLong>d__0 stateMachine = this;
                        <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
                        return;
                    }
                }
                else
                {
                    awaiter = <>u__1;
                    <>u__1 = default(TaskAwaiter);
                    num = (<>1__state = -1);
                }
                awaiter.GetResult();
                <i>5__1 = 0;
                while (<i>5__1 < 1000)
                {
                    Console.Write("<");
                    <i>5__1++;
                }
            }
            catch (Exception exception)
            {
                <>1__state = -2;
                <>t__builder.SetException(exception);
                return;
            }
            <>1__state = -2;
            <>t__builder.SetResult();
        }

        void IAsyncStateMachine.MoveNext()
        {
            // ILSpy generated this explicit interface implementation from
            // .override directive in MoveNext
            this.MoveNext();
        }

        [DebuggerHidden]
        private void SetStateMachine(IAsyncStateMachine stateMachine)
        {
        }

        void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
        {
            // ILSpy generated this explicit interface implementation from
            // .override directive in SetStateMachine
            this.SetStateMachine(stateMachine);
        }
    }

    [AsyncStateMachine(typeof(<DoSomethingLong>d__0))]
    [DebuggerStepThrough]
    private static Task DoSomethingLong()
    {
        <DoSomethingLong>d__0 stateMachine = new <DoSomethingLong>d__0();
        stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
        stateMachine.<>1__state = -1;
        AsyncTaskMethodBuilder <>t__builder = stateMachine.<>t__builder;
        <>t__builder.Start(ref stateMachine);
        return stateMachine.<>t__builder.Task;
    }
}

What happens in practice is that the first part of the DoSomethingLong method is executed synchronously. It is the part before the first await. In this specific case there is nothing before the first await, so the call to DoSomethingLong returns almost immediately. It only has to register a continuation with the rest of the code, to run in a thread-pool thread after 10 msec. After the return of the call, this code runs in the main thread:

for (int i = 0; i < 1000; i++)
    Console.Write(".");

await longTask;

While this code is running, the thread-pool is signalled to run the scheduled continuation. It starts running in parallel in a thread-pool thread.

for (int i = 0; i < 1000; i++)
    Console.Write("<");

It is a good thing that the Console is thread-safe, because it is called by two threads concurrently. If it wasn't, your program could crash!

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
1

Since they happen at the same time they are on different threads. It all depends on the context handling the tasks. You can set it to allow only one task to run at one time, or many. By default it handles multiple.

In this case the DoSomething() starts running when you call it. When printing out thread IDs it shows that after the delay another is started to print out the <. By having the await in the method it creates another thread to run it and falls back to run Main because there is no await telling it to wait on it before continuing.

As for will the code continue to run on the same thread that depends on context given and if ConfigureAwait is used. For example, ASP.NET doesn’t have a context so the execution will continue in whatever thread happens to be available. In WPF by default it will run on the same thread as it started, unless ConfigureAwait(false) is used to tell the system it may run in whatever thread available.

In this console example after awaiting the DoSomething() the Main will continue on its thread, not on the one that started the whole program on my test case. This is logical since console program doesn’t have a context either to manage which thread runs which part.

Here's a modified code to test what's happening on the specific implementation:

static async Task Main(string[] args)
{
    Console.WriteLine("Start: " + Thread.CurrentThread.ManagedThreadId);
    var longTask = DoSomethingLong();
    Console.WriteLine("After long: " + Thread.CurrentThread.ManagedThreadId);

    for (int i = 0; i < 1000; i++)
        Console.Write(".");

    Console.WriteLine("Before main wait: " + Thread.CurrentThread.ManagedThreadId);
    await longTask;
    Console.WriteLine("After main wait: " + Thread.CurrentThread.ManagedThreadId);
}

static async Task DoSomethingLong()
{
    Console.WriteLine("Before long wait: " + Thread.CurrentThread.ManagedThreadId);
    await Task.Delay(10);
    Console.WriteLine("After long wait: " + Thread.CurrentThread.ManagedThreadId);
    for (int i = 0; i < 1000; i++)
        Console.Write("<");
}

On mine (.NET Core 3.0 preview 7) it will display this:

Start: 1
After long: 1
Before long wait: 1
After long wait: 4
Before main wait: 1
After main wait: 4

The last one may be 1 or 4 on my system depending on which thread happens to be available. This shows how awaiting for Task.Delay() will drop out of the DoSomethingLong() method, resume running Main, and after the delay it needs another thread to continue running DoSomethingLong() since the original thread is busy doing other things.

Interestingly (at least to me), even when running DoSomethingLong() directly with await the ManagedThreadIds do the same thing. I would've expected there to be only thread ID 1 in that case, but that's not what the implementation does it seems.

Sami Kuhmonen
  • 30,146
  • 9
  • 61
  • 74
  • 2
    Please stop saying async is about running on different threads, that's just not true. – DavidG Aug 10 '19 at 09:28
  • @DavidG I’m saying what this program in this case does. I never said async is about it. It just manifests as such. Or maybe you can explain the exact wording to use when two tasks are run on separate threads simultaneously if it’s not threads? – Sami Kuhmonen Aug 10 '19 at 09:29
  • Read the links I added above, there are no threads, and saying so (even as a metaphor) is incorrect. – DavidG Aug 10 '19 at 09:31
  • 1
    @DavidG So when .NET tells me there's different threads running there's no threads? You're claiming threre's *never* any threads? Seriousy I do understand the regular case where there isn't, but in this case two threads run simultaenously running two tasks. That's what .NET even tells itself. So do explain how there's no threads, please. The links you've given work in a case where there's *a single path of code waiting for things*, not *two paths running simultaenously*. – Sami Kuhmonen Aug 10 '19 at 09:33
  • So in this particular case 2 threads are used and we get some parallelism. But if for example I would await the DoSomethingLong in a line where I start it, I could still use 2 threads (one before the await, a different one after it), but have no paralellism right? What would my thread 1 do when it hits await? It will get back to threadpool because there is no work for hi mto do? – agnieszka Aug 10 '19 at 09:53
  • @agnieszka Yes, it seems that at least on the implementation I am using (Core 3.0-Preview7 on Win10 x64) two threads are created without parallelism even when awaiting directly. The first thread will go to the pool to wait for things to do in that case. – Sami Kuhmonen Aug 10 '19 at 09:56