Waaaaay oversimplified async/await plumbing
Often, when someone asks "how does this async await stuff actually work"? There is a lot of hand waving or someone says "just use reflection and look at it" but the real compiled code is a complex recursive state machine. So I want to show a (relatively) simplified example that isn’t the real thing but is conceptually correct.
Conceptually, the way I think about it is that the compiler just breaks down my method into a series of tasks that need to be run. Lets start with a simple scenario which has a single await.
We kind of hide the Task by default but if you change it to look like this, you can see we aren't awaiting the method, we are awaiting the Task returned from the method.
Now we can take the async/await keywords away by breaking the method up into 2 areas – the code that runs before the await, and the code that runs after the await. Because the GetCountAsync() call is async, we can’t run that “after” code immediately. We want to wait until after countTask is complete and then run the “after” code. So what actually runs looks more like this
As you can see, we have taken the “after” code and told it to run as a continuation of the countTask. This means that the GetCountAsync method will run and whenever it gets done, the “after” code will execute. Of course this means we have another task to deal with. The “after” code continuation provides us a new Task object to know when that is done. When that is finished we know that our entire method is finished. So we can return that final Task to the caller of our method so that they will know when we are complete.
Now this is definitely not a complete picture (e.g. we are not dealing with looping or exceptions or managing the UI thread). But I find this a pretty compelling way to demonstrate a couple of key principles.
- When you use the async keyword, your method becomes asynchronous because it gets broken into Tasks based on the await keywords in use.
- Your async method will return before it completes. Yes, that's the whole point and is obvious to some people, but here it is shown explicitly because in the final example above, our method isn’t doing any real work except creating the Task chain and then returning.
- The code before the first await always runs synchronously. There is no Task management going on until you hit the first await. So if you have a 10 second operation before the first await, the method will take 10 seconds and then hook up the task chain and return.
- This is why your callstack looks different when debugging. The “after” code is not being called from DoWorkWithoutAwait(). It is being called directly from the .NET Task infrastructure as a continuation of the previous task.
Taking that a bit further
Lets take that concept and apply it to a more realistic method which contains multiple awaits.
In the same steps, we can think of this as dividing our method up into a number of intermediate Tasks separated by the await keywords.
Again, this just demonstrates those same points that I see some developers forget or struggle with. When I see a series of await keywords, I’m thinking about how that method gets broken down into individual tasks – not how it is going to run as a single unit. This helps me remember that even if this ends up running on a single thread, the fact that is gets chunked up means that other scheduled code can potentially run in between my method’s various sections. You can also see this concept graphically in this previous post.