-1

Say we have the following program

public class Program
{
    public static async Task Main(string[] args)
    {
        var x = 5;
        var y = await FooAsync();
        var z = x + y;
    }

    public static async Task<int> FooAsync()
    {
        var p = "foo";
        await Task.Delay(100);
        return p.GetHashCode();
    }
}

After the "true async work" launched from Task.Delay is initiated, there exists a call stack like

---------------------------------
             ...
---------------------------------
 p: "foo"
 return value: (unset)
---------------------------------
 continueOnCapturedContext: true
 x: 5
 y: (unset)
 z: (unset)
 return value: (none)
---------------------------------

and some sort of pointer to

---> await Task.Delay(100);

so that when this async work completes (via some interrupt), execution can continue where it left off.

A few questions:

  • Where is this call stack, in terms of a .NET type I can experiment with in Visual Studio?
  • My understanding is that the thread which executes this "remainder" could be any thread. So let's say there are only threads A, B, C when the program launches and say A ran up until the async work began. I know A is then considered "done" and returns to some zero state (ThreadState.Stopped? ThreadState.Unstarted?) so that it could be used for more work if there was more. How does the thread which is chosen to execute the raminer, which could be any of A, B or C, get associated with the call stack? I don't see any constructors for Thread which give it this info.
  • `My understanding is that the thread which executes this "remainder" could be any thread` -- *including the calling thread.* The awaited method is signed onto the current thread as a continuation while the calling method goes on its merry way. – Robert Harvey Nov 30 '20 at 22:56
  • 1
    Its hard to answer this, because there are lot of assumptions and a lot of preconceptions that are off the mark. I would suggest just playing with a decompiler or Sharp IO – TheGeneral Nov 30 '20 at 23:00
  • 1
    (1) your app almost always has a Thread Pool, managed by the runtime, and thread pool gives your app bonus threads that you did not start, that you don't control, that get spawned/killed at thread pool's discretion, and that often just stay idle and wait for any occasion to grab something waiting from (2) the job/task/event queue associated with the thread pool. (3) read a lot about `ConfigureAwait`, it should explain to you exactly which thread will run the continuation. (1+2+3) makes up most of the answer to your question and fixes up most of your mis-assumptions – quetzalcoatl Nov 30 '20 at 23:01
  • 1
    oh, and regarding "this callstack" you ask about -- it's not really a callstack, it's more like a lambda with closure object that captured some variables... The actual `callstack` is also _recorded_ because it's very important for exception handling - you WANT to know the "trail" when a continuation of continuation of continuation throws an exception and you need to diagnose it post-mortem - but in fact, the callstack is not really needed for executing continuations. Play with the Threading.Task object and its .ContinueWith() method and observe how you can chain them. It's almost async/await. – quetzalcoatl Nov 30 '20 at 23:06
  • The concept you're thinking of is called "fibers", and they do exist. But they're not used to implement `async`/`await`. `async` is implemented with callbacks. You can see this yourself by having several `async` methods all calling each other, and then break into the debugger after the topmost `await`. The call stack for that method has the methods in "opposite" order because they're implemented as callbacks. – Stephen Cleary Dec 01 '20 at 00:20

1 Answers1

0

Resuming async functions leads to more of a return stack, rather than a call stack.

When you await and the task has not yet completed, a continuation callback is registered. Then the method returns. The stack that existed before is unwound. And each await keyword encountered in the stack is turned into another callback. Any other state like "local" variables will be written to fields on a generated class so they can be reloaded later.

When an async method resumes, the old stack is not reconstructed. When an async method returns, this is actually compiled into a method call, which immediately executes any registered callbacks.

Jeremy Lakeman
  • 9,515
  • 25
  • 29